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
233 lines
9.9 KiB
C#
233 lines
9.9 KiB
C#
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";
|
|
}
|
|
}
|