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:
1 parent
e0682fa868
commit
c47b6e5533
26 files changed
+582
-36
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user