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); [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_followed_community_announcing_a_vote_or_its_undo_is_recorded_and_changes_nothing_yet() { 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"); 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"); //votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create") Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object)); Assert.Equal(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object)); 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)); } [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)); } } }