Pins across servers, both ways
An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators) is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them. Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05 checks clean, 2454 of 2454. 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
5f150b7a59
commit
1c7d1ece9c
23 files changed
+510
-19
No files matched your search
@@ -0,0 +1,143 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
// pins across servers: an account elsewhere features its own posts (Add and Remove on its `featured` collection, and
|
||||
// the collection itself), a community features posts made in it, and a persona's pin is told to its followers
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FeaturedTests : 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();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
// a public note of the actor's, served where its id points
|
||||
string Note(RemoteActor author, string text, string audience = default)
|
||||
{
|
||||
var path = $"/notes/{Guid.NewGuid():N}";
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
|
||||
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
if (audience != default)
|
||||
note["audience"] = audience;
|
||||
_harness.Peer.Serve(path, note.ToJsonString());
|
||||
return Origin(author) + path;
|
||||
}
|
||||
|
||||
JsonObject Featuring(RemoteActor actor, string type, string objectUri, string target = default) => new()
|
||||
{
|
||||
["id"] = $"{actor.Id}#{type}-{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = objectUri,
|
||||
["target"] = target ?? actor.Id + "/featured"
|
||||
};
|
||||
|
||||
Task<List<RemoteFeatured>> FeaturedBy(RemoteActor actor) =>
|
||||
DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == actor.Id).Sort(f => f.Position, Order.Ascending).ExecuteAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task An_account_features_its_own_post_and_takes_it_back_but_nothing_else()
|
||||
{
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
var carol = new RemoteActor(_harness.Peer, "carol", _harness.Peer.B);
|
||||
var mine = Note(bob, "pinned");
|
||||
var theirs = Note(carol, "not bob's");
|
||||
var older = Note(bob, "pinned before");
|
||||
|
||||
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", older));
|
||||
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", mine));
|
||||
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", theirs));
|
||||
// Smithereen's wall is a collection of the actor's too, but no pin
|
||||
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", Note(bob, "on the wall"), bob.Id + "/wall"));
|
||||
|
||||
Assert.Equal(new[] { mine, older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == mine).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(post.ID, (await FeaturedBy(bob))[0].PostId);
|
||||
|
||||
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Remove", mine));
|
||||
|
||||
Assert.Equal(new[] { older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_featured_collection_is_read_in_its_order_from_the_accounts_own_server()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
var first = Note(bob, "first pin");
|
||||
var second = Note(bob, "second pin");
|
||||
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/featured", new JsonObject
|
||||
{
|
||||
["id"] = bob.Id + "/featured", ["type"] = "OrderedCollection", ["totalItems"] = 2,
|
||||
["orderedItems"] = new JsonArray(new JsonObject { ["id"] = first, ["type"] = "Note" }, second)
|
||||
}.ToJsonString());
|
||||
var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token);
|
||||
|
||||
await _harness.Featured.Sync(account, token);
|
||||
|
||||
Assert.Equal(new[] { first, second }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_personas_pin_and_unpin_reach_its_followers_as_add_and_remove()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var follower = new RemoteActor(_harness.Peer, "fan");
|
||||
await _harness.FollowedBy(alice, follower);
|
||||
await _harness.Posts.InsertPost(root, new PrivaPub.ClientModels.Post.InsertPostForm { AvatarId = alice.Id, Text = "worth a pin" }, token);
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
|
||||
|
||||
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: true, token);
|
||||
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: false, token);
|
||||
|
||||
var sent = (await _harness.Outgoing(follower.SharedInbox)).Concat(await _harness.Outgoing(follower.Id + "/inbox"))
|
||||
.Where(a => a["type"]!.GetValue<string>() is "Add" or "Remove").ToList();
|
||||
Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue<string>()));
|
||||
Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue<string>(), a["target"]!.GetValue<string>())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_community_features_a_post_made_in_it()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var community = new RemoteActor(_harness.Peer, "books", type: "Group");
|
||||
var member = new RemoteActor(_harness.Peer, "reader", _harness.Peer.B);
|
||||
var account = await _harness.Remote.GetActor(community.Id, refresh: false, token);
|
||||
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = community.Id, TargetAccountId = account.ID, State = FollowState.Accepted }, token);
|
||||
var post = Note(member, "a thread in the community", community.Id);
|
||||
|
||||
await _harness.Deliver(community, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{community.Id}#announce-{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = community.Id,
|
||||
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(member)}/activities/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = member.Id, ["object"] = post,
|
||||
["target"] = community.Id + "/featured"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { post }, (await FeaturedBy(community)).Select(f => f.ObjectURI));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,33 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids);
|
||||
}
|
||||
|
||||
// an account elsewhere: what it features, in its order, its public posts only
|
||||
[Fact]
|
||||
public async Task A_remote_accounts_pinned_statuses_are_what_it_features()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var (bob, bobId) = await Remote();
|
||||
Post Held(string text, PostVisibility visibility) => new()
|
||||
{
|
||||
ObjectURI = $"{bob.Id}/statuses/{Guid.NewGuid():N}", ActorURI = bob.Id, AuthorAccountId = bobId, IsFederatedCopy = true,
|
||||
Visibility = visibility, ContentHtml = $"<p>{text}</p>", CreationDate = DateTime.UtcNow, UpdateDate = DateTime.UtcNow
|
||||
};
|
||||
var first = Held("first pin", PostVisibility.Public);
|
||||
var second = Held("second pin", PostVisibility.Unlisted);
|
||||
var hidden = Held("for followers", PostVisibility.FollowersOnly);
|
||||
await DB.Default.SaveAsync(new[] { first, second, hidden }, Token);
|
||||
await DB.Default.SaveAsync(new[]
|
||||
{
|
||||
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = second.ObjectURI, PostId = second.ID, Position = 1 },
|
||||
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = hidden.ObjectURI, PostId = hidden.ID, Position = 2 },
|
||||
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = first.ObjectURI, PostId = first.ID, Position = 0 }
|
||||
}, Token);
|
||||
|
||||
var pinned = (await alice.Client.Get($"/api/v1/accounts/{bobId}/statuses?pinned=true")).Ok();
|
||||
|
||||
Assert.Equal(new[] { first.ID, second.ID }, pinned.Ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace PrivaPub.Tests.Support
|
||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
|
||||
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);
|
||||
Handlers = new IActivityHandler[]
|
||||
{
|
||||
new FollowHandler(Db, Local, Remote, Delivery),
|
||||
@@ -74,7 +75,9 @@ namespace PrivaPub.Tests.Support
|
||||
new FlagHandler(Db, Local),
|
||||
new BlockHandler(Db, Local),
|
||||
new LockHandler(Db),
|
||||
new MoveHandler(Remote, Db, Local, Follows, Relationships)
|
||||
new MoveHandler(Remote, Db, Local, Follows, Relationships),
|
||||
new FeaturedHandler(Featured, add: true),
|
||||
new FeaturedHandler(Featured, add: false)
|
||||
};
|
||||
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
|
||||
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
|
||||
@@ -114,6 +117,7 @@ namespace PrivaPub.Tests.Support
|
||||
public Participations Participations { get; }
|
||||
public TimelineService Timelines { get; }
|
||||
public RelationshipService Relationships { get; }
|
||||
public FeaturedPosts Featured { get; }
|
||||
public ReportService Reports { get; }
|
||||
|
||||
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace PrivaPub.Tests.Support
|
||||
["preferredUsername"] = Name,
|
||||
["inbox"] = Id + "/inbox",
|
||||
["followers"] = Id + "/followers",
|
||||
["featured"] = Id + "/featured",
|
||||
["publicKey"] = new JsonObject
|
||||
{
|
||||
["id"] = KeyId,
|
||||
|
||||
Reference in new issue
Block a user