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 Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue()); static async Task 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"])); 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(); 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 Keyed(string text) { var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary { ["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())); 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, "

which?

", 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); } } }