using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Objects; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.FederatedSeeds; namespace PrivaPub.Tests.Federation { // GoToSocial's interaction policies: who may reply to, like and boost a post at once, who must ask its author first, and // who may not [Trait("Category", "Integration")] public sealed class InteractionPolicyTests : 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 Rule(params string[] automatic) => Rule(automatic, Array.Empty()); static JsonObject Rule(string[] automatic, string[] manual) => new() { ["automaticApproval"] = new JsonArray(automatic.Select(a => (JsonNode)a).ToArray()), ["manualApproval"] = new JsonArray(manual.Select(m => (JsonNode)m).ToArray()) }; // bob's public post, which alice follows bob to see, under the policy given async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Posted(JsonObject policy) { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); var fan = new RemoteActor(_harness.Peer, "fan"); await Follows(alice.Id, bob); await _harness.FollowedBy(alice, fan); var note = PublicNote(bob, "

a post with rules

"); note["interactionPolicy"] = policy; await _harness.Deliver(bob, "/human-centipede", Create(bob, note)); var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); return (alice, bob, post, fan); } Task> To(RemoteActor actor) => _harness.Outgoing(actor.Id + "/inbox"); Task> ToShared(RemoteActor actor) => _harness.Outgoing(actor.SharedInbox); JsonObject Authorization(RemoteActor author, string type, string interaction, Post target) { var path = $"/authorizations/{Guid.NewGuid():N}"; var stamp = new JsonObject { ["id"] = Origin(author) + path, ["type"] = type, ["attributedTo"] = author.Id, ["interactingObject"] = interaction, ["interactionTarget"] = target.ObjectURI }; _harness.Peer.Serve(path, stamp.ToJsonString()); return stamp; } [Fact] public async Task A_reply_the_policy_lets_in_at_once_goes_out_as_always_and_one_it_shuts_out_is_refused() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, _) = await Posted(new JsonObject { ["canReply"] = Rule("https://www.w3.org/ns/activitystreams#Public") }); var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an open reply", InReplyTo = post.ID }, token); Assert.Equal(ApprovalState.None, reply.Post.Approval); Assert.Contains(await To(bob), a => a["type"]!.GetValue() == "Create"); var (carol, dave, shut, _) = await Posted(new JsonObject { ["canReply"] = Rule(new string[0], new string[0]) }); var refused = await _harness.Statuses.Publish(carol, new StatusDraft { Text = "a shut-out reply", InReplyTo = shut.ID }, token); Assert.Equal(422, refused.Status); } [Fact] public async Task A_reply_its_author_must_approve_is_asked_for_and_goes_out_with_the_authorization_once_given() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, fan) = await Posted(new JsonObject { ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) }); var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post; Assert.Equal(ApprovalState.Pending, reply.Approval); var request = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); Assert.Equal(post.ObjectURI, request["object"]!.GetValue()); Assert.Equal(reply.ObjectURI, request["instrument"]!["id"]!.GetValue()); Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); var stamp = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, post); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = new JsonObject { ["type"] = "ReplyRequest", ["id"] = request["id"]!.GetValue() }, ["result"] = IdOf(stamp) }); var after = await DB.Default.Find().OneAsync(reply.ID, token); Assert.Equal((ApprovalState.Accepted, IdOf(stamp)), (after.Approval, after.ApprovalURI)); var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); Assert.Equal(IdOf(stamp), create["object"]!["replyAuthorization"]!.GetValue()); } [Fact] public async Task A_rejected_reply_stays_ours_alone_and_a_forged_authorization_changes_nothing() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, fan) = await Posted(new JsonObject { ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) }); var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", InReplyTo = post.ID }, token)).Post; var request = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); // an authorization naming another post is no authorization var forged = Authorization(bob, "ReplyAuthorization", reply.ObjectURI, new Post { ObjectURI = "https://elsewhere.invalid/post" }); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue(), ["result"] = IdOf(forged) }); Assert.Equal(ApprovalState.Pending, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = request["id"]!.GetValue() }); Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); } [Fact] public async Task A_like_and_a_boost_its_author_must_approve_are_asked_for_and_a_shut_out_boost_is_refused() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, fan) = await Posted(new JsonObject { ["canLike"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }), ["canAnnounce"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) }); await _harness.Statuses.Favourite(alice, post.ID, true, token); var like = await DB.Default.Find().Match(f => f.AccountId == alice.Id && f.PostId == post.ID).ExecuteSingleAsync(token); Assert.Equal(ApprovalState.Pending, like.Approval); var likeRequest = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "LikeRequest"); Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue() == "Like"); var stamp = Authorization(bob, "LikeAuthorization", like.ActivityURI, post); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = NewId(bob, "accepts"), ["type"] = "Accept", ["actor"] = bob.Id, ["object"] = likeRequest["id"]!.GetValue(), ["result"] = IdOf(stamp) }); Assert.Equal(ApprovalState.Accepted, (await DB.Default.Find().OneAsync(like.ID, token)).Approval); Assert.Equal(IdOf(stamp), Assert.Single(await To(bob), a => a["type"]!.GetValue() == "Like")["likeAuthorization"]!.GetValue()); var boost = (await _harness.Statuses.Reblog(alice, post.ID, true, PostVisibility.Public, token)).Post; Assert.Equal(ApprovalState.Pending, boost.Approval); var announceRequest = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"), a => a["type"]!.GetValue() == "AnnounceRequest"); Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Announce"); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = announceRequest["id"]!.GetValue() }); Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(boost.ID, token)).Approval); var (carol, _, shut, _) = await Posted(new JsonObject { ["canAnnounce"] = Rule(new string[0], new string[0]) }); Assert.Equal(422, (await _harness.Statuses.Reblog(carol, shut.ID, true, PostVisibility.Public, token)).Status); } // as a third party: a reply bob's policy does not let in at once is shown only with bob's authorization [Fact] public async Task A_reply_to_a_post_that_needs_its_authors_approval_is_kept_only_with_that_approval() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, _) = await Posted(new JsonObject { ["canReply"] = Rule(new[] { "https://dummy.invalid/nobody" }, new[] { "https://www.w3.org/ns/activitystreams#Public" }) }); var carol = new RemoteActor(_harness.Peer, "carol"); await Follows(alice.Id, carol); var unasked = PublicNote(carol, "

barging in

"); unasked["inReplyTo"] = post.ObjectURI; await _harness.Deliver(carol, "/human-centipede", Create(carol, unasked)); Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(unasked)).ExecuteAnyAsync(token)); var asked = PublicNote(carol, "

with leave

"); asked["inReplyTo"] = post.ObjectURI; asked["replyAuthorization"] = IdOf(Authorization(bob, "ReplyAuthorization", IdOf(asked), post)); await _harness.Deliver(carol, "/human-centipede", Create(carol, asked)); Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == IdOf(asked)).ExecuteAnyAsync(token)); } // bob's public post whose replies wait for his approval (FEP-5624's canReply, as PeerTube sets it on a video whose // comments are moderated), which alice follows bob to see async Task<(PrivaPub.Federation.Actors.LocalActor Alice, RemoteActor Bob, Post Post, RemoteActor Fan)> Moderated(JsonNode canReply) { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var bob = new RemoteActor(_harness.Peer, "bob"); var fan = new RemoteActor(_harness.Peer, "fan"); await Follows(alice.Id, bob); await _harness.FollowedBy(alice, fan); var note = PublicNote(bob, "

a moderated post

"); note["canReply"] = canReply; await _harness.Deliver(bob, "/human-centipede", Create(bob, note)); var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); return (alice, bob, post, fan); } Task Answer(RemoteActor from, string type, Post reply, string inReplyTo) => _harness.Deliver(from, "/human-centipede", new JsonObject { ["id"] = NewId(from, "approvals"), ["type"] = type, ["actor"] = from.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = inReplyTo }); [Fact] public async Task A_reply_its_author_approves_goes_to_that_author_alone_then_out_with_the_approval() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public"); var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to approve", InReplyTo = post.ID }, token)).Post; Assert.Equal(ApprovalState.Pending, reply.Approval); var asked = Assert.Single(await To(bob), a => a["type"]!.GetValue() == "Create"); Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue()); Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue() == "ReplyRequest"); Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); var approval = NewId(bob, "approve-reply"); await _harness.Deliver(bob, "/human-centipede", new JsonObject { ["id"] = approval, ["type"] = "ApproveReply", ["actor"] = bob.Id, ["object"] = reply.ObjectURI, ["inReplyTo"] = post.ObjectURI }); var after = await DB.Default.Find().OneAsync(reply.ID, token); Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI)); var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue()); Assert.Null(create["object"]!["replyAuthorization"]); } [Fact] public async Task A_refused_reply_stays_ours_alone_and_an_approval_from_elsewhere_changes_nothing() { var token = TestContext.Current.CancellationToken; var (alice, bob, post, fan) = await Moderated("https://www.w3.org/ns/activitystreams#Public"); var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a comment to refuse", InReplyTo = post.ID }, token)).Post; // an approval by someone else than the post's author, or naming another post, is no approval await Answer(new RemoteActor(_harness.Peer, "mallory"), "ApproveReply", reply, post.ObjectURI); await Answer(bob, "ApproveReply", reply, "https://elsewhere.invalid/post"); Assert.Equal(ApprovalState.Pending, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); await Answer(bob, "RejectReply", reply, post.ObjectURI); Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find().OneAsync(reply.ID, token)).Approval); Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue() == "Create"); } [Fact] public async Task Only_those_its_canReply_names_or_it_mentions_may_reply() { var (alice, _, post, _) = await Moderated(new JsonArray()); var refused = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "nobody asked me", InReplyTo = post.ID }, TestContext.Current.CancellationToken); Assert.Equal(422, refused.Status); } } }