T6: the Mastodon API over HTTP
88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.
Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
with indexed and array fields_attributes (form and JSON), source[*],
quote_policy, locked/bot, avatar and header uploads resized and stripped of
EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
and a circle's id answer 404); search with and without resolve (only a
signed-in persona resolves, the Peer is untouched otherwise), by post and
actor address, hashtags, undiscoverable personas; account statuses with
pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
both ways; followers-only posts for followers (local and remote authors);
community accounts; followers/following only to their owner; follow (open,
locked, remote Follow delivery), unfollow and Undo; follow requests from
local and remote followers answered with the original Follow;
remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
with duration and the notifications choice, never federated; domain blocks;
relationships with junk ids; a banned login's tokens; reports forwarded as a
Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
local and remote posts (mention, inReplyTo, the author's inbox); polls and
votes (local, and remote votes only to the author without published); media
attached only by its owner; quotes, the quotes list and revocation; edit
history, source and the Update delivery; delete for redraft, the 410
Tombstone and the Delete delivery; favourite/reblog counts with Like,
Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
interaction_policy matching canQuote in the note and in the Update;
strangers get 404 for followers-only and direct posts; a located post is
unreachable by id for anyone else on every route; statuses?id[];
Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
remote; tag (anonymous); list stub; favourites; conversations and read;
markers; notifications with types[], exclude_types[], account_id, paging,
get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
unreadable files, video and audio made with ffmpeg lavfi sources and checked
with ffprobe; every remote media address goes through the proxy; the proxy
refuses unsigned URLs, streams ranges as 206 without caching, caches whole
downloads and serves them with ranges, and streams anything over
Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
no signature, the trigger as activity) posts, visibility of provenance,
instance descriptions; reading any of them makes no outbound request.
Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
notifications.
Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
follower. It now sends Reject{Follow} with the stored Follow id, through
RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
so a post in their home timeline answered 404 to GET, context, favourite and
reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
post was gone; it answers 404.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
31d614efd4
commit
fc5bb9511f
12 files changed
+2425
-28
No files matched your search
@@ -0,0 +1,224 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
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 ProvenanceAndReactionsTests : 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 CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
static string Provenance(string id) => $"/api/privapub/v1/statuses/{id}/provenance";
|
||||
|
||||
[Fact]
|
||||
public async Task A_local_post_has_a_local_provenance()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var status = await alice.Status("made here");
|
||||
|
||||
var provenance = (await _host.Client().Get(Provenance(status.Text("id")))).Ok();
|
||||
|
||||
Assert.Equal("local", provenance.Body.Text("path"));
|
||||
Assert.Equal(status.Text("uri"), provenance.Body.Text("object_uri"));
|
||||
Assert.Equal(status.Text("url"), provenance.Body.Text("url"));
|
||||
Assert.Null(provenance.Body["signature"]);
|
||||
Assert.Null(provenance.Body["raw"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_delivered_post_shows_its_signature_and_raw_form_and_reading_it_asks_nobody()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var create = bob.Create("<p>signed and sealed</p>", new[] { MastodonHelpers.Public }, new[] { alice.Uri },
|
||||
note => note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }));
|
||||
await _host.Deliver(bob, alice.Mouth, create);
|
||||
var noteId = create["object"].Text("id");
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token);
|
||||
var asked = _peer.Requests.Count;
|
||||
|
||||
var provenance = (await alice.Client.Get(Provenance(post.ID))).Ok().Body;
|
||||
var anonymous = (await _host.Client().Get(Provenance(post.ID))).Ok().Body;
|
||||
|
||||
Assert.Equal("delivered", provenance.Text("path"));
|
||||
Assert.Equal(noteId, provenance.Text("object_uri"));
|
||||
Assert.Equal("Note", provenance.Text("object_type"));
|
||||
Assert.Null(provenance["fetched_by"]);
|
||||
Assert.Equal((create.Text("id"), "Create", bob.Id), (provenance["activity"].Text("id"), provenance["activity"].Text("type"), provenance["activity"].Text("actor")));
|
||||
Assert.Equal(bob.KeyId, provenance["signature"].Text("key_id"));
|
||||
Assert.Equal("rsa-sha256", provenance["signature"].Text("algorithm"));
|
||||
Assert.Contains("digest", provenance["signature"]!["headers"]!.AsArray().Select(h => h!.GetValue<string>()));
|
||||
Assert.Equal(noteId, provenance["raw"].Text("id"));
|
||||
Assert.False(string.IsNullOrEmpty(provenance.Text("raw_hash")));
|
||||
Assert.True(provenance.Number("raw_bytes") > 0);
|
||||
Assert.NotNull(provenance.Text("received_at"));
|
||||
Assert.Equal(provenance.ToJsonString(), anonymous.ToJsonString());
|
||||
Assert.Equal(asked, _peer.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fetched_parent_shows_no_signature_and_was_fetched_by_the_instance_actor()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var parentPath = $"/notes/{Guid.NewGuid():N}";
|
||||
var parentId = _peer.A + parentPath;
|
||||
_peer.Serve(parentPath, new JsonObject
|
||||
{
|
||||
["id"] = parentId,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = bob.Id,
|
||||
["to"] = new JsonArray(MastodonHelpers.Public),
|
||||
["content"] = "<p>the start of it</p>",
|
||||
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
|
||||
}.ToJsonString());
|
||||
var reply = bob.Create("<p>and a reply</p>", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, note =>
|
||||
{
|
||||
note["inReplyTo"] = parentId;
|
||||
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName });
|
||||
});
|
||||
await _host.Deliver(bob, alice.Mouth, reply);
|
||||
var parent = await DB.Default.Find<Post>().Match(p => p.ObjectURI == parentId).ExecuteFirstAsync(Token);
|
||||
Assert.NotNull(parent);
|
||||
var asked = _peer.Requests.Count;
|
||||
|
||||
var provenance = (await alice.Client.Get(Provenance(parent.ID))).Ok().Body;
|
||||
|
||||
Assert.Equal("fetched", provenance.Text("path"));
|
||||
Assert.Equal("instance-actor", provenance.Text("fetched_by"));
|
||||
Assert.Null(provenance["signature"]);
|
||||
Assert.Equal((reply.Text("id"), "Create"), (provenance["activity"].Text("id"), provenance["activity"].Text("type")));
|
||||
Assert.Null(provenance["inbox"]);
|
||||
Assert.Equal(parentId, provenance["raw"].Text("id"));
|
||||
Assert.Contains(_peer.Requests, r => r.Path == parentPath && r.Signature.Contains("/peasants/privapub#main-key"));
|
||||
Assert.Equal(asked, _peer.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provenance_follows_who_may_see_the_post()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var other = await _host.Mastodon("other");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
var direct = bob.Create("<p>between us</p>", new[] { alice.Uri }, shape: note =>
|
||||
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }));
|
||||
await _host.Deliver(bob, alice.Mouth, direct);
|
||||
var noteId = direct["object"].Text("id");
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token);
|
||||
|
||||
Assert.Equal("delivered", (await alice.Client.Get(Provenance(post.ID))).Ok().Body.Text("path"));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get(Provenance(post.ID))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance(post.ID))).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance("000000000000000000000000"))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_instance_is_described_from_what_was_stored_and_reading_it_asks_nobody()
|
||||
{
|
||||
var host = $"described{Guid.NewGuid():N}.example";
|
||||
await DB.Default.SaveAsync(new RemoteInstance
|
||||
{
|
||||
Host = host,
|
||||
Software = "gotosocial",
|
||||
SoftwareVersion = "0.20.0",
|
||||
NodeName = "a test pasture",
|
||||
Protocols = new List<string> { "activitypub" },
|
||||
OpenRegistrations = false,
|
||||
DescribedAt = DateTime.UtcNow.AddDays(-1),
|
||||
NodeInfo = "{\"software\":{\"name\":\"gotosocial\",\"version\":\"0.20.0\"}}",
|
||||
ConsecutiveFailures = 2,
|
||||
LastFailureAt = DateTime.UtcNow.AddHours(-1)
|
||||
}, Token);
|
||||
var alice = await _host.Mastodon("alice");
|
||||
|
||||
var described = (await alice.Client.Get($"/api/privapub/v1/instances/{host.ToUpperInvariant()}")).Ok().Body;
|
||||
|
||||
Assert.Equal(host, described.Text("host"));
|
||||
Assert.Equal(("gotosocial", "0.20.0", "a test pasture"), (described.Text("software"), described.Text("version"), described.Text("node_name")));
|
||||
Assert.Equal("activitypub", Assert.Single(described["protocols"]!.AsArray())!.GetValue<string>());
|
||||
Assert.False(described.Flag("open_registrations"));
|
||||
Assert.Equal("gotosocial", described["node_info"]!["software"].Text("name"));
|
||||
Assert.Equal(2, described["delivery"].Number("consecutive_failures"));
|
||||
Assert.NotNull(described["delivery"].Text("last_failure_at"));
|
||||
Assert.Null(described["delivery"]["last_success_at"]);
|
||||
Assert.Equal(described.ToJsonString(), (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body.ToJsonString());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example")).Status);
|
||||
Assert.Empty(_peer.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reacting_to_a_remote_post_federates_an_emoji_react_and_its_undo()
|
||||
{
|
||||
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 post = await _host.PublicPostFrom(bob, alice);
|
||||
var path = $"/api/v1/pleroma/statuses/{post.ID}/reactions";
|
||||
|
||||
var reacted = (await alice.Client.Put(path + "/%F0%9F%91%8D")).Ok();
|
||||
|
||||
var reaction = Assert.Single(reacted.Body["emoji_reactions"]!.AsArray());
|
||||
Assert.Equal(("👍", 1, true), (reaction.Text("name"), reaction.Number("count"), reaction.Flag("me")));
|
||||
Assert.Equal("👍", Assert.Single(reacted.Body["pleroma"]!["emoji_reactions"]!.AsArray()).Text("name"));
|
||||
(await carol.Client.Put(path + "/%F0%9F%91%8D")).Ok();
|
||||
var listed = Assert.Single((await _host.Client().Get(path)).Ok().Array);
|
||||
Assert.Equal(("👍", 2, false), (listed.Text("name"), listed.Number("count"), listed.Flag("me")));
|
||||
Assert.Equal(new[] { alice.Id, carol.Id }.Order(), listed!["accounts"]!.AsArray().Select(a => a.Text("id")).Order());
|
||||
Assert.True(Assert.Single((await alice.Client.Get(path)).Ok().Array).Flag("me"));
|
||||
var react = Assert.Single(await bob.Delivered(since), d => d.Type() == "EmojiReact" && d.Text("actor") == alice.Uri);
|
||||
Assert.Equal(("👍", post.ObjectURI), (react.Text("content"), react.Text("object")));
|
||||
|
||||
var withdrawn = (await alice.Client.Delete(path + "/%F0%9F%91%8D")).Ok();
|
||||
|
||||
Assert.Equal(1, Assert.Single(withdrawn.Body["emoji_reactions"]!.AsArray()).Number("count"));
|
||||
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo" && d.Text("actor") == alice.Uri);
|
||||
Assert.Equal(react.Text("id"), undo["object"].Text("id"));
|
||||
Assert.Equal("EmojiReact", undo["object"].Text("type"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put(path + "/abc")).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put("/api/v1/pleroma/statuses/000000000000000000000000/reactions/%F0%9F%91%8D")).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reacting_to_a_local_post_notifies_its_author_and_federates_nothing()
|
||||
{
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var carol = await _host.Mastodon("carol");
|
||||
var bob = new RemoteActor(_peer, "bob");
|
||||
await _host.FollowedBy(carol, bob);
|
||||
var post = await carol.Status("react to me");
|
||||
|
||||
(await alice.Client.Put($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok();
|
||||
|
||||
var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=pleroma:emoji_reaction")).Ok().Array);
|
||||
Assert.Equal(("🎉", alice.Id, post.Text("id")), (notification.Text("emoji"), notification["account"].Text("id"), notification["status"].Text("id")));
|
||||
Assert.DoesNotContain(await bob.Delivered(since), d => d.Type() is "EmojiReact" or "Like");
|
||||
(await alice.Client.Delete($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok();
|
||||
Assert.Empty((await carol.Client.Get($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions")).Ok().Array);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user