T6: the Mastodon API over HTTP

88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.

Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
  with indexed and array fields_attributes (form and JSON), source[*],
  quote_policy, locked/bot, avatar and header uploads resized and stripped of
  EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
  and a circle's id answer 404); search with and without resolve (only a
  signed-in persona resolves, the Peer is untouched otherwise), by post and
  actor address, hashtags, undiscoverable personas; account statuses with
  pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
  both ways; followers-only posts for followers (local and remote authors);
  community accounts; followers/following only to their owner; follow (open,
  locked, remote Follow delivery), unfollow and Undo; follow requests from
  local and remote followers answered with the original Follow;
  remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
  with duration and the notifications choice, never federated; domain blocks;
  relationships with junk ids; a banned login's tokens; reports forwarded as a
  Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
  local and remote posts (mention, inReplyTo, the author's inbox); polls and
  votes (local, and remote votes only to the author without published); media
  attached only by its owner; quotes, the quotes list and revocation; edit
  history, source and the Update delivery; delete for redraft, the 410
  Tombstone and the Delete delivery; favourite/reblog counts with Like,
  Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
  interaction_policy matching canQuote in the note and in the Update;
  strangers get 404 for followers-only and direct posts; a located post is
  unreachable by id for anyone else on every route; statuses?id[];
  Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
  remote; tag (anonymous); list stub; favourites; conversations and read;
  markers; notifications with types[], exclude_types[], account_id, paging,
  get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
  extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
  unreadable files, video and audio made with ffmpeg lavfi sources and checked
  with ffprobe; every remote media address goes through the proxy; the proxy
  refuses unsigned URLs, streams ranges as 206 without caching, caches whole
  downloads and serves them with ranges, and streams anything over
  Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
  no signature, the trigger as activity) posts, visibility of provenance,
  instance descriptions; reading any of them makes no outbound request.
  Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
  notifications.

Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
  follower. It now sends Reject{Follow} with the stored Follow id, through
  RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
  so a post in their home timeline answered 404 to GET, context, favourite and
  reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
  personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
  drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
  addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
  Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
  post was gone; it answers 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:58:31 +02:00
1 parent 31d614efd4
commit fc5bb9511f
12 files changed
+2425 -28

No files matched your search

@@ -0,0 +1,224 @@
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class ProvenanceAndReactionsTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
static CancellationToken Token => TestContext.Current.CancellationToken;
static string Provenance(string id) => $"/api/privapub/v1/statuses/{id}/provenance";
[Fact]
public async Task A_local_post_has_a_local_provenance()
{
var alice = await _host.Mastodon("alice");
var status = await alice.Status("made here");
var provenance = (await _host.Client().Get(Provenance(status.Text("id")))).Ok();
Assert.Equal("local", provenance.Body.Text("path"));
Assert.Equal(status.Text("uri"), provenance.Body.Text("object_uri"));
Assert.Equal(status.Text("url"), provenance.Body.Text("url"));
Assert.Null(provenance.Body["signature"]);
Assert.Null(provenance.Body["raw"]);
}
[Fact]
public async Task A_delivered_post_shows_its_signature_and_raw_form_and_reading_it_asks_nobody()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var create = bob.Create("<p>signed and sealed</p>", new[] { MastodonHelpers.Public }, new[] { alice.Uri },
note => note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }));
await _host.Deliver(bob, alice.Mouth, create);
var noteId = create["object"].Text("id");
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token);
var asked = _peer.Requests.Count;
var provenance = (await alice.Client.Get(Provenance(post.ID))).Ok().Body;
var anonymous = (await _host.Client().Get(Provenance(post.ID))).Ok().Body;
Assert.Equal("delivered", provenance.Text("path"));
Assert.Equal(noteId, provenance.Text("object_uri"));
Assert.Equal("Note", provenance.Text("object_type"));
Assert.Null(provenance["fetched_by"]);
Assert.Equal((create.Text("id"), "Create", bob.Id), (provenance["activity"].Text("id"), provenance["activity"].Text("type"), provenance["activity"].Text("actor")));
Assert.Equal(bob.KeyId, provenance["signature"].Text("key_id"));
Assert.Equal("rsa-sha256", provenance["signature"].Text("algorithm"));
Assert.Contains("digest", provenance["signature"]!["headers"]!.AsArray().Select(h => h!.GetValue<string>()));
Assert.Equal(noteId, provenance["raw"].Text("id"));
Assert.False(string.IsNullOrEmpty(provenance.Text("raw_hash")));
Assert.True(provenance.Number("raw_bytes") > 0);
Assert.NotNull(provenance.Text("received_at"));
Assert.Equal(provenance.ToJsonString(), anonymous.ToJsonString());
Assert.Equal(asked, _peer.Requests.Count);
}
[Fact]
public async Task A_fetched_parent_shows_no_signature_and_was_fetched_by_the_instance_actor()
{
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var parentPath = $"/notes/{Guid.NewGuid():N}";
var parentId = _peer.A + parentPath;
_peer.Serve(parentPath, new JsonObject
{
["id"] = parentId,
["type"] = "Note",
["attributedTo"] = bob.Id,
["to"] = new JsonArray(MastodonHelpers.Public),
["content"] = "<p>the start of it</p>",
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
}.ToJsonString());
var reply = bob.Create("<p>and a reply</p>", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, note =>
{
note["inReplyTo"] = parentId;
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName });
});
await _host.Deliver(bob, alice.Mouth, reply);
var parent = await DB.Default.Find<Post>().Match(p => p.ObjectURI == parentId).ExecuteFirstAsync(Token);
Assert.NotNull(parent);
var asked = _peer.Requests.Count;
var provenance = (await alice.Client.Get(Provenance(parent.ID))).Ok().Body;
Assert.Equal("fetched", provenance.Text("path"));
Assert.Equal("instance-actor", provenance.Text("fetched_by"));
Assert.Null(provenance["signature"]);
Assert.Equal((reply.Text("id"), "Create"), (provenance["activity"].Text("id"), provenance["activity"].Text("type")));
Assert.Null(provenance["inbox"]);
Assert.Equal(parentId, provenance["raw"].Text("id"));
Assert.Contains(_peer.Requests, r => r.Path == parentPath && r.Signature.Contains("/peasants/privapub#main-key"));
Assert.Equal(asked, _peer.Requests.Count);
}
[Fact]
public async Task Provenance_follows_who_may_see_the_post()
{
var alice = await _host.Mastodon("alice");
var other = await _host.Mastodon("other");
var bob = new RemoteActor(_peer, "bob");
var direct = bob.Create("<p>between us</p>", new[] { alice.Uri }, shape: note =>
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }));
await _host.Deliver(bob, alice.Mouth, direct);
var noteId = direct["object"].Text("id");
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token);
Assert.Equal("delivered", (await alice.Client.Get(Provenance(post.ID))).Ok().Body.Text("path"));
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get(Provenance(post.ID))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance(post.ID))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance("000000000000000000000000"))).Status);
}
[Fact]
public async Task An_instance_is_described_from_what_was_stored_and_reading_it_asks_nobody()
{
var host = $"described{Guid.NewGuid():N}.example";
await DB.Default.SaveAsync(new RemoteInstance
{
Host = host,
Software = "gotosocial",
SoftwareVersion = "0.20.0",
NodeName = "a test pasture",
Protocols = new List<string> { "activitypub" },
OpenRegistrations = false,
DescribedAt = DateTime.UtcNow.AddDays(-1),
NodeInfo = "{\"software\":{\"name\":\"gotosocial\",\"version\":\"0.20.0\"}}",
ConsecutiveFailures = 2,
LastFailureAt = DateTime.UtcNow.AddHours(-1)
}, Token);
var alice = await _host.Mastodon("alice");
var described = (await alice.Client.Get($"/api/privapub/v1/instances/{host.ToUpperInvariant()}")).Ok().Body;
Assert.Equal(host, described.Text("host"));
Assert.Equal(("gotosocial", "0.20.0", "a test pasture"), (described.Text("software"), described.Text("version"), described.Text("node_name")));
Assert.Equal("activitypub", Assert.Single(described["protocols"]!.AsArray())!.GetValue<string>());
Assert.False(described.Flag("open_registrations"));
Assert.Equal("gotosocial", described["node_info"]!["software"].Text("name"));
Assert.Equal(2, described["delivery"].Number("consecutive_failures"));
Assert.NotNull(described["delivery"].Text("last_failure_at"));
Assert.Null(described["delivery"]["last_success_at"]);
Assert.Equal(described.ToJsonString(), (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body.ToJsonString());
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example")).Status);
Assert.Empty(_peer.Requests);
}
[Fact]
public async Task Reacting_to_a_remote_post_federates_an_emoji_react_and_its_undo()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var bob = new RemoteActor(_peer, "bob");
var post = await _host.PublicPostFrom(bob, alice);
var path = $"/api/v1/pleroma/statuses/{post.ID}/reactions";
var reacted = (await alice.Client.Put(path + "/%F0%9F%91%8D")).Ok();
var reaction = Assert.Single(reacted.Body["emoji_reactions"]!.AsArray());
Assert.Equal(("👍", 1, true), (reaction.Text("name"), reaction.Number("count"), reaction.Flag("me")));
Assert.Equal("👍", Assert.Single(reacted.Body["pleroma"]!["emoji_reactions"]!.AsArray()).Text("name"));
(await carol.Client.Put(path + "/%F0%9F%91%8D")).Ok();
var listed = Assert.Single((await _host.Client().Get(path)).Ok().Array);
Assert.Equal(("👍", 2, false), (listed.Text("name"), listed.Number("count"), listed.Flag("me")));
Assert.Equal(new[] { alice.Id, carol.Id }.Order(), listed!["accounts"]!.AsArray().Select(a => a.Text("id")).Order());
Assert.True(Assert.Single((await alice.Client.Get(path)).Ok().Array).Flag("me"));
var react = Assert.Single(await bob.Delivered(since), d => d.Type() == "EmojiReact" && d.Text("actor") == alice.Uri);
Assert.Equal(("👍", post.ObjectURI), (react.Text("content"), react.Text("object")));
var withdrawn = (await alice.Client.Delete(path + "/%F0%9F%91%8D")).Ok();
Assert.Equal(1, Assert.Single(withdrawn.Body["emoji_reactions"]!.AsArray()).Number("count"));
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo" && d.Text("actor") == alice.Uri);
Assert.Equal(react.Text("id"), undo["object"].Text("id"));
Assert.Equal("EmojiReact", undo["object"].Text("type"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put(path + "/abc")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put("/api/v1/pleroma/statuses/000000000000000000000000/reactions/%F0%9F%91%8D")).Status);
}
[Fact]
public async Task Reacting_to_a_local_post_notifies_its_author_and_federates_nothing()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var bob = new RemoteActor(_peer, "bob");
await _host.FollowedBy(carol, bob);
var post = await carol.Status("react to me");
(await alice.Client.Put($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok();
var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=pleroma:emoji_reaction")).Ok().Array);
Assert.Equal(("🎉", alice.Id, post.Text("id")), (notification.Text("emoji"), notification["account"].Text("id"), notification["status"].Text("id")));
Assert.DoesNotContain(await bob.Delivered(since), d => d.Type() is "EmojiReact" or "Like");
(await alice.Client.Delete($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok();
Assert.Empty((await carol.Client.Get($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions")).Ok().Array);
}
}
}