Personas have walls their followers write on

Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.

Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.

Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 08:17:10 +02:00
1 parent e0682fa868
commit c47b6e5533
26 files changed
+582 -36

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
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"] = $"<p>{text}</p>",
["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<string, JsonObject> _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<PostEntity> Post(string objectUri) => DB.Default.Find<PostEntity>().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<string>());
Assert.Equal($"{Harness.Base}/peasants/alice/groupies", actor["privacySettings"]!["wallPosting"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Equal(Public, actor["privacySettings"]!["wallPostVisibility"]!["allowedTo"]![0]!.GetValue<string>());
Assert.Contains(actor["@context"]!.AsArray(), c => c is JsonObject terms && terms["wall"]?["@id"]?.GetValue<string>() == "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<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.True(await DB.Default.Find<Notification>().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<string>() == "Add");
Assert.Equal((alice.Uri, noteId, alice.Wall), (add["actor"]!.GetValue<string>(), add["object"]!.GetValue<string>(), add["target"]!["id"]!.GetValue<string>()));
Assert.Contains(await _harness.Outgoing(smuser.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "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<string>() == "Remove");
Assert.Equal((noteId, alice.Wall), (remove["object"]!.GetValue<string>(), remove["target"]!.GetValue<string>()));
}
[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<string>() == "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<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(Token));
Assert.False(await DB.Default.Find<RemoteFeatured>().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<string>(), collection.Json["totalItems"]!.GetValue<int>()));
var items = page.Json["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToList();
Assert.Equal(new[] { written.ObjectURI, persona.ActorUri() + "/scribbles/" + open.ID }, items);
Assert.DoesNotContain(items, i => i.EndsWith(quiet.ID));
}
}
}
+5 -3
View File
@@ -59,6 +59,7 @@ namespace PrivaPub.Tests.Support
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Walls = new Walls(Db, Local, RemotePosts, Delivery, Fanout);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
@@ -71,15 +72,15 @@ namespace PrivaPub.Tests.Support
new DislikeHandler(Db),
new JoinHandler(Db, Local, Delivery),
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote, Records, Quotes, relays: Relays),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays),
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups, Records, Polls, new NoPreviews(), Quotes, Approvals, Relays, Walls),
new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes),
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes, Delivery),
new FlagHandler(Db, Local),
new BlockHandler(Db, Local),
new LockHandler(Db),
new MoveHandler(Remote, Db, Local, Follows, Relationships),
new FeaturedHandler(Featured, add: true),
new FeaturedHandler(Featured, add: false)
new FeaturedHandler(Featured, add: true, Walls, Remote),
new FeaturedHandler(Featured, add: false, Walls, Remote)
};
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
@@ -120,6 +121,7 @@ namespace PrivaPub.Tests.Support
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; }
public Walls Walls { get; }
public Relays Relays { get; }
// the relays the harness's instance actor subscribes to: a test sets them before Relays.Reconcile
public PrivaPub.Infrastructure.Http.FederationOptions RelayOptions { get; } = new();
+8 -2
View File
@@ -14,14 +14,17 @@ namespace PrivaPub.Tests.Support
{
readonly RSA _key = RSA.Create(2048);
readonly bool _namesSharedInbox;
readonly bool _hasWall;
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
// sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do; wall:
// whether it has a wall (sm:wall, Smithereen's)
public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false, bool wall = false)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
_namesSharedInbox = sharedInbox;
_hasWall = wall;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
@@ -31,6 +34,7 @@ namespace PrivaPub.Tests.Support
public string Id { get; }
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
public string Wall => Id + "/wall";
public JsonObject Document()
{
@@ -52,6 +56,8 @@ namespace PrivaPub.Tests.Support
};
if (_namesSharedInbox)
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
if (_hasWall)
document["wall"] = Wall;
return document;
}