using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.Host.FederationHelpers; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Tests.Federation { // walls (FEP-400e, Smithereen's; owner decision 2026-10-06): a persona's followers write on its wall, PrivaPub hosts what // they write and tells the persona's followers with Add{Note}; the persona takes it off with Remove; an account elsewhere // that tells of a post on its own wall has it shown to its followers here [Trait("Category", "Integration")] public sealed class WallTests : IAsyncLifetime { const string Public = "https://www.w3.org/ns/activitystreams#Public"; 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 CancellationToken Token => TestContext.Current.CancellationToken; // a public note by author, written on the wall of owner (whose actor and wall are given), served at its id string WallNote(RemoteActor author, string ownerUri, string wall, string text, string inReplyTo = default, string to = Public) { var path = $"/notes/{Guid.NewGuid():N}"; var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority); var note = new JsonObject { ["id"] = origin + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", ["to"] = new JsonArray(to, ownerUri), ["published"] = DateTime.UtcNow.ToString("O"), ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = wall, ["attributedTo"] = ownerUri } }; if (inReplyTo != default) note["inReplyTo"] = inReplyTo; _harness.Peer.Serve(path, note.ToJsonString()); _notes[origin + path] = note; return origin + path; } readonly Dictionary _notes = new(); // as Smithereen sends it: the whole note, to the wall's owner JsonObject Create(RemoteActor author, string noteId) => new() { ["id"] = noteId + "/activityCreate", ["type"] = "Create", ["actor"] = author.Id, ["to"] = _notes[noteId]["to"]!.DeepClone(), ["object"] = _notes[noteId].DeepClone() }; Task Post(string objectUri) => DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(Token); [Fact] public void A_persona_names_its_wall_and_lets_its_followers_write_on_it() { var actor = ActivityPubRenderer.Actor(new LocalActor { Id = "a", UserName = "alice", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Person }); var circle = ActivityPubRenderer.Actor(new LocalActor { Id = "c", UserName = "circle", BaseAddress = Harness.Base, Kind = PrivaPub.Models.Federation.LocalActorKind.Group, IsCircle = true }); Assert.Equal($"{Harness.Base}/peasants/alice/graffiti", actor["wall"]!.GetValue()); Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue()); Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue()); Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue() == "sm:wall"); Assert.Null(circle["wall"]); } [Fact] public async Task A_followers_post_on_the_wall_is_hosted_shown_and_told_to_the_personas_followers() { var (_, alice) = await _harness.Persona("alice"); var smuser = new RemoteActor(_harness.Peer, "smuser", wall: true); await _harness.FollowedBy(alice, smuser); var noteId = WallNote(smuser, alice.Uri, alice.Wall, "on alice's wall"); await _harness.Deliver(smuser, "/human-centipede", Create(smuser, noteId)); var post = await Post(noteId); Assert.NotNull(post); Assert.Equal((alice.Wall, alice.Uri, alice.Id), (post.WallURI, post.WallOwnerURI, post.WallOwnerAccountId)); Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); Assert.True(await DB.Default.Find().Match(n => n.AvatarId == alice.Id && n.PostId == post.ID && n.Type == NotificationType.Mention).ExecuteAnyAsync(Token)); var add = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue(), add["object"]!.GetValue(), add["target"]!["id"]!.GetValue())); Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue() == "Add"); Assert.True(await _harness.Walls.Remove(alice, post, Token)); Assert.Null(await Post(noteId)); var remove = Assert.Single(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Remove"); Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue(), remove["target"]!.GetValue())); } [Fact] public async Task A_wall_post_from_a_stranger_a_reply_or_a_quiet_one_is_refused() { var (_, alice) = await _harness.Persona("alice"); var stranger = new RemoteActor(_harness.Peer, "stranger", wall: true); var follower = new RemoteActor(_harness.Peer, "follower", wall: true); await _harness.FollowedBy(alice, follower); var fromStranger = WallNote(stranger, alice.Uri, alice.Wall, "from a stranger"); var reply = WallNote(follower, alice.Uri, alice.Wall, "a reply", inReplyTo: "https://elsewhere.example/notes/1"); var quiet = WallNote(follower, alice.Uri, alice.Wall, "quietly", to: follower.Id + "/followers"); await _harness.Deliver(stranger, "/human-centipede", Create(stranger, fromStranger)); await _harness.Deliver(follower, "/human-centipede", Create(follower, reply)); await _harness.Deliver(follower, "/human-centipede", Create(follower, quiet)); Assert.Null(await Post(fromStranger)); Assert.Null(await Post(reply)); Assert.Null(await Post(quiet)); Assert.DoesNotContain(await _harness.Outgoing(_harness.Peer.A + "/inbox"), a => a["type"]!.GetValue() == "Add"); } [Fact] public async Task A_post_on_a_followed_accounts_wall_elsewhere_reaches_its_followers_here_until_it_is_taken_off() { var (_, alice) = await _harness.Persona("alice"); var owner = new RemoteActor(_harness.Peer, "owner", wall: true); var writer = new RemoteActor(_harness.Peer, "writer"); await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); var noteId = WallNote(writer, owner.Id, owner.Wall, "on the owner's wall"); // (kept before PrivaPub read walls: its document is read again when it names one) await DB.Default.SaveAsync(new PrivaPub.Models.User.ForeignAvatar { ActorURI = owner.Id, UserName = owner.Name, PublicKeyId = owner.KeyId, PublicKey = owner.PublicKeyPem, InboxURL = owner.Id + "/inbox", FollowersURL = owner.Id + "/followers", UpdatedAt = DateTime.UtcNow.AddDays(-2) }, Token); await _harness.Deliver(owner, "/human-centipede", new JsonObject { ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityAdd", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = new JsonObject { ["type"] = "Collection", ["id"] = owner.Wall, ["attributedTo"] = owner.Id }, ["to"] = new JsonArray(Public, owner.Id + "/followers") }); var post = await Post(noteId); Assert.NotNull(post); Assert.Equal((writer.Id, owner.Id), (post.ActorURI, post.WallOwnerURI)); Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token)); Assert.False(await DB.Default.Find().Match(f => f.ObjectURI == noteId).ExecuteAnyAsync(Token)); await _harness.Deliver(owner, "/human-centipede", new JsonObject { ["id"] = $"{owner.Id}/posts/{Guid.NewGuid():N}/activityRemove", ["type"] = "Remove", ["actor"] = owner.Id, ["object"] = noteId, ["target"] = owner.Wall }); Assert.Null(await Post(noteId)); } } [Trait("Category", "Integration")] public sealed class WallCollectionTests : IAsyncLifetime { PrivaPubHost _host; HttpClient _client; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); } public ValueTask DisposeAsync() { _client?.Dispose(); return ValueTask.CompletedTask; } [Fact] public async Task The_wall_lists_the_personas_public_posts_and_what_others_wrote_on_it() { var token = TestContext.Current.CancellationToken; var persona = await _host.Persona(await _host.SignUp(), "wall"); var open = await _host.Publish(persona, "for everyone", PostVisibility.Public); var quiet = await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly); var wall = persona.ActorUri() + "/graffiti"; var written = new PostEntity { ObjectURI = $"https://elsewhere.example/notes/{Guid.NewGuid():N}", ActorURI = "https://elsewhere.example/users/writer", IsFederatedCopy = true, Visibility = PostVisibility.Public, WallURI = wall, WallOwnerURI = persona.ActorUri(), WallOwnerAccountId = persona.Id }; await DB.Default.SaveAsync(written, token); var collection = await _client.Fetch($"/peasants/{persona.UserName}/graffiti"); var page = await _client.Fetch($"/peasants/{persona.UserName}/graffiti?page=true"); Assert.Equal(HttpStatusCode.OK, collection.Status); Assert.Equal((wall, 2), (collection.Json["id"]!.GetValue(), collection.Json["totalItems"]!.GetValue())); var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue()).ToList(); Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items); Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID)); } } }