using MongoDB.Entities; using PrivaPub.ClientModels.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using System.Security.Cryptography; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.FederatedSeeds; namespace PrivaPub.Tests.Federation { [Trait("Category", "Integration")] public sealed class InboxGapTests : 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(); } static JsonObject Follow(RemoteActor follower, string target) => new() { ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target }; Task Stored(RemoteActor actor) => DB.Default.Find().Match(f => f.ActorURI == actor.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken); PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity); // a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a // follow it took before it started sending to us, and the community's posts were refused as not followed) [Fact] public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var quiet = new RemoteActor(_harness.Peer, "quiet"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token); var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token); async Task Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue() == "Follow"); var made = following.CreatedAt; Assert.Equal(1, await Follows()); // (the store is shared with other tests' requests: what this one got is counted on its own inbox) async Task After(TimeSpan wait) { var before = await Follows(); await _harness.Follows.ResendPending(made + wait, token); return await Follows() - before; } Assert.Equal(0, await After(TimeSpan.FromMinutes(10))); Assert.Equal(1, await After(TimeSpan.FromMinutes(16))); Assert.Equal(0, await After(TimeSpan.FromMinutes(30))); Assert.Equal(1, await After(TimeSpan.FromMinutes(77))); Assert.Equal(3, await Follows()); // each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue()).ToList(); Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds); // a request older than the count is sent again too; an answered one never await DB.Default.Update().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token); Assert.Equal(1, await After(TimeSpan.FromDays(1))); await _harness.Deliver(quiet, "/human-centipede", new JsonObject { ["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2" }); Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); Assert.Equal(0, await After(TimeSpan.FromDays(30))); } // Pixelfed names our post by its page (/@name/, the post's url) in its Like, Announce and their Undo: the post is // found the same as by its id // Friendica's activity ids are uniqid(), a prefix and the microsecond: two of its processes answering two follows at // once gave both Accepts one id, and the second was dropped as a copy of the first, leaving that follow pending [Fact] public async Task Two_activities_a_server_gave_one_id_are_both_taken_and_a_true_copy_once() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var dario = new RemoteActor(_harness.Peer, "dario"); var jun = new RemoteActor(_harness.Peer, "jun"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = dario.Id }, token); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = jun.Id }, token); Task Of(RemoteActor target) => DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteSingleAsync(token); async Task Accept(RemoteActor by, string id) => new() { ["id"] = id, ["type"] = "Accept", ["actor"] = by.Id, ["object"] = new JsonObject { ["id"] = (await Of(by)).FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = by.Id } }; var shared = NewId(dario, "activity"); var first = await _harness.Deliver(dario, "/human-centipede", await Accept(dario, shared)); var second = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared)); var copy = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared)); Assert.Equal(("queued", "queued", "duplicate"), (first.Reason, second.Reason, copy.Reason)); Assert.Equal(FollowState.Accepted, (await Of(dario)).State); Assert.Equal(FollowState.Accepted, (await Of(jun)).State); } // Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used // to be refused as misattributed (400), so the edit was lost and the deletion left the event in place [Fact] public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var group = new RemoteActor(_harness.Peer, "group", type: "Group"); var organiser = new RemoteActor(_harness.Peer, "organiser"); await Follows(alice.Id, group); var path = $"/events/{Guid.NewGuid():N}"; JsonObject Event(string name) => new() { ["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "

bring bread

", ["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z", ["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers") }; _harness.Peer.Serve(path, Event("A picnic").ToJsonString()); Task Stored() => DB.Default.Find().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token); var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic"))); Assert.Equal(202, created.StatusCode); Assert.Equal(group.Id, (await Stored()).ActorURI); var moved = Event("A picnic, moved indoors"); moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O"); _harness.Peer.Serve(path, moved.ToJsonString()); await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims"))); Assert.Equal("A picnic, moved indoors", (await Stored()).Title); await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); Assert.NotNull(await Stored()); _harness.Peer.Answer(path, 410); await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!)); Assert.Null(await Stored()); // attributed to someone of another server, it is still refused var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B); var claimed = Event("Someone else's"); claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}"; claimed["attributedTo"] = elsewhere.Id; Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode); } // Funkwhale names its Accept after the Follow it answers (`/accept`), on our origin, not its own [Fact] public async Task An_answer_named_after_our_follow_is_taken_and_one_naming_someone_elses_is_not() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var channel = new RemoteActor(_harness.Peer, "channel"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = channel.Id }, token); var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == channel.Id).ExecuteSingleAsync(token); var elsewhere = await _harness.Deliver(channel, "/human-centipede", new JsonObject { ["id"] = "https://privapub.test/peasants/somebody/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = following.FollowActivityURI }); Assert.Equal(400, elsewhere.StatusCode); var named = await _harness.Deliver(channel, "/human-centipede", new JsonObject { ["id"] = following.FollowActivityURI + "/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = new JsonObject { ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = channel.Id } }); Assert.Equal(202, named.StatusCode); Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); } // Funkwhale's channel deletes its uploads in one Delete, their ids in a list as the object's id (and the Delete has // no id of its own) [Fact] public async Task A_delete_naming_several_objects_removes_each() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var channel = new RemoteActor(_harness.Peer, "channel"); await Follows(alice.Id, channel); var first = PublicNote(channel, "

a first track

"); var second = PublicNote(channel, "

a second track

"); await _harness.Deliver(channel, "/human-centipede", Create(channel, first)); await _harness.Deliver(channel, "/human-centipede", Create(channel, second)); Task Held(JsonObject note) => DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteAnyAsync(token); Assert.True(await Held(first) && await Held(second)); var result = await _harness.Receiver.Receive(channel.Post(Harness.Host, "/human-centipede", new JsonObject { ["type"] = "Delete", ["actor"] = channel.Id, ["object"] = new JsonObject { ["id"] = new JsonArray(IdOf(first), IdOf(second)), ["type"] = "Audio" } }), default, token); Assert.Equal(202, result.StatusCode); var listed = IdOf(first); var job = await DB.Default.Find().Match(j => j.Kind == PrivaPub.Models.Jobs.JobKind.ProcessInbox && j.State == PrivaPub.Models.Jobs.JobState.Pending && j.Payload.Contains(listed)).Sort(j => j.CreatedAt, MongoDB.Entities.Order.Descending) .ExecuteFirstAsync(token); await _harness.Processor.Handle(job, token); Assert.False(await Held(first)); Assert.False(await Held(second)); } [Fact] public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var fan = new RemoteActor(_harness.Peer, "fan"); var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post; var page = alice.PostHtmlUrl(post.ID); Assert.NotEqual(post.ObjectURI, page); var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page }; await _harness.Deliver(fan, "/human-centipede", like); Assert.Equal("accepted", Processed("Like").Outcome); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like)); Assert.Equal(0, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); // a page that is no post of ours stays as it is var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") }; await _harness.Deliver(fan, "/human-centipede", elsewhere); Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason)); } [Fact] public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri)); var before = await Stored(bob); using var rotated = RSA.Create(2048); var document = bob.Document(); document["name"] = "Bob Renamed"; document["summary"] = "

a new bio

"; document["publicKey"]!["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem(); _harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString()); var embedded = (JsonObject)document.DeepClone(); embedded["name"] = "What the activity claims"; embedded["updated"] = "2001-01-01T00:00:00Z"; var result = await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", embedded)); var after = await Stored(bob); Assert.Equal(202, result.StatusCode); Assert.Null(before.Name); Assert.Equal(before.ID, after.ID); Assert.Equal("Bob Renamed", after.Name); Assert.Equal("

a new bio

", after.Biography); Assert.Equal(bob.KeyId, after.PublicKeyId); Assert.NotEqual(before.PublicKey, after.PublicKey); Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey); Assert.Equal(("accepted", "actor-refresh"), (Processed("Update").Outcome, Processed("Update").Reason)); } [Fact] public async Task A_key_moved_to_a_new_id_replaces_the_old_one() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri)); using var rotated = RSA.Create(2048); var document = bob.Document(); document["publicKey"] = new JsonObject { ["id"] = bob.Id + "#key-2", ["owner"] = bob.Id, ["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem() }; _harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString()); await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", JsonValue.Create(bob.Id)!)); var after = await Stored(bob); Assert.Equal(bob.Id + "#key-2", after.PublicKeyId); Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey); } [Fact] public async Task Undoing_a_follow_removes_the_follower_by_the_activity_id_or_by_its_object() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); var carol = new RemoteActor(_harness.Peer, "carol"); var bobFollows = Follow(bob, alice.Uri); await _harness.Deliver(bob, "/human-centipede", bobFollows); await _harness.Deliver(carol, "/human-centipede", Follow(carol, alice.Uri)); Assert.Equal(2, await DB.Default.CountAsync(f => f.LocalActorId == alice.Id, token)); await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Undo", JsonValue.Create(IdOf(bobFollows))!)); Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(token)); Assert.Equal(("accepted", "undone"), (Processed("Undo").Outcome, Processed("Undo").Reason)); var forgotten = Follow(carol, alice.Uri); forgotten["id"] = NewId(carol, "follows"); await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten)); Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token)); await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten)); Assert.Equal(("dropped", "unknown-object"), (Processed("Undo").Outcome, Processed("Undo").Reason)); } [Fact] public async Task A_rejected_quote_request_marks_the_quote_rejected_and_only_the_quoted_author_can_reject_it() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var ann = new RemoteActor(_harness.Peer, "ann"); var mallory = new RemoteActor(_harness.Peer, "mallory"); var note = PublicNote(ann, "

ask me first

", alice.Uri); note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" }); note["interactionPolicy"] = new JsonObject { ["canQuote"] = new JsonObject { ["manualApproval"] = new JsonArray(Addressing.Public) } }; await _harness.Deliver(ann, "/human-centipede", Create(ann, note)); var original = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", QuotedStatusId = original.ID }, token); Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState); var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue() == "QuoteRequest"); await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!)); Assert.Equal(QuoteState.Pending, (await DB.Default.Find().OneAsync(outcome.Post.ID, token)).QuoteState); await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request)); var quoting = await DB.Default.Find().OneAsync(outcome.Post.ID, token); Assert.Equal(QuoteState.Rejected, quoting.QuoteState); Assert.Null(quoting.QuoteAuthorizationURI); Assert.Equal(0, (await DB.Default.Find().OneAsync(original.ID, token)).QuotesCount); Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason)); } [Fact] public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var stranger = new RemoteActor(_harness.Peer, "dogs", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); var voter = new RemoteActor(_harness.Peer, "voter"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); var page = PublicNote(poster, "

a post

", community.Id); page["type"] = "Page"; page["name"] = "a title"; page["audience"] = community.Id; _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI, ["audience"] = community.Id }; await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); var liked = Processed("Announce"); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); var unliked = Processed("Announce"); await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like)); var unfollowed = Processed("Announce"); //Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server //is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored Assert.Equal("accepted", liked.Outcome); Assert.Equal("accepted", unliked.Outcome); Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason)); var after = await DB.Default.Find().OneAsync(post.ID, token); Assert.Equal(0, after.FavouritesCount); Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token)); } // Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still // reaches us through it, and only through that community (found by the town's village) [Fact] public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); var voter = new RemoteActor(_harness.Peer, "voter"); var page = PublicNote(poster, "

a thread

", community.Id); page["type"] = "Page"; page["name"] = "a thread"; page["audience"] = community.Id; _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token); Assert.Equal(community.Id, root.AudienceURI); var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post; var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI }; await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like)); Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); Assert.Equal("accepted", Processed("Announce").Outcome); Assert.Equal(1, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like))); Assert.Equal(0, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); } // PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it [Fact] public async Task A_post_moved_to_another_community_takes_its_thread_along() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); async Task Announced(JsonObject note) { note["audience"] = community.Id; _harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString()); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note))); return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); } var page = PublicNote(poster, "

misplaced

", community.Id); page["type"] = "Page"; page["name"] = "misplaced"; var post = await Announced(page); var comment = PublicNote(poster, "

a comment

", community.Id); comment["inReplyTo"] = IdOf(page); var answer = await Announced(comment); var move = new JsonObject { ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id }; // only the community it leaves speaks for it await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject { ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id })); Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move)); Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason)); Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(post.ID, token)).AudienceURI); Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(answer.ID, token)).AudienceURI); } // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo [Fact] public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); var moderator = new RemoteActor(_harness.Peer, "moderator"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); JsonObject Page(string text, bool commentsEnabled = true) { var page = PublicNote(poster, $"

{text}

", community.Id); page["type"] = "Page"; page["name"] = text; page["audience"] = community.Id; page["commentsEnabled"] = commentsEnabled; _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); return page; } async Task Announced(JsonObject page) { await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); } Task Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token); var post = await Announced(Page("a thread")); var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI }; await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt)); Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason)); var refused = await Reply(post); Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt))); Assert.Null((await DB.Default.Find().OneAsync(post.ID, token)).LockedAt); Assert.True((await Reply(post)).Ok); // a post its community serves locked arrives locked Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt); var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban)); Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); var banned = await Reply(post); Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban))); Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason)); Assert.True((await Reply(post)).Ok); // Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban, // never the moderator's own block of the persona var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id }; await _harness.Deliver(moderator, "/human-centipede", direct); Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason)); Assert.False(await DB.Default.Find().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token)); Assert.Equal(422, (await Reply(post)).Status); await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct)); Assert.True((await Reply(post)).Ok); var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id }; await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere)); Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason)); } // a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for // anything else only once its vote is fetched from its own origin [Fact] public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B); var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); var page = PublicNote(poster, "

a post

", community.Id); page["type"] = "Page"; page["name"] = "a title"; _harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString()); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page))); var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token); var other = await OwnPost(alice, token); var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI }; var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI }; await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like)); await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged)); Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); Assert.True(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason)); Assert.Equal(0, (await DB.Default.Find().OneAsync(other.ID, token)).FavouritesCount); } // a public post of a persona's own, outside any community static async Task OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token) { var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" }; post.ID = (string)post.GenerateNewID(); post.ObjectURI = author.PostUri(post.ID); await DB.Default.SaveAsync(post, token); return post; } [Fact] public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow() { var token = TestContext.Current.CancellationToken; var (_, open) = await _harness.Persona("alice"); await DB.Default.Update().MatchID(open.Id).Modify(a => a.Settings.IsLocked, true).ExecuteAsync(token); var alice = await _harness.Local.FindById(LocalActorKind.Person, open.Id, token); var bob = new RemoteActor(_harness.Peer, "bob"); var carol = new RemoteActor(_harness.Peer, "carol"); var bobFollows = Follow(bob, alice.Uri); var carolFollows = Follow(carol, alice.Uri); Assert.True(alice.ManuallyApprovesFollowers); await _harness.Deliver(bob, "/human-centipede", bobFollows); await _harness.Deliver(carol, "/human-centipede", carolFollows); var pending = await DB.Default.Find().Match(f => f.LocalActorId == alice.Id).ExecuteAsync(token); Assert.Equal(2, pending.Count); Assert.All(pending, f => Assert.False(f.IsAccepted)); Assert.Equal(("accepted", "pending"), (Processed("Follow").Outcome, Processed("Follow").Reason)); var requests = await DB.Default.Find().Match(n => n.AvatarId == alice.Id).ExecuteAsync(token); Assert.Equal(2, requests.Count); Assert.All(requests, n => Assert.Equal(NotificationType.FollowRequest, n.Type)); Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox")); Assert.Empty(await _harness.Delivery.FollowerInboxes(alice, token)); var bobAccount = await Stored(bob); var carolAccount = await Stored(carol); Assert.True(await _harness.Follows.Decide(alice, bobAccount.ID, accept: true, token)); Assert.True(await _harness.Follows.Decide(alice, carolAccount.ID, accept: false, token)); Assert.False(await _harness.Follows.Decide(alice, carolAccount.ID, accept: true, token)); var accept = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox")); Assert.Equal("Accept", accept["type"]!.GetValue()); Assert.Equal(alice.Uri, accept["actor"]!.GetValue()); Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!)); Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue()); var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox")); Assert.Equal("Reject", reject["type"]!.GetValue()); Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!)); Assert.True((await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted); Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token)); Assert.Contains(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Follow).ExecuteAsync(token), n => n.FromAccountId == bobAccount.ID); Assert.Equal(new[] { bob.Id + "/inbox" }, await _harness.Delivery.FollowerInboxes(alice, token)); } } }