Files
SocialPub/PrivaPub.Tests/Http/MastodonStatusesTests.cs
T

549 lines
31 KiB
C#
Raw Permalink Normal View History

2026-10-03 11:56:39 +02:00
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"]));
// named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message
Assert.Equal(new[] { followers }, Strings(quietNote["cc"]));
2026-10-03 11:56:39 +02:00
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);
}
}
}