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,232 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using NetVips;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PrivaPub.Tests.Support.Host
|
||||
{
|
||||
public sealed record Mastodon(Persona Persona, string Token, HttpClient Client)
|
||||
{
|
||||
public string Id => Persona.Id;
|
||||
public string UserName => Persona.UserName;
|
||||
public string Uri => $"{PrivaPubHost.Base}/peasants/{Persona.UserName}";
|
||||
public string Mouth => $"/peasants/{Persona.UserName}/mouth";
|
||||
}
|
||||
|
||||
public sealed record ApiAnswer(HttpStatusCode Status, JsonNode Body, string Text, HttpResponseHeaders Headers)
|
||||
{
|
||||
public JsonObject Object => Body.AsObject();
|
||||
public JsonArray Array => Body.AsArray();
|
||||
public IEnumerable<string> Ids => Array.Select(item => item!["id"]!.GetValue<string>());
|
||||
|
||||
public ApiAnswer Ok()
|
||||
{
|
||||
Assert.True(Status == HttpStatusCode.OK, $"expected 200, got {(int)Status}: {Text}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public string Link(string rel) =>
|
||||
Headers.TryGetValues("Link", out var values)
|
||||
? Regex.Matches(string.Join(", ", values), "<([^>]+)>; rel=\"([a-z]+)\"").FirstOrDefault(m => m.Groups[2].Value == rel)?.Groups[1].Value
|
||||
: default;
|
||||
}
|
||||
|
||||
public static class MastodonHelpers
|
||||
{
|
||||
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
public static async Task<Mastodon> Mastodon(this PrivaPubHost host, string name = "persona", Root root = default, string scopes = "read write follow")
|
||||
{
|
||||
root ??= await host.SignUp();
|
||||
var persona = await host.Persona(root, name);
|
||||
var token = await host.MastodonToken(persona, scopes);
|
||||
return new Mastodon(persona, token, host.As(token));
|
||||
}
|
||||
|
||||
public static Task<ApiAnswer> Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path);
|
||||
|
||||
public static Task<ApiAnswer> Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form);
|
||||
|
||||
public static Task<ApiAnswer> Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form);
|
||||
|
||||
public static Task<ApiAnswer> Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form);
|
||||
|
||||
public static Task<ApiAnswer> Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form);
|
||||
|
||||
public static Task<ApiAnswer> Call(this HttpClient client, HttpMethod method, string path, params (string Key, string Value)[] form)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (form.Length > 0)
|
||||
request.Content = new FormUrlEncodedContent(form.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
return client.Exchange(request);
|
||||
}
|
||||
|
||||
public static Task<ApiAnswer> Json(this HttpClient client, HttpMethod method, string path, JsonNode body) =>
|
||||
client.Exchange(new HttpRequestMessage(method, path) { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json") });
|
||||
|
||||
public static async Task<ApiAnswer> Exchange(this HttpClient client, HttpRequestMessage request)
|
||||
{
|
||||
using (request)
|
||||
{
|
||||
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
JsonNode body = default;
|
||||
if (!string.IsNullOrWhiteSpace(text) && text.TrimStart()[0] is '{' or '[')
|
||||
body = JsonNode.Parse(text);
|
||||
return new ApiAnswer(response.StatusCode, body, text, response.Headers);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<JsonObject> Status(this Mastodon account, string text, params (string Key, string Value)[] more) =>
|
||||
(await account.Client.Post("/api/v1/statuses", more.Prepend(("status", text)).ToArray())).Ok().Object;
|
||||
|
||||
public static string Text(this JsonNode node, string property) => node?[property]?.GetValue<string>();
|
||||
|
||||
public static int Number(this JsonNode node, string property) => node![property]!.GetValue<int>();
|
||||
|
||||
public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue<bool>();
|
||||
|
||||
public static async Task<ForeignAvatar> Known(this PrivaPubHost host, RemoteActor actor) =>
|
||||
await host.Get<IRemoteActorService>().GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||
|
||||
public static string Handle(this RemoteActor actor) => $"{actor.Name}@{new Uri(actor.Id).Authority}";
|
||||
|
||||
public static string Inbox(this RemoteActor actor) => actor.Id + "/inbox";
|
||||
|
||||
public static string Origin(this RemoteActor actor) => actor.Id.Split("/users/")[0];
|
||||
|
||||
public static void WebFinger(this Peer peer, RemoteActor actor) =>
|
||||
peer.ServeText("/.well-known/webfinger", new JsonObject
|
||||
{
|
||||
["subject"] = $"acct:{actor.Handle()}",
|
||||
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Id })
|
||||
}.ToJsonString(), "application/jrd+json");
|
||||
|
||||
public static async Task Deliver(this PrivaPubHost host, RemoteActor actor, string path, JsonObject activity)
|
||||
{
|
||||
using var client = host.Client();
|
||||
using var response = await client.SendAsync(actor.SignedPost(path, activity), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
await host.RunInbox(activity["id"]!.GetValue<string>(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<string> FollowedBy(this PrivaPubHost host, Mastodon account, RemoteActor actor)
|
||||
{
|
||||
var followId = $"{actor.Origin()}/follows/{Guid.NewGuid():N}";
|
||||
await host.Deliver(actor, account.Mouth, new JsonObject
|
||||
{
|
||||
["id"] = followId,
|
||||
["type"] = "Follow",
|
||||
["actor"] = actor.Id,
|
||||
["object"] = account.Uri
|
||||
});
|
||||
return followId;
|
||||
}
|
||||
|
||||
public static JsonObject Create(this RemoteActor actor, string content, IEnumerable<string> to, IEnumerable<string> cc = default,
|
||||
Action<JsonObject> shape = default)
|
||||
{
|
||||
var noteId = $"{actor.Origin()}/notes/{Guid.NewGuid():N}";
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = noteId,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = actor.Id,
|
||||
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()),
|
||||
["cc"] = new JsonArray((cc ?? Enumerable.Empty<string>()).Select(c => (JsonNode)c).ToArray()),
|
||||
["content"] = content,
|
||||
["published"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
shape?.Invoke(note);
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = noteId + "/activity",
|
||||
["type"] = "Create",
|
||||
["actor"] = actor.Id,
|
||||
["to"] = note["to"]!.DeepClone(),
|
||||
["cc"] = note["cc"]!.DeepClone(),
|
||||
["object"] = note
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<Post> PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "<p>a remote thought</p>",
|
||||
Action<JsonObject> shape = default)
|
||||
{
|
||||
var create = actor.Create(content, new[] { Public }, new[] { mentioned.Uri }, note =>
|
||||
{
|
||||
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName });
|
||||
shape?.Invoke(note);
|
||||
});
|
||||
await host.Deliver(actor, mentioned.Mouth, create);
|
||||
var noteId = create["object"]!["id"]!.GetValue<string>();
|
||||
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<List<JsonObject>> Delivered(this RemoteActor actor, DateTime since) =>
|
||||
await Jobs.Deliveries(actor.Inbox(), since, TestContext.Current.CancellationToken);
|
||||
|
||||
public static string Type(this JsonObject activity) => activity["type"]!.GetValue<string>();
|
||||
|
||||
public static byte[] JpegWithMetadata(int width, int height)
|
||||
{
|
||||
using var image = (NetVips.Image.Black(width, height, bands: 3) + new double[] { 30, 140, 200 }).Cast(Enums.BandFormat.Uchar);
|
||||
using var tagged = image.Mutate(m =>
|
||||
{
|
||||
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
|
||||
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
|
||||
m.Set(GValue.BlobType, "xmp-data", Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
||||
});
|
||||
return tagged.WriteToBuffer(".jpg");
|
||||
}
|
||||
|
||||
public static void AssertNoMetadata(byte[] image)
|
||||
{
|
||||
using var loaded = NetVips.Image.NewFromBuffer(image);
|
||||
var fields = loaded.GetFields();
|
||||
Assert.DoesNotContain("exif-data", fields);
|
||||
Assert.DoesNotContain("xmp-data", fields);
|
||||
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static MultipartFormDataContent Multipart(params (string Name, byte[] Bytes, string ContentType, string FileName)[] files)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var part = new ByteArrayContent(file.Bytes);
|
||||
part.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
|
||||
content.Add(part, file.Name, file.FileName);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
public static async Task<ApiAnswer> ActivityPub(this PrivaPubHost host, string path)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
request.Headers.Accept.ParseAdd("application/activity+json");
|
||||
return await host.Client().Exchange(request);
|
||||
}
|
||||
|
||||
public static async Task<string> Located(this PrivaPubHost host, Mastodon account, string text)
|
||||
{
|
||||
using var client = host.As(account.Persona.Root.Jwt);
|
||||
var response = await client.PostAsJsonAsync("/clientapi/post/insert",
|
||||
new { avatarId = account.Id, text, latitude = 45.07, longitude = 7.68, rangeKm = 5 }, TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
return JsonNode.Parse(body)!["id"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user