Communities follow FEP-1b12, circles federate to their members only
Communities: - a post addressed to a community (to, cc or audience) is accepted according to its posting policy - followers, anyone, or moderators - and GroupDistributor announces the whole activity with `audience` to the community's followers, plus the object for new posts so Mastodon shows them; updates and deletes of community content are announced too; - top-level posts are Pages with a name (the title, or a headline from the text); /flock counts members, /wardens lists moderators; - a Mastodon client posts into a community by mentioning it, or into a remote group, which sets `audience`; - an Announce of an activity from a remote group a persona follows (Lemmy) is followed through: the object is fetched from its own origin, kept with its AudienceURI, and fanned out to the group's local followers; updates are applied in place and deletes checked against the origin. Circles stop being local-only: an undiscoverable Group actor whose follows are all requests the owner approves; posts addressed to the circle and its /flock and delivered to members' own inboxes, never announced, never public; a remote member's post into the circle is accepted from members only. SignedFetchAuthorizer serves circle posts and collections only to a signed request from a member or a member server's instance actor - 404 for anyone else. Circles never surface in search, lookups, mentions, account ids or profile pages. Federation:SecureMode requires a valid signature on every GET under /peasants except the instance actor. Group forms take a posting policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
0ccbcd558f
commit
a5d9a89445
32 files changed
+652
-58
No files matched your search
@@ -0,0 +1,198 @@
|
||||
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<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>()));
|
||||
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"));
|
||||
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
new FollowHandler(db, _local, remote, delivery),
|
||||
new UndoHandler(db, _local),
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
|
||||
new DeleteHandler(db, _local, remote, delivery),
|
||||
new UpdateHandler(db, _local, remote)
|
||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)),
|
||||
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
||||
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
}
|
||||
|
||||
@@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_is_not_a_federated_actor()
|
||||
public async Task A_circle_only_takes_follow_requests()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
@@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation
|
||||
["object"] = actor.Uri
|
||||
};
|
||||
|
||||
Assert.False(actor.IsFederated);
|
||||
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
||||
Assert.True(actor.IsCircle);
|
||||
Assert.True(actor.ManuallyApprovesFollowers);
|
||||
Assert.False(actor.Discoverable);
|
||||
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
||||
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
|
||||
Assert.False(request.IsAccepted);
|
||||
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support
|
||||
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
||||
Delivery = new DeliveryService(Db, Queue);
|
||||
Fanout = new Fanout(Db);
|
||||
Groups = new GroupDistributor(Delivery);
|
||||
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
||||
@@ -50,10 +51,10 @@ namespace PrivaPub.Tests.Support
|
||||
new RejectHandler(Db, Local),
|
||||
new UndoHandler(Db, Local),
|
||||
new LikeHandler(Db),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery),
|
||||
new UpdateHandler(Db, Local, Remote),
|
||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
|
||||
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||
new UpdateHandler(Db, Local, Remote, Groups),
|
||||
new FlagHandler(Db, Local)
|
||||
}, NullLogger<InboxProcessor>.Instance);
|
||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||
@@ -61,7 +62,7 @@ namespace PrivaPub.Tests.Support
|
||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||
Local, default, NullLogger<MediaService>.Instance);
|
||||
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media);
|
||||
Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups);
|
||||
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
||||
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||
@@ -82,6 +83,7 @@ namespace PrivaPub.Tests.Support
|
||||
public PostsService Posts { get; }
|
||||
public StatusService Statuses { get; }
|
||||
public MediaService Media { get; }
|
||||
public GroupDistributor Groups { get; }
|
||||
public Fanout Fanout { get; }
|
||||
public RemotePosts RemotePosts { get; }
|
||||
public TimelineService Timelines { get; }
|
||||
|
||||
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
|
||||
{
|
||||
readonly RSA _key = RSA.Create(2048);
|
||||
|
||||
public RemoteActor(Peer peer, string name, string origin = default)
|
||||
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
|
||||
{
|
||||
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||
Type = type;
|
||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public string Name { get; }
|
||||
public string Id { get; }
|
||||
public string KeyId => Id + "#main-key";
|
||||
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
|
||||
public JsonObject Document() => new()
|
||||
{
|
||||
["id"] = Id,
|
||||
["type"] = "Person",
|
||||
["type"] = Type,
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["followers"] = Id + "/followers",
|
||||
@@ -41,6 +44,21 @@ namespace PrivaPub.Tests.Support
|
||||
}
|
||||
};
|
||||
|
||||
public HttpRequest Get(string host, string path)
|
||||
{
|
||||
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
|
||||
var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}";
|
||||
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = "GET";
|
||||
context.Request.Host = new HostString(host);
|
||||
context.Request.Path = path;
|
||||
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
|
||||
context.Request.Headers["Date"] = date;
|
||||
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\"";
|
||||
return context.Request;
|
||||
}
|
||||
|
||||
public HttpRequest Post(string host, string path, JsonNode activity)
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||
|
||||
Reference in new issue
Block a user