Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks pass. Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships. Its scenario has 44 checks, which pass three runs in a row: - follows, posts, CW, followers-only posts and the published time; - replies, likes, boosts and their undos; - EmojiReact both ways and withdrawn; - DMs, polls, quotes, media, edits with history and deletes, both ways; - unfollow, block, unblock and statistics. The two bugs, both fixed: - Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private only if an address in `to` contains "/followers" or its cc is not empty. Ours is /groupies, and a post mentioning nobody had an empty cc. The followers collection is now named in cc as well, which tells nobody anything new. - Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end. Neither of these is a PrivaPub bug: - Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice with to[]. - Its API never reports a remote blocker as blocked_by, so the block is read from its database. Also in this commit: - The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst, InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts limit, which back-to-back runs from one address had hit. - A new Lemmy never sends what it queued for a server before it started that server's send worker, so the scenario waits for the worker before its first follow. All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey 35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
550 lines
31 KiB
C#
550 lines
31 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"));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|