A sender's digest of its followers here is compared and mended
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
This commit is contained in:
1 parent
bd18de35a6
commit
e0682fa868
11 files changed
+291
-12
No files matched your search
@@ -10,6 +10,7 @@ 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;
|
||||
|
||||
@@ -146,6 +147,87 @@ namespace PrivaPub.Tests.Federation
|
||||
|
||||
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")]
|
||||
|
||||
Reference in new issue
Block a user