A reply whose text had lost the @name of the author it answers (decePub prefills it, a reader may delete it) was addressed to nobody on the author's server. PrivaPub delivered it to the author's inbox anyway, but GoToSocial keeps only what is addressed to someone there, so the reply never appeared under the post. A public or unlisted reply to a remote post now names its parent's author in cc, as Pleroma does; the reply already says whom it answers, so nothing new is revealed. The parent author's actor id is kept on the reply (InReplyToActorURI). Found by decePub's end-to-end tests on the town. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
601 lines
33 KiB
C#
601 lines
33 KiB
C#
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"]));
|
|
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"));
|
|
}
|
|
|
|
// Akkoma marks a post with sensitive media "sensitive" and leaves its summary empty: the media is hidden, the words are
|
|
// not, and no warning is made up for them (found by decePub's e2e tests on the town)
|
|
[Fact]
|
|
public async Task A_remote_post_sensitive_without_a_summary_hides_its_media_and_not_its_words()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var post = await _host.PublicPostFrom(bob, alice, shape: note => { note["sensitive"] = true; note["summary"] = ""; });
|
|
|
|
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
|
|
|
|
Assert.True(status["sensitive"]!.GetValue<bool>());
|
|
Assert.Equal("", status.Text("spoiler_text"));
|
|
}
|
|
|
|
// a status asked for by id is shown even when the viewer blocked or muted its author, as Mastodon shows it: only lists
|
|
// leave such posts out (it once answered 200 with null, and decePub's thread page showed nothing)
|
|
[Fact]
|
|
public async Task A_status_of_an_author_the_viewer_blocked_is_still_shown_by_its_id()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var post = await _host.PublicPostFrom(bob, alice);
|
|
var bobId = (await _host.Known(bob)).ID;
|
|
(await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok();
|
|
|
|
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
|
|
|
|
Assert.Equal(post.ID, status.Text("id"));
|
|
}
|
|
|
|
// a reader who deletes the @name decePub prefills still answers its author: GoToSocial dropped such a reply, addressed
|
|
// to nobody there (found by decePub's e2e tests on the town)
|
|
[Fact]
|
|
public async Task A_reply_without_the_authors_name_still_names_the_author_in_cc()
|
|
{
|
|
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 reply = await alice.Status("no name in this one", ("in_reply_to_id", parent.ID));
|
|
|
|
Assert.Empty(reply["mentions"]!.AsArray());
|
|
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
|
Assert.Contains(bob.Id, Strings(create["object"]!["cc"]));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|