using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Federation { // FEP-8fcf (owner decision 2026-10-06): a delivery addressed to a persona's followers tells the receiving server, in a signed // header, a digest of the persona's followers there; the roll-call it names lists them, to that server only [Trait("Category", "Integration")] [Xunit.Collection(nameof(Exclusive))] public sealed class FollowersSynchronizationTests : IAsyncLifetime { static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; Harness _harness; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _harness = await Harness.Start(); await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); } public async ValueTask DisposeAsync() { if (_harness != default) await _harness.DisposeAsync(); } static CancellationToken Token => TestContext.Current.CancellationToken; [Fact] public void The_digest_is_the_xor_of_each_ids_sha256_whatever_the_order() { Assert.Equal(new string('0', 64), FollowersSynchronization.Digest([])); Assert.Equal(FollowersSynchronization.Digest(["https://a.example/users/one", "https://a.example/users/two"]), FollowersSynchronization.Digest(["https://a.example/users/two", "https://a.example/users/one"])); Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes("https://a.example/users/one"))), FollowersSynchronization.Digest(["https://a.example/users/one"])); } async Task Delivered(LocalActor signer, string inbox, JsonObject activity) { await _harness.Delivery.Enqueue(signer, new[] { inbox }, activity, Token); var job = await DB.Default.Find().Match(j => j.DedupeKey == $"{activity["id"]!.GetValue()}|{inbox}").ExecuteSingleAsync(Token); var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger.Instance); Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result); return Assert.Single(_harness.Peer.Requests, r => r.Body?.Contains(activity["id"]!.GetValue()) == true); } static JsonObject Create(LocalActor author, params string[] to) => new() { ["id"] = $"{author.Uri}/grunts/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Uri, ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()), ["object"] = new JsonObject { ["type"] = "Note", ["content"] = "hi", ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) } }; [Fact] public async Task A_delivery_to_followers_carries_a_signed_digest_of_the_followers_on_that_server_only() { var (_, alice) = await _harness.Persona("alice"); var one = new RemoteActor(_harness.Peer, "one"); var two = new RemoteActor(_harness.Peer, "two"); var far = new RemoteActor(_harness.Peer, "far", _harness.Peer.B); await _harness.FollowedBy(alice, one); await _harness.FollowedBy(alice, two); await _harness.FollowedBy(alice, far); await DB.Default.SaveAsync(new Follower { LocalActorId = alice.Id, LocalActorKind = alice.Kind, ActorURI = _harness.Peer.A + "/users/asking", InboxURL = _harness.Peer.A + "/users/asking/inbox", IsAccepted = false }, Token); _harness.Peer.Answer("/inbox", 202); var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, alice.Followers)); Assert.Equal(alice.Followers, Value(received, "collectionId")); Assert.Equal(alice.Followers + "/roll-call", Value(received, "url")); Assert.Equal(FollowersSynchronization.Digest([one.Id, two.Id]), Value(received, "digest")); var signature = HttpSignatures.Parse(received.Signature); Assert.Equal(new[] { "(request-target)", "host", "date", "digest", "collection-synchronization" }, signature.Headers); var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}\n" + $"collection-synchronization: {received.Headers[FollowersSynchronization.Header]}"; using var key = RSA.Create(); key.ImportFromPem(alice.PublicKeyPem); Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); } static string Value(HttpRequestRecord received, string name) { var values = received.Headers[FollowersSynchronization.Header].Split(',', StringSplitOptions.TrimEntries) .Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1].Trim('"')); return values[name]; } [Fact] public async Task A_delivery_that_is_not_for_followers_carries_none() { var (_, alice) = await _harness.Persona("alice"); var recipient = new RemoteActor(_harness.Peer, "recipient"); await _harness.FollowedBy(alice, recipient); _harness.Peer.Answer("/inbox", 202); var received = await Delivered(alice, _harness.Peer.A + "/inbox", Create(alice, recipient.Id)); Assert.False(received.Headers.ContainsKey(FollowersSynchronization.Header)); Assert.DoesNotContain("collection-synchronization", HttpSignatures.Parse(received.Signature).Headers); } // Mastodon undoes a follow it never knew of with the same id each time ({actor}#follows//undo): when it comes again, // after a new follow, it ends that one too; a copy of it, with nothing to end, stays a copy [Fact] public async Task An_undo_of_a_follow_sent_again_after_a_new_follow_ends_that_one_too() { var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); var undo = new JsonObject { ["id"] = bob.Id + "#follows//undo", ["type"] = "Undo", ["actor"] = bob.Id, ["object"] = new JsonObject { ["id"] = bob.Id + "#follows/", ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri } }; Task Follows() => DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(Token); await _harness.FollowedBy(alice, bob); await _harness.Deliver(bob, "/human-centipede", undo.DeepClone()); Assert.False(await Follows()); await _harness.FollowedBy(alice, bob); await _harness.Deliver(bob, "/human-centipede", undo.DeepClone()); Assert.False(await Follows()); Assert.Equal("duplicate", (await _harness.Deliver(bob, "/human-centipede", undo.DeepClone())).Reason); } } [Trait("Category", "Integration")] public sealed class RollCallTests : IAsyncLifetime { PrivaPubHost _host; HttpClient _client; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { _client?.Dispose(); if (_peer != default) await _peer.DisposeAsync(); } static string[] Items(JsonObject collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToArray(); [Fact] public async Task The_roll_call_lists_the_followers_on_the_signing_server_and_asks_for_a_signature() { var token = TestContext.Current.CancellationToken; var persona = await _host.Persona(await _host.SignUp(), "rollcall"); var near = new RemoteActor(_peer, "near"); var server = new RemoteActor(_peer, "instance", type: "Application"); var far = new RemoteActor(_peer, "far", _peer.B); foreach (var follower in new[] { near, far }) await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token); var path = $"/peasants/{persona.UserName}/groupies/roll-call"; var asServer = await _client.Fetch(server.SignedGet(path)); var asFar = await _client.Fetch(far.SignedGet(path)); Assert.Equal(HttpStatusCode.OK, asServer.Status); Assert.Equal(persona.ActorUri() + "/groupies/roll-call", asServer.Json["id"]!.GetValue()); Assert.Equal(new[] { near.Id }, Items(asServer.Json)); Assert.Equal(new[] { far.Id }, Items(asFar.Json)); Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Fetch(path)).Status); } } }