FEP-8fcf, received. When a delivery's Collection-Synchronization header digests the sender's followers on PrivaPub
otherwise than the personas following it, a job reads the list the header names (on the sender's origin, signed by the
instance actor): a follow the list leaves out ends, only when the list is the one the digest describes; a request it
lists is taken as accepted; a persona it lists that follows nothing there sends Undo{Follow}, as Mastodon does. Each
claiming delivery is compared once.
Checked live (scenarios/followsync.sh, now 15 checks): PrivaPub ends a follow Mastodon lost and undoes one only
Mastodon remembered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
283 lines
13 KiB
C#
283 lines
13 KiB
C#
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<RemoteInstance>(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<HttpRequestRecord> Delivered(LocalActor signer, string inbox, JsonObject activity)
|
|
{
|
|
await _harness.Delivery.Enqueue(signer, new[] { inbox }, activity, Token);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == $"{activity["id"]!.GetValue<string>()}|{inbox}").ExecuteSingleAsync(Token);
|
|
var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())),
|
|
NullLogger<DeliveryJobHandler>.Instance);
|
|
Assert.Equal(JobResult.Done, (await handler.Handle(job, Token)).Result);
|
|
return Assert.Single(_harness.Peer.Requests, r => r.Body?.Contains(activity["id"]!.GetValue<string>()) == 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<bool> Follows() => DB.Default.Find<Follower>().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<Job>().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<Following> 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> Following(LocalActor persona, RemoteActor bob) =>
|
|
DB.Default.Find<Following>().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<string>() == "Undo").ToList();
|
|
Assert.Equal(new[] { alice.Uri, dave.Uri }.Order(), undos.Select(u => u["actor"]!.GetValue<string>()).Order());
|
|
var daves = Assert.Single(undos, u => u["actor"]!.GetValue<string>() == dave.Uri);
|
|
Assert.Equal(("Follow", bob.Id), (daves["object"]!["type"]!.GetValue<string>(), daves["object"]!["object"]!.GetValue<string>()));
|
|
}
|
|
|
|
[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<string>()).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<string>());
|
|
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);
|
|
}
|
|
}
|
|
}
|