Forwarded activities are believed as far as their origin vouches
A thread's server passes on what happens in it, signed with its own key: Mastodon forwards the replies to its accounts' posts and their deletions, Friendica every activity in its threads. PrivaPub answered them 401, which also tells a sender its signature failed. Now they get 202 and nothing in them is believed: a forwarded Create or Update is taken as its object reads at the actor's origin, a Delete of a public or unlisted copy once that origin answers 404 or 410 (RemoteActorService.IsGone; FederationHttp remembers the status of a refusal), anything else is let go, and our own activities coming back are ignored. A forwarded copy has its own dedupe key, so one that failed never hides the author's own delivery. A reply in the thread of someone followed here is kept, as Mastodon keeps them. Mastodon delivers a reply to the followers of the account it answers; PrivaPub dropped those as unaddressed, which the pasture showed: the outsider's reply its Mastodon scenario said was never delivered had been, and was thrown away. 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
01808fa644
commit
7eb7c017a5
12 files changed
+334
-21
No files matched your search
@@ -0,0 +1,167 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Tests.Support.FederatedSeeds;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
// Inbox forwarding: a thread's server passes on the activities in it, signed with its own key (Mastodon the replies to
|
||||
// its accounts' posts and their deletions, Friendica everything in its threads). They used to be refused with 401.
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ForwardedTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
|
||||
|
||||
PrivaPub.Models.Statistics.InteractionEvent Received(string activity) => _harness.Ledger.Of("recv").Last(e => e.Activity == activity);
|
||||
|
||||
Task<Post> Stored(string objectUri) =>
|
||||
DB.Default.Find<Post>().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// alice follows the thread's author (on its own server), whose post she holds; bob, elsewhere, replies to it
|
||||
async Task<(RemoteActor Owner, RemoteActor Bob, JsonObject Reply)> Thread()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var owner = new RemoteActor(_harness.Peer, "owner", _harness.Peer.B);
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
await Follows(alice.Id, owner);
|
||||
var post = PublicNote(owner, "<p>the thread</p>");
|
||||
await _harness.Deliver(owner, "/human-centipede", Create(owner, post));
|
||||
Assert.NotNull(await Stored(IdOf(post)));
|
||||
|
||||
var reply = PublicNote(bob, "<p>bob's reply as he wrote it</p>", owner.Id);
|
||||
reply["inReplyTo"] = IdOf(post);
|
||||
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, reply.ToJsonString());
|
||||
return (owner, bob, reply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reply_the_threads_server_forwards_is_kept_as_its_author_wrote_it()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
var claimed = (JsonObject)reply.DeepClone();
|
||||
claimed["content"] = "<p>what the forwarder says bob wrote</p>";
|
||||
|
||||
var result = await _harness.Deliver(owner, "/human-centipede", Create(bob, claimed));
|
||||
|
||||
Assert.Equal((202, "forwarded"), (result.StatusCode, result.Reason));
|
||||
var stored = await Stored(IdOf(reply));
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(bob.Id, stored.ActorURI);
|
||||
Assert.Contains("as he wrote it", stored.ContentHtml);
|
||||
Assert.Equal(("accepted", "stored"), (Processed("Create").Outcome, Processed("Create").Reason));
|
||||
Assert.True((await DB.Default.Find<PrivaPub.Models.Federation.ObjectRecord>().Match(r => r.ObjectURI == IdOf(reply)).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Refetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forwarded_edit_applies_the_post_as_its_origin_has_it_now()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
|
||||
var edited = (JsonObject)reply.DeepClone();
|
||||
edited["content"] = "<p>bob's reply, edited</p>";
|
||||
edited["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
|
||||
_harness.Peer.Serve(new Uri(IdOf(reply)).AbsolutePath, edited.ToJsonString());
|
||||
var claimed = (JsonObject)edited.DeepClone();
|
||||
claimed["content"] = "<p>an edit bob never made</p>";
|
||||
|
||||
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Update", claimed));
|
||||
|
||||
Assert.Contains("bob's reply, edited", (await Stored(IdOf(reply))).ContentHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forwarded_deletion_waits_for_the_origin_to_say_the_post_is_gone()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
|
||||
|
||||
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
|
||||
Assert.Equal(("dropped", "forwarded-not-gone"), (Processed("Delete").Outcome, Processed("Delete").Reason));
|
||||
Assert.NotNull(await Stored(IdOf(reply)));
|
||||
|
||||
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 410);
|
||||
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
|
||||
Assert.Null(await Stored(IdOf(reply)));
|
||||
}
|
||||
|
||||
// the origin answers 404 for a followers-only post to our instance actor too: only its author's server says it is gone
|
||||
[Fact]
|
||||
public async Task A_forwarded_deletion_of_a_followers_only_post_is_left_to_its_author()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
await _harness.Deliver(owner, "/human-centipede", Create(bob, reply));
|
||||
await DB.Default.Update<Post>().Match(p => p.ObjectURI == IdOf(reply)).Modify(p => p.Visibility, PostVisibility.FollowersOnly).ExecuteAsync(TestContext.Current.CancellationToken);
|
||||
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404);
|
||||
|
||||
await _harness.Deliver(owner, "/human-centipede", Activity(bob, "Delete", JsonValue.Create(IdOf(reply))!));
|
||||
|
||||
Assert.Equal(("dropped", "forwarded-private"), (Processed("Delete").Outcome, Processed("Delete").Reason));
|
||||
Assert.NotNull(await Stored(IdOf(reply)));
|
||||
}
|
||||
|
||||
// a forwarded copy is kept apart from the author's own delivery, which carries what the copy could not
|
||||
[Fact]
|
||||
public async Task The_authors_own_delivery_is_still_taken_after_a_forwarded_copy_that_failed()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
_harness.Peer.Answer(new Uri(IdOf(reply)).AbsolutePath, 404);
|
||||
var create = Create(bob, reply);
|
||||
|
||||
await _harness.Deliver(owner, "/human-centipede", create);
|
||||
Assert.Null(await Stored(IdOf(reply)));
|
||||
|
||||
var direct = await _harness.Deliver(bob, "/human-centipede", create);
|
||||
Assert.Equal("queued", direct.Reason);
|
||||
Assert.NotNull(await Stored(IdOf(reply)));
|
||||
}
|
||||
|
||||
// what cannot be checked against its origin (a vote, a follow, someone else's post) is let go, answered 202: a 401
|
||||
// tells the forwarder its signature failed
|
||||
[Fact]
|
||||
public async Task What_a_forwarder_cannot_vouch_for_is_let_go_without_an_error()
|
||||
{
|
||||
var (owner, bob, reply) = await Thread();
|
||||
var like = new JsonObject { ["id"] = NewId(bob, "likes"), ["type"] = "Like", ["actor"] = bob.Id, ["object"] = IdOf(reply) };
|
||||
|
||||
var result = await _harness.Deliver(owner, "/human-centipede", like);
|
||||
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
|
||||
Assert.Equal("dropped", Received("Like").Outcome);
|
||||
|
||||
var onTheForwardersServer = PublicNote(owner, "<p>a post of the forwarder's</p>");
|
||||
onTheForwardersServer["attributedTo"] = bob.Id;
|
||||
result = await _harness.Deliver(owner, "/human-centipede", Create(bob, onTheForwardersServer));
|
||||
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
|
||||
Assert.Null(await Stored(IdOf(onTheForwardersServer)));
|
||||
|
||||
// our own, coming back from a thread on another server
|
||||
var (_, carol) = await _harness.Persona("carol");
|
||||
var ours = new JsonObject
|
||||
{
|
||||
["id"] = carol.Uri + "/grunts/create-" + Guid.NewGuid().ToString("N"),
|
||||
["type"] = "Create",
|
||||
["actor"] = carol.Uri,
|
||||
["object"] = new JsonObject { ["id"] = carol.Uri + "/scribbles/" + Guid.NewGuid().ToString("N"), ["type"] = "Note", ["attributedTo"] = carol.Uri }
|
||||
};
|
||||
result = await _harness.Deliver(owner, "/human-centipede", ours);
|
||||
Assert.Equal((202, "forwarded-ignored"), (result.StatusCode, result.Reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,8 @@ namespace PrivaPub.Tests.Support
|
||||
public async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
||||
{
|
||||
var result = await Receiver.Receive(sender.Post(Host, path, activity), default, CancellationToken.None);
|
||||
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
||||
var id = activity is JsonObject ? activity["id"]?.GetValue<string>() : default;
|
||||
var dedupe = (result.Reason == "forwarded" ? "inbox|forwarded|" : "inbox|") + id;
|
||||
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync();
|
||||
if (job != default)
|
||||
Assert.Equal(JobResult.Done, (await Processor.Handle(job, CancellationToken.None)).Result);
|
||||
|
||||
Reference in new issue
Block a user