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 Stored(string objectUri) => DB.Default.Find().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, "

the thread

"); await _harness.Deliver(owner, "/human-centipede", Create(owner, post)); Assert.NotNull(await Stored(IdOf(post))); var reply = PublicNote(bob, "

bob's reply as he wrote it

", 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"] = "

what the forwarder says bob wrote

"; 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().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"] = "

bob's reply, edited

"; 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"] = "

an edit bob never made

"; 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().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, "

a post of the forwarder's

"); 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)); } } }