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.Models.Social; 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); } // received: bob's delivery claims a digest of his followers here, and lists them at his own synchronisation URL async Task Claimed(RemoteActor bob, string digest, params string[] listed) { var path = new Uri(bob.Id).AbsolutePath + "/followers/sync"; _harness.Peer.Serve(path, new JsonObject { ["id"] = bob.Id + "/followers/sync", ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(listed.Select(l => (JsonNode)l).ToArray()) }.ToJsonString()); var note = $"{bob.Id}/notes/{Guid.NewGuid():N}"; var request = bob.Post(Harness.Host, "/human-centipede", new JsonObject { ["id"] = note + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["object"] = new JsonObject { ["id"] = note, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(bob.Id + "/followers"), ["content"] = "hi" } }); request.Headers[FollowersSynchronization.Header] = $"collectionId=\"{bob.Id}/followers\", url=\"{bob.Id}/followers/sync\", digest=\"{digest}\""; await _harness.Receiver.Receive(request, default, Token); var job = await DB.Default.Find().Match(j => j.Kind == JobKind.SynchronizeFollowing && j.State == JobState.Pending && j.Payload.Contains(bob.Id)) .ExecuteSingleAsync(Token); var handler = new FollowingSynchronization(_harness.Db, _harness.Local, _harness.Remote, _harness.Follows, _harness.Delivery); Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result); } async Task Follows(LocalActor persona, RemoteActor bob, FollowState state) { var following = new Following { AvatarId = persona.Id, TargetActorURI = bob.Id, TargetInboxURL = bob.Id + "/inbox", State = state, FollowActivityURI = persona.ActivityUri($"follow-{Guid.NewGuid():N}") }; await DB.Default.SaveAsync(following, Token); return following; } Task Following(LocalActor persona, RemoteActor bob) => DB.Default.Find().Match(f => f.AvatarId == persona.Id && f.TargetActorURI == bob.Id).ExecuteFirstAsync(Token); [Fact] public async Task A_claim_that_differs_is_mended_from_the_accounts_own_list() { var (_, alice) = await _harness.Persona("alice"); var (_, carol) = await _harness.Persona("carol"); var (_, dave) = await _harness.Persona("dave"); var bob = new RemoteActor(_harness.Peer, "bob"); await Follows(alice, bob, FollowState.Accepted); await Follows(carol, bob, FollowState.Requested); await Claimed(bob, FollowersSynchronization.Digest([carol.Uri, dave.Uri]), carol.Uri, dave.Uri); Assert.Null(await Following(alice, bob)); Assert.Equal(FollowState.Accepted, (await Following(carol, bob)).State); var undos = (await _harness.Outgoing(bob.Id + "/inbox")).Where(a => a["type"]!.GetValue() == "Undo").ToList(); Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue()).Order()); var daves = Assert.Single(undos, u => u["actor"]!.GetValue() == dave.Uri); Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue(), daves["object"]!["object"]!.GetValue())); } [Fact] public async Task A_list_the_claim_does_not_describe_ends_no_follow() { var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); await Follows(alice, bob, FollowState.Accepted); await Claimed(bob, FollowersSynchronization.Digest(["https://privapub.test/peasants/someone"])); Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); } [Fact] public async Task A_claim_that_agrees_reads_nothing() { var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); await Follows(alice, bob, FollowState.Accepted); await Claimed(bob, FollowersSynchronization.Digest([alice.Uri])); Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path.EndsWith("/followers/sync")); Assert.Equal(FollowState.Accepted, (await Following(alice, bob)).State); } } [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); } } }