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,548 @@
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 MastodonStatusesTests : 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 IEnumerable<string> Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue<string>());
static async Task<JsonObject> Image(Mastodon account) =>
(await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v1/media")
{
Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(80, 60), "image/jpeg", "a.jpg"))
})).Ok().Object;
[Fact]
public async Task Each_visibility_is_addressed_the_way_mastodon_addresses_it()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var carol = new RemoteActor(_peer, "carol");
await _host.FollowedBy(alice, bob);
_peer.WebFinger(carol);
var followers = alice.Uri + "/groupies";
var open = await alice.Status("for everyone");
var unlisted = await alice.Status("quietly public", ("visibility", "unlisted"));
var quiet = await alice.Status("for followers", ("visibility", "private"));
var direct = await alice.Status($"@{carol.Handle()} just you", ("visibility", "direct"));
Assert.Equal(new[] { "public", "unlisted", "private", "direct" }, new[] { open, unlisted, quiet, direct }.Select(s => s.Text("visibility")));
var creates = (await bob.Delivered(since)).Where(d => d.Type() == "Create").ToDictionary(d => d["object"].Text("id"));
Assert.Equal(3, creates.Count);
var publicNote = creates[open.Text("uri")]["object"];
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(publicNote["to"]));
Assert.Equal(new[] { followers }, Strings(publicNote["cc"]));
var unlistedNote = creates[unlisted.Text("uri")]["object"];
Assert.Equal(new[] { followers }, Strings(unlistedNote["to"]));
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
var quietNote = creates[quiet.Text("uri")]["object"];
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
Assert.Empty(quietNote["cc"]!.AsArray());
var whisper = Assert.Single(await carol.Delivered(since));
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
Assert.Empty(whisper["object"]!["cc"]!.AsArray());
Assert.Equal(carol.Handle(), Assert.Single(direct["mentions"]!.AsArray()).Text("acct"));
}
[Fact]
public async Task A_content_warning_is_kept_as_spoiler_text_and_federates_as_a_summary()
{
var alice = await _host.Mastodon("alice");
var warned = await alice.Status("the butler did it", ("spoiler_text", "spoilers"));
Assert.True(warned.Flag("sensitive"));
Assert.Equal("spoilers", warned.Text("spoiler_text"));
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{warned.Text("id")}")).Ok().Body;
Assert.Equal("spoilers", note.Text("summary"));
Assert.True(note.Flag("sensitive"));
Assert.Contains("the butler did it", note.Text("content"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", " "))).Status);
}
[Fact]
public async Task A_reply_to_a_local_post_threads_counts_and_shows_in_the_context()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var root = await alice.Status("what do you think?");
var reply = await bob.Status("I agree", ("in_reply_to_id", root.Text("id")));
var nested = await alice.Status("thanks", ("in_reply_to_id", reply.Text("id")));
Assert.Equal(root.Text("id"), reply.Text("in_reply_to_id"));
Assert.Equal(alice.Id, reply.Text("in_reply_to_account_id"));
Assert.Equal(1, (await bob.Client.Get($"/api/v1/statuses/{root.Text("id")}")).Ok().Body.Number("replies_count"));
var context = (await _host.Client().Get($"/api/v1/statuses/{reply.Text("id")}/context")).Ok();
Assert.Equal(new[] { root.Text("id") }, context.Body["ancestors"]!.AsArray().Select(s => s.Text("id")));
Assert.Equal(new[] { nested.Text("id") }, context.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
var whole = (await alice.Client.Get($"/api/v1/statuses/{root.Text("id")}/context")).Ok();
Assert.Equal(new[] { reply.Text("id"), nested.Text("id") }, whole.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Post("/api/v1/statuses", ("status", "lost"), ("in_reply_to_id", "000000000000000000000000"))).Status);
}
[Fact]
public async Task A_reply_to_a_remote_post_mentions_its_author_and_reaches_its_inbox()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
_peer.WebFinger(bob);
var parent = await _host.PublicPostFrom(bob, alice);
var bobId = (await _host.Known(bob)).ID;
var reply = await alice.Status($"@{bob.Handle()} well said", ("in_reply_to_id", parent.ID));
Assert.Equal(parent.ID, reply.Text("in_reply_to_id"));
Assert.Equal(bobId, reply.Text("in_reply_to_account_id"));
var mention = Assert.Single(reply["mentions"]!.AsArray());
Assert.Equal((bobId, bob.Handle()), (mention.Text("id"), mention.Text("acct")));
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
var note = create["object"]!;
Assert.Equal(parent.ObjectURI, note.Text("inReplyTo"));
Assert.Contains(bob.Id, Strings(note["cc"]));
Assert.Contains(note["tag"]!.AsArray(), t => t.Text("type") == "Mention" && t.Text("href") == bob.Id);
Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count"));
}
[Fact]
public async Task A_poll_is_created_validated_and_federated_as_a_question()
{
var alice = await _host.Mastodon("alice");
var status = await alice.Status("tea or coffee?", ("poll[options][]", "tea"), ("poll[options][]", "coffee"), ("poll[expires_in]", "600"),
("poll[multiple]", "true"));
var poll = status["poll"]!;
Assert.Equal(new[] { "tea", "coffee" }, poll["options"]!.AsArray().Select(o => o.Text("title")));
Assert.True(poll.Flag("multiple"));
Assert.False(poll.Flag("expired"));
Assert.NotNull(poll.Text("expires_at"));
Assert.Equal(status.Text("id"), (await _host.Client().Get($"/api/v1/polls/{status.Text("id")}")).Ok().Body.Text("id"));
var question = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
Assert.Equal("Question", question.Text("type"));
Assert.Equal(2, question["anyOf"]!.AsArray().Count);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "one?"), ("poll[options][]", "only"),
("poll[expires_in]", "600"))).Status);
}
[Fact]
public async Task Media_is_attached_only_by_its_owner()
{
var alice = await _host.Mastodon("alice");
var mallory = await _host.Mastodon("mallory");
var upload = await Image(alice);
var status = await alice.Status("look", ("media_ids[]", upload.Text("id")));
var attachment = Assert.Single(status["media_attachments"]!.AsArray());
Assert.Equal(upload.Text("id"), attachment.Text("id"));
Assert.Equal("image", attachment.Text("type"));
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
Assert.Equal(upload.Text("url"), Assert.Single(note["attachment"]!.AsArray()).Text("url"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await mallory.Client.Post("/api/v1/statuses", ("status", "mine"), ("media_ids[]", (await Image(alice)).Text("id")))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "again"), ("media_ids[]", upload.Text("id")))).Status);
}
[Fact]
public async Task A_quote_of_a_local_post_is_accepted_listed_and_revocable()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var original = await carol.Status("quote me");
var closed = await carol.Status("do not quote me", ("quote_approval_policy", "nobody"));
var quote = await alice.Status("so true", ("quoted_status_id", original.Text("id")));
Assert.Equal("accepted", quote["quote"].Text("state"));
Assert.Equal(original.Text("id"), quote["quote"]!["quoted_status"].Text("id"));
Assert.Equal(1, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
Assert.Equal(new[] { quote.Text("id") }, (await _host.Client().Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Ids);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "anyway"), ("quoted_status_id", closed.Text("id")))).Status);
Assert.Empty(closed["quote_approval"]!["automatic"]!.AsArray());
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Status);
var revoked = (await carol.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Ok();
Assert.Equal(quote.Text("id"), revoked.Body.Text("id"));
Assert.Equal("revoked", revoked.Body["quote"].Text("state"));
Assert.Empty((await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Array);
Assert.Equal(0, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
}
[Fact]
public async Task An_edit_keeps_its_history_shows_its_source_and_federates_an_update()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var other = await _host.Mastodon("other");
var bob = new RemoteActor(_peer, "bob");
await _host.FollowedBy(alice, bob);
var status = await alice.Status("first version", ("spoiler_text", "cw"));
var id = status.Text("id");
var edited = (await alice.Client.Put($"/api/v1/statuses/{id}", ("status", "second version"))).Ok();
Assert.NotNull(edited.Body.Text("edited_at"));
Assert.Contains("second version", edited.Body.Text("content"));
Assert.Contains("second version", (await other.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Text("content"));
var history = (await _host.Client().Get($"/api/v1/statuses/{id}/history")).Ok().Array;
Assert.Equal(2, history.Count);
Assert.Contains("first version", history[0].Text("content"));
Assert.Equal("cw", history[0].Text("spoiler_text"));
Assert.Contains("second version", history[1].Text("content"));
Assert.Equal(alice.Id, history[1]!["account"].Text("id"));
var source = (await alice.Client.Get($"/api/v1/statuses/{id}/source")).Ok();
Assert.Equal(("second version", ""), (source.Body.Text("text"), source.Body.Text("spoiler_text")));
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get($"/api/v1/statuses/{id}/source")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/statuses/{id}", ("status", "hijacked"))).Status);
var update = Assert.Single(await bob.Delivered(since), d => d.Type() == "Update");
Assert.Equal(status.Text("uri"), update["object"].Text("id"));
Assert.Contains("second version", update["object"].Text("content"));
}
[Fact]
public async Task Delete_returns_the_text_for_a_redraft_and_leaves_a_tombstone()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var other = await _host.Mastodon("other");
var bob = new RemoteActor(_peer, "bob");
await _host.FollowedBy(alice, bob);
var status = await alice.Status("regrettable", ("spoiler_text", "hot take"));
var id = status.Text("id");
var scribble = $"/peasants/{alice.UserName}/scribbles/{id}";
Assert.Equal(HttpStatusCode.OK, (await _host.ActivityPub(scribble)).Status);
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Delete($"/api/v1/statuses/{id}")).Status);
var deleted = (await alice.Client.Delete($"/api/v1/statuses/{id}")).Ok();
Assert.Equal(id, deleted.Body.Text("id"));
Assert.Equal("regrettable", deleted.Body.Text("text"));
Assert.Equal("hot take", deleted.Body.Text("spoiler_text"));
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Delete($"/api/v1/statuses/{id}")).Status);
var tombstone = await _host.ActivityPub(scribble);
Assert.Equal(HttpStatusCode.Gone, tombstone.Status);
Assert.Equal("Tombstone", tombstone.Body.Text("type"));
Assert.Equal(status.Text("uri"), tombstone.Body.Text("id"));
var delete = Assert.Single(await bob.Delivered(since), d => d.Type() == "Delete");
Assert.Equal(status.Text("uri"), delete["object"].Text("id"));
}
[Fact]
public async Task Favourites_and_reblogs_count_and_federate_with_their_undos()
{
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 remote = await _host.PublicPostFrom(bob, alice);
var path = $"/api/v1/statuses/{remote.ID}";
var liked = (await alice.Client.Post(path + "/favourite")).Ok();
Assert.True(liked.Body.Flag("favourited"));
Assert.Equal(1, liked.Body.Number("favourites_count"));
Assert.Equal(new[] { alice.Id }, (await _host.Client().Get(path + "/favourited_by")).Ok().Ids);
Assert.Equal(1, (await alice.Client.Post(path + "/favourite")).Ok().Body.Number("favourites_count"));
var unliked = (await alice.Client.Post(path + "/unfavourite")).Ok();
Assert.False(unliked.Body.Flag("favourited"));
Assert.Equal(0, unliked.Body.Number("favourites_count"));
var reblog = (await alice.Client.Post(path + "/reblog")).Ok();
Assert.Equal(remote.ID, reblog.Body["reblog"].Text("id"));
Assert.True(reblog.Body["reblog"].Flag("reblogged"));
Assert.Equal(1, reblog.Body["reblog"].Number("reblogs_count"));
Assert.Equal(new[] { alice.Id }, (await carol.Client.Get(path + "/reblogged_by")).Ok().Ids);
var unreblogged = (await alice.Client.Post(path + "/unreblog")).Ok();
Assert.False(unreblogged.Body.Flag("reblogged"));
Assert.Equal(0, unreblogged.Body.Number("reblogs_count"));
Assert.Empty((await carol.Client.Get(path + "/reblogged_by")).Ok().Array);
var sent = await bob.Delivered(since);
var like = Assert.Single(sent, d => d.Type() == "Like");
Assert.Equal((alice.Uri, remote.ObjectURI), (like.Text("actor"), like.Text("object")));
var announce = Assert.Single(sent, d => d.Type() == "Announce");
Assert.Equal(remote.ObjectURI, announce.Text("object"));
var undos = sent.Where(d => d.Type() == "Undo").ToList();
Assert.Equal(new[] { "Announce", "Like" }, undos.Select(u => u["object"].Text("type")).Order());
Assert.Equal(like.Text("id"), undos.Single(u => u["object"].Text("type") == "Like")["object"].Text("id"));
var local = await carol.Status("a local one");
(await alice.Client.Post($"/api/v1/statuses/{local.Text("id")}/favourite")).Ok();
var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array);
Assert.Equal((alice.Id, local.Text("id")), (notification["account"].Text("id"), notification["status"].Text("id")));
var quiet = await carol.Status("followers only", ("visibility", "private"));
(await alice.Client.Post($"/api/v1/accounts/{carol.Id}/follow")).Ok();
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/reblog")).Status);
Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited"));
}
[Fact]
public async Task Bookmarks_are_kept_listed_and_removed()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var first = await carol.Status("worth keeping");
var second = await carol.Status("this too");
Assert.True((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/bookmark")).Ok().Body.Flag("bookmarked"));
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
Assert.Equal(new[] { second.Text("id"), first.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
Assert.Empty((await carol.Client.Get("/api/v1/bookmarks")).Ok().Array);
var page = (await alice.Client.Get("/api/v1/bookmarks?limit=1")).Ok();
Assert.Equal(new[] { second.Text("id") }, page.Ids);
Assert.Equal(new[] { first.Text("id") }, (await alice.Client.Get(page.Link("next"))).Ok().Ids);
Assert.False((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/unbookmark")).Ok().Body.Flag("bookmarked"));
Assert.Equal(new[] { second.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/statuses/000000000000000000000000/bookmark")).Status);
}
[Fact]
public async Task Pins_take_only_the_owners_public_posts()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var mine = await alice.Status("pin me");
var quiet = await alice.Status("not this", ("visibility", "private"));
var theirs = await carol.Status("not yours");
Assert.True((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/pin")).Ok().Body.Flag("pinned"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{theirs.Text("id")}/pin")).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
var featured = (await _host.ActivityPub($"/peasants/{alice.UserName}/trophies")).Ok().Body;
Assert.Equal(mine.Text("uri"), Assert.Single(featured["orderedItems"]!.AsArray()).Text("id"));
for (var i = 0; i < 4; i++)
(await alice.Client.Post($"/api/v1/statuses/{(await alice.Status($"more {i}")).Text("id")}/pin")).Ok();
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{(await alice.Status("one too many")).Text("id")}/pin")).Status);
Assert.False((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/unpin")).Ok().Body.Flag("pinned"));
Assert.Equal(4, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses?pinned=true")).Ok().Array.Count);
}
[Fact]
public async Task The_interaction_policy_sets_who_may_quote_and_matches_the_activitypub_note()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
await _host.FollowedBy(alice, bob);
var open = await alice.Status("quotable");
var quiet = await alice.Status("followers only", ("visibility", "private"));
var scribble = $"/peasants/{alice.UserName}/scribbles/{open.Text("id")}";
string CanQuote(JsonNode note) => Assert.Single(note["interactionPolicy"]!["canQuote"]!["automaticApproval"]!.AsArray())!.GetValue<string>();
Assert.Equal(new[] { "public" }, Strings(open["quote_approval"]!["automatic"]));
Assert.Equal(MastodonHelpers.Public, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
var followers = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "followers"))).Ok();
Assert.Equal(new[] { "followers" }, Strings(followers.Body["quote_approval"]!["automatic"]));
Assert.Equal(alice.Uri + "/groupies", CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
var nobody = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "nobody"))).Ok();
Assert.Empty(nobody.Body["quote_approval"]!["automatic"]!.AsArray());
Assert.Equal(alice.Uri, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
var forced = (await alice.Client.Put($"/api/v1/statuses/{quiet.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Ok();
Assert.Empty(forced.Body["quote_approval"]!["automatic"]!.AsArray());
var updates = (await bob.Delivered(since)).Where(d => d.Type() == "Update").ToList();
Assert.Equal(3, updates.Count);
Assert.Equal(alice.Uri, CanQuote(updates.Single(u => u["object"].Text("id") == quiet.Text("uri"))["object"]));
Assert.Equal(HttpStatusCode.UnprocessableEntity,
(await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "everyone"))).Status);
Assert.Equal(HttpStatusCode.NotFound,
(await (await _host.Mastodon("other")).Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Status);
}
[Fact]
public async Task A_stranger_gets_404_for_followers_only_and_direct_posts()
{
var alice = await _host.Mastodon("alice");
var follower = await _host.Mastodon("follower");
var friend = await _host.Mastodon("friend");
var stranger = await _host.Mastodon("stranger");
(await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
var quiet = await alice.Status("followers only", ("visibility", "private"));
var direct = await alice.Status($"@{friend.UserName} only you", ("visibility", "direct"));
var anonymous = _host.Client();
foreach (var id in new[] { quiet.Text("id"), direct.Text("id") })
{
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}/context")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/favourite")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/bookmark")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}/favourited_by")).Status);
}
Assert.Empty((await stranger.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Array);
Assert.Equal(new[] { quiet.Text("id") }, (await follower.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
Assert.Equal(new[] { direct.Text("id") }, (await friend.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}/context")).Status);
Assert.Equal(quiet.Text("id"), (await follower.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", quiet.Text("id")))).Ok().Body.Text("in_reply_to_id"));
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{quiet.Text("id")}")).Status);
}
[Fact]
public async Task A_located_post_is_never_reachable_by_id_by_anyone_else()
{
var alice = await _host.Mastodon("alice");
var other = await _host.Mastodon("other");
var id = await _host.Located(alice, "the bench by the river");
var anonymous = _host.Client();
foreach (var client in new[] { other.Client, anonymous })
{
foreach (var path in new[] { "", "/context", "/history", "/favourited_by", "/reblogged_by", "/quotes" })
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/statuses/{id}{path}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/privapub/v1/statuses/{id}/provenance")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/pleroma/statuses/{id}/reactions")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/polls/{id}")).Status);
Assert.Empty((await client.Get($"/api/v1/statuses?id[]={id}")).Ok().Array);
Assert.DoesNotContain(id, (await client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Ids);
}
foreach (var action in new[] { "favourite", "reblog", "bookmark", "pin" })
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post($"/api/v1/statuses/{id}/{action}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/pleroma/statuses/{id}/reactions/%F0%9F%91%8D")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "qt"), ("quoted_status_id", id))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{id}")).Status);
}
[Fact]
public async Task Creating_honours_the_idempotency_key_the_scopes_and_needs_a_recipient_for_a_direct_message()
{
var alice = await _host.Mastodon("alice");
var reader = await _host.Mastodon("reader", scopes: "read");
var key = Guid.NewGuid().ToString("N");
Task<ApiAnswer> Keyed(string text)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["status"] = text }) };
request.Headers.Add("Idempotency-Key", key);
return alice.Client.Exchange(request);
}
var first = (await Keyed("once")).Ok();
var second = (await Keyed("once again")).Ok();
Assert.Equal(first.Body.Text("id"), second.Body.Text("id"));
Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
var noRecipient = await alice.Client.Post("/api/v1/statuses", ("status", "to nobody"), ("visibility", "direct"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, noRecipient.Status);
Assert.Contains("recipient", noRecipient.Body.Text("error"));
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post("/api/v1/statuses", ("status", "read only"))).Status);
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post($"/api/v1/statuses/{first.Body.Text("id")}/favourite")).Status);
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get($"/api/v1/statuses/{first.Body.Text("id")}")).Status);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post("/api/v1/statuses", ("status", "anonymous"))).Status);
}
[Fact]
public async Task Local_polls_take_one_vote_per_persona_and_never_from_their_author()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var poll = await alice.Status("pick one", ("poll[options][]", "red"), ("poll[options][]", "blue"), ("poll[expires_in]", "3600"));
var path = $"/api/v1/polls/{poll.Text("id")}";
var voted = (await carol.Client.Post(path + "/votes", ("choices[]", "1"))).Ok();
Assert.True(voted.Body.Flag("voted"));
Assert.Equal(new[] { 1 }, voted.Body["own_votes"]!.AsArray().Select(v => v!.GetValue<int>()));
Assert.Equal(1, voted.Body.Number("votes_count"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await carol.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("dave")).Client.Post(path + "/votes", ("choices[]", "0"), ("choices[]", "1"))).Status);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("erin")).Client.Post(path + "/votes", ("choices[]", "7"))).Status);
var anonymous = (await _host.Client().Get(path)).Ok();
Assert.Null(anonymous.Body["voted"]);
Assert.Equal(new[] { 0, 1 }, anonymous.Body["options"]!.AsArray().Select(o => o.Number("votes_count")));
}
[Fact]
public async Task A_vote_on_a_remote_poll_goes_only_to_its_author_without_a_published_date()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var carol = new RemoteActor(_peer, "carol");
await _host.FollowedBy(alice, carol);
var question = await _host.PublicPostFrom(bob, alice, "<p>which?</p>", note =>
{
note["type"] = "Question";
note["oneOf"] = new JsonArray(
new JsonObject { ["type"] = "Note", ["name"] = "this", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } },
new JsonObject { ["type"] = "Note", ["name"] = "that", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } });
note["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O");
});
var voted = (await alice.Client.Post($"/api/v1/polls/{question.ID}/votes", ("choices[]", "1"))).Ok();
Assert.True(voted.Body.Flag("voted"));
var vote = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
Assert.Equal("that", vote["object"].Text("name"));
Assert.Equal(question.ObjectURI, vote["object"].Text("inReplyTo"));
Assert.Null(vote["object"]!["published"]);
Assert.Equal(new[] { bob.Id }, Strings(vote["object"]!["to"]));
Assert.DoesNotContain(await carol.Delivered(since), d => d.Type() == "Create");
}
[Fact]
public async Task Deleting_a_reblog_undoes_it()
{
var since = DateTime.UtcNow.AddSeconds(-1);
var alice = await _host.Mastodon("alice");
var bob = new RemoteActor(_peer, "bob");
var remote = await _host.PublicPostFrom(bob, alice);
var reblog = (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok().Body;
(await alice.Client.Delete($"/api/v1/statuses/{reblog.Text("id")}")).Ok();
Assert.False((await alice.Client.Get($"/api/v1/statuses/{remote.ID}")).Ok().Body.Flag("reblogged"));
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{reblog.Text("id")}")).Status);
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo");
Assert.Equal("Announce", undo["object"].Text("type"));
}
[Fact]
public async Task Many_statuses_are_fetched_by_id_in_one_call()
{
var alice = await _host.Mastodon("alice");
var first = await alice.Status("one");
var second = await alice.Status("two");
var answer = (await _host.Client().Get($"/api/v1/statuses?id[]={first.Text("id")}&id[]={second.Text("id")}&id[]=junk")).Ok();
Assert.Equal(new[] { first.Text("id"), second.Text("id") }, answer.Ids.Order());
Assert.Empty((await _host.Client().Get("/api/v1/statuses")).Ok().Array);
}
}
}