207 lines
10 KiB
C#
207 lines
10 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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|