Files
SocialPub/PrivaPub.Tests/Support/Host/MastodonHelpers.cs
T
thepraandClaude Opus 5.5 fc5bb9511f 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
2026-10-03 11:58:31 +02:00

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";
}
}