Files

232 lines
9.9 KiB
C#
Raw Permalink Normal View History

2026-10-03 11:56:39 +02:00
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";
}
}