2026-10-01 12:30:57 +02:00
|
|
|
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;
|
2026-10-04 03:01:14 +02:00
|
|
|
using PrivaPub.StaticServices;
|
2026-10-01 12:30:57 +02:00
|
|
|
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<string> to, string audience = default)
|
|
|
|
|
{
|
|
|
|
|
var note = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
|
|
|
|
|
["type"] = "Note",
|
|
|
|
|
["attributedTo"] = author.Id,
|
|
|
|
|
["content"] = "<p>to the group</p>",
|
|
|
|
|
["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<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
|
|
|
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
|
|
|
|
|
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
|
|
|
|
|
Assert.Equal(2, announces.Count);
|
|
|
|
|
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == 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<Post>().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<string>());
|
|
|
|
|
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
|
|
|
|
|
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
|
|
|
|
|
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "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<string>()));
|
2026-10-04 03:01:14 +02:00
|
|
|
// the member's copy names the member, so Mastodon and GoToSocial keep it, and names nobody else
|
|
|
|
|
Assert.Equal(new[] { member.Id }, create["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
|
|
|
|
Assert.Equal(new[] { member.Id }, create["object"]!["cc"]!.AsArray().Select(t => t!.GetValue<string>()));
|
2026-10-01 12:30:57 +02:00
|
|
|
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<string>() == "Announce"));
|
|
|
|
|
|
2026-10-04 03:01:14 +02:00
|
|
|
var authorizer = new SignedFetchAuthorizer(_harness.Remote, new DbEntities());
|
2026-10-01 12:30:57 +02:00
|
|
|
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));
|
2026-10-04 03:01:14 +02:00
|
|
|
Assert.True(await authorizer.MayRead(outcome.Post, asMember, token));
|
|
|
|
|
Assert.False(await authorizer.MayRead(outcome.Post, asOutsider, token));
|
|
|
|
|
Assert.Equal(new[] { member.Id }, SignedFetchAuthorizer.CircleReaders(circleEntity, asMember));
|
2026-10-01 12:30:57 +02:00
|
|
|
Assert.True(circle.IsCircle);
|
|
|
|
|
Assert.False(circle.Discoverable);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:01:14 +02:00
|
|
|
[Fact]
|
|
|
|
|
public async Task A_circle_posts_edit_and_delete_reach_each_member_naming_only_that_member()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
|
|
|
var first = new RemoteActor(_harness.Peer, "first");
|
|
|
|
|
var second = new RemoteActor(_harness.Peer, "second", _harness.Peer.B);
|
|
|
|
|
var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, first.Id, second.Id);
|
|
|
|
|
await _harness.Remote.GetActor(first.Id, refresh: false, token);
|
|
|
|
|
await _harness.Remote.GetActor(second.Id, refresh: false, token);
|
|
|
|
|
|
|
|
|
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
|
|
|
|
|
await _harness.Statuses.Edit(alice, outcome.Post.ID, new StatusDraft { Text = "just us, edited" }, token);
|
|
|
|
|
await _harness.Statuses.Remove(alice, outcome.Post.ID, token);
|
|
|
|
|
|
|
|
|
|
foreach (var (member, other) in new[] { (first, second), (second, first) })
|
|
|
|
|
{
|
|
|
|
|
var sent = await _harness.Outgoing(member.Id + "/inbox");
|
|
|
|
|
Assert.Equal(new[] { "Create", "Update", "Delete" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
|
|
|
|
Assert.All(sent, a => Assert.Contains(member.Id, a["cc"]!.AsArray().Select(c => c!.GetValue<string>())));
|
|
|
|
|
Assert.All(sent, a => Assert.DoesNotContain(other.Id, a.ToJsonString()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_reply_to_a_circle_post_stays_in_it_and_a_circle_post_asks_nobody_outside_for_a_quote()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
|
|
|
var (_, bob) = await _harness.Persona("bob");
|
|
|
|
|
var (entity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id);
|
|
|
|
|
entity.Members.Add(new GroupMember { AvatarId = bob.Id });
|
|
|
|
|
await DB.Default.SaveAsync(entity, token);
|
|
|
|
|
var outsider = new RemoteActor(_harness.Peer, "asked");
|
|
|
|
|
await _harness.Remote.GetActor(outsider.Id, refresh: false, token);
|
|
|
|
|
var asksFirst = new Post
|
|
|
|
|
{
|
|
|
|
|
ObjectURI = $"{Origin(outsider)}/notes/{Guid.NewGuid():N}",
|
|
|
|
|
ActorURI = outsider.Id,
|
|
|
|
|
IsFederatedCopy = true,
|
|
|
|
|
Visibility = PostVisibility.Public,
|
|
|
|
|
ContentHtml = "<p>ask me first</p>",
|
|
|
|
|
QuotePolicy = new InteractionRule { Manual = new() { Addressing.Public } }
|
|
|
|
|
};
|
|
|
|
|
await DB.Default.SaveAsync(asksFirst, token);
|
|
|
|
|
|
|
|
|
|
var root = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token);
|
|
|
|
|
var reply = await _harness.Statuses.Publish(bob, new StatusDraft { Text = "still just us", InReplyTo = root.Post.ID }, token);
|
|
|
|
|
var quote = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "look", GroupId = circle.Id, QuotedStatusId = asksFirst.ID }, token);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(PostVisibility.Circle, reply.Post.Visibility);
|
|
|
|
|
Assert.Equal(circle.Id, reply.Post.GroupId);
|
|
|
|
|
Assert.Equal(422, quote.Status);
|
|
|
|
|
Assert.Empty(await _harness.Outgoing(outsider.Id + "/inbox"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 12:30:57 +02:00
|
|
|
[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<Post>().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<TimelineEntry>().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<Following>().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<string>();
|
|
|
|
|
_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<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
|
|
|
|
|
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
|
|
|
|
|
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|