PeerTube puts canReply on a video whose comments wait for approval, and answers each comment with ApproveReply. A persona's reply to such a post now waits (privapub.approval: pending), its Create going to the author alone; the author's ApproveReply, signed by the author and naming the post answered, lets it out to its audience with replyApproval, and RejectReply leaves it ours. A null canReply (PeerTube's open comments) says nothing; an empty one refuses. The PeerTube scenario holds a comment for review and approves it through PeerTube's API (28 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
285 lines
14 KiB
C#
285 lines
14 KiB
C#
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<string>());
|
|
|
|
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, "<p>a post with rules</p>");
|
|
note["interactionPolicy"] = policy;
|
|
await _harness.Deliver(bob, "/human-centipede", Create(bob, note));
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
|
|
return (alice, bob, post, fan);
|
|
}
|
|
|
|
Task<List<JsonObject>> To(RemoteActor actor) => _harness.Outgoing(actor.Id + "/inbox");
|
|
|
|
Task<List<JsonObject>> 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<string>() == "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<string>() == "ReplyRequest");
|
|
Assert.Equal(post.ObjectURI, request["object"]!.GetValue<string>());
|
|
Assert.Equal(reply.ObjectURI, request["instrument"]!["id"]!.GetValue<string>());
|
|
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "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<string>() },
|
|
["result"] = IdOf(stamp)
|
|
});
|
|
|
|
var after = await DB.Default.Find<Post>().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<string>() == "Create");
|
|
Assert.Equal(IdOf(stamp), create["object"]!["replyAuthorization"]!.GetValue<string>());
|
|
}
|
|
|
|
[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<string>() == "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<string>(), ["result"] = IdOf(forged)
|
|
});
|
|
Assert.Equal(ApprovalState.Pending, (await DB.Default.Find<Post>().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<string>()
|
|
});
|
|
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
|
|
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "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<Favourite>().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<string>() == "LikeRequest");
|
|
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "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<string>(), ["result"] = IdOf(stamp)
|
|
});
|
|
Assert.Equal(ApprovalState.Accepted, (await DB.Default.Find<Favourite>().OneAsync(like.ID, token)).Approval);
|
|
Assert.Equal(IdOf(stamp), Assert.Single(await To(bob), a => a["type"]!.GetValue<string>() == "Like")["likeAuthorization"]!.GetValue<string>());
|
|
|
|
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<string>() == "AnnounceRequest");
|
|
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Announce");
|
|
await _harness.Deliver(bob, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(bob, "rejects"), ["type"] = "Reject", ["actor"] = bob.Id, ["object"] = announceRequest["id"]!.GetValue<string>()
|
|
});
|
|
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().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, "<p>barging in</p>");
|
|
unasked["inReplyTo"] = post.ObjectURI;
|
|
await _harness.Deliver(carol, "/human-centipede", Create(carol, unasked));
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(unasked)).ExecuteAnyAsync(token));
|
|
|
|
var asked = PublicNote(carol, "<p>with leave</p>");
|
|
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<Post>().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, "<p>a moderated post</p>");
|
|
note["canReply"] = canReply;
|
|
await _harness.Deliver(bob, "/human-centipede", Create(bob, note));
|
|
var post = await DB.Default.Find<Post>().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<string>() == "Create");
|
|
Assert.Equal(reply.ObjectURI, asked["object"]!["id"]!.GetValue<string>());
|
|
Assert.DoesNotContain(await To(bob), a => a["type"]!.GetValue<string>() == "ReplyRequest");
|
|
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "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<Post>().OneAsync(reply.ID, token);
|
|
Assert.Equal((ApprovalState.Accepted, approval), (after.Approval, after.ReplyApprovalURI));
|
|
var create = Assert.Single(await ToShared(fan), a => a["type"]!.GetValue<string>() == "Create");
|
|
Assert.Equal(approval, create["object"]!["replyApproval"]!.GetValue<string>());
|
|
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<Post>().OneAsync(reply.ID, token)).Approval);
|
|
|
|
await Answer(bob, "RejectReply", reply, post.ObjectURI);
|
|
Assert.Equal(ApprovalState.Rejected, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).Approval);
|
|
Assert.DoesNotContain(await ToShared(fan), a => a["type"]!.GetValue<string>() == "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);
|
|
}
|
|
}
|
|
}
|