using MongoDB.Entities; using PrivaPub.ClientModels.Social; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Signing; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using System.Text.Json.Nodes; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Tests.Federation { [Trait("Category", "Integration")] public sealed class GroupTests : 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(); } async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers) { var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair(); var group = new GroupEntity { UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), Kind = kind, PostingPolicy = policy, PrivateKey = privateKey, PublicKey = publicKey, Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } } }; group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true })); await DB.Default.SaveAsync(group); return (group, _harness.Local.FromGroup(group)); } static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); static JsonObject Create(RemoteActor author, IEnumerable to, string audience = default) { var note = new JsonObject { ["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}", ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "

to the group

", ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) }; if (audience != default) note["audience"] = audience; return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; } [Fact] public async Task A_follower_posting_to_a_community_is_announced_activity_and_object() { var token = TestContext.Current.CancellationToken; var (_, owner) = await _harness.Persona("owner"); var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id); var lemmy = new RemoteActor(_harness.Peer, "lemmy"); var stranger = new RemoteActor(_harness.Peer, "stranger"); await _harness.FollowedBy(community, lemmy); await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri)); await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri)); Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); Assert.Equal(2, announces.Count); Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue() == "Create" && a["audience"]!.GetValue() == community.Uri); Assert.Contains(announces, a => a["object"] is JsonValue); } [Fact] public async Task An_open_community_takes_posts_from_anyone() { var token = TestContext.Current.CancellationToken; var (_, owner) = await _harness.Persona("owner"); var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id); var stranger = new RemoteActor(_harness.Peer, "stranger"); await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public })); Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); } [Fact] public async Task Mentioning_a_community_posts_into_it_as_a_titled_page() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id); var follower = new RemoteActor(_harness.Peer, "follower"); await _harness.FollowedBy(community, follower); var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token); Assert.Equal(community.Id, outcome.Post.GroupId); var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default); Assert.Equal("Page", note["type"]!.GetValue()); Assert.Equal(community.Uri, note["audience"]!.GetValue()); Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue())); Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue() == "Announce"); } [Fact] public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var member = new RemoteActor(_harness.Peer, "member"); var outsider = new RemoteActor(_harness.Peer, "outsider"); var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); await _harness.Remote.GetActor(member.Id, refresh: false, token); var follower = new RemoteActor(_harness.Peer, "follower"); await _harness.FollowedBy(alice, follower); var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token); Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility); var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox")); Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue())); Assert.DoesNotContain(Addressing.Public, create.ToJsonString()); Assert.Empty(await _harness.Outgoing(follower.SharedInbox)); Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue() == "Announce")); var authorizer = new SignedFetchAuthorizer(_harness.Remote); var path = new Uri(outcome.Post.ObjectURI).AbsolutePath; var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token); var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token); Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember)); Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider)); Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default)); Assert.True(circle.IsCircle); Assert.False(circle.Discoverable); } [Fact] public async Task Only_circle_members_can_post_into_a_circle() { var token = TestContext.Current.CancellationToken; var (aliceRoot, alice) = await _harness.Persona("alice"); var member = new RemoteActor(_harness.Peer, "member"); var outsider = new RemoteActor(_harness.Peer, "outsider"); var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock })); await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock })); var stored = await DB.Default.Find().Match(p => p.GroupId == circle.Id).ExecuteAsync(token); Assert.Equal(member.Id, Assert.Single(stored).ActorURI); Assert.Equal(PostVisibility.Circle, stored[0].Visibility); Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token)); } [Fact] public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group"); var poster = new RemoteActor(_harness.Peer, "poster"); await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token); await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id); var noteId = create["object"]!["id"]!.GetValue(); _harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString()); await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject { ["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id, ["to"] = new JsonArray(Addressing.Public), ["object"] = create }); var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token); Assert.Equal(lemmyCommunity.Id, post.AudienceURI); Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token)); } } }