Tests through the real routes of PeasantsController, WellKnownController and UsersController, on the whole server under test (34 new tests): - FederationGetTests: the actor document (activity+json, SPKI key at #main-key owned by the actor, sharedInbox, published = PublishedOn's day, no creation date, no root); ld+json; browsers sent to /@name; Vary: Accept; /users 301; the outbox's totalItems and ?page=true&max_id paging across the 20-item boundary, boosts as Announces, and no followers-only, direct, located, federated-copy or deleted post; /groupies and /stalking naming nobody; /trophies (public pins, newest first) and /tattoos; /scribbles (public and unlisted 200, browsers redirected, followers-only, direct and located 404, deleted 410 Tombstone); a circle post only for a signed member or its instance actor; a circle's /groupies, /flock and /wardens only for members; /grunts create- and announce- ids; /parrot-licences 200, revoked 410, wrong author 404; secure mode's 401 for every unsigned GET but the instance actor's. - WellKnownTests: WebFinger by acct:, @-prefixed, bare, upper-case and actor URI; other domains, unknown names, a root's login name and no resource; the instance actor, a community, a circle (answered: current behaviour); NodeInfo links, 2.0 and 2.1 naming no root, unknown versions 404; usage counting only public, unlisted, non-boost local posts (Exclusive). - InboxRouteTests: all three inboxes accept a signed delivery and refuse junk (400), unsigned (401), a bad Digest, a two-hour-old Date, a signature for another host and a swapped body (401); an unknown persona's /mouth is 404; ld+json with the ActivityStreams profile is accepted; a signer whose actor answers 503 gets 503 with Retry-After; the 301st unsigned POST from one address is 429 while a signed server from it is not. - PersonaSeparationHttpTests: with a sibling persona and its community on the same login, every GET under /api (filled with the persona's ids) plus search, lookup and relationships, and a crawl of everything federation publishes about the persona (actor, outbox pages, collections, scribbles, grunts, WebFinger, NodeInfo, /@ pages), never name the sibling, its community or the login. Fixed: - A circle's /groupies told anyone how many followers (members) it has, while its /flock and /wardens were already for members only; it now answers 404 to anyone but a signed member or a member's instance actor. - WebFinger answered 404 to a bare user@domain or @user@domain resource, which Mastodon, GoToSocial and Pleroma all accept; it now treats them as acct: (noted in docs/INTEROP.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
142 lines
5.8 KiB
C#
142 lines
5.8 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Support.Host
|
|
{
|
|
public sealed record Fetched(HttpStatusCode Status, string MediaType, string Text, HttpResponseMessage Response)
|
|
{
|
|
public JsonObject Json => JsonNode.Parse(Text)!.AsObject();
|
|
public string Location => Response.Headers.Location?.OriginalString;
|
|
}
|
|
|
|
public sealed record LocalGroup(string Id, string UserName)
|
|
{
|
|
public string Uri => $"{PrivaPubHost.Base}/peasants/{UserName}";
|
|
}
|
|
|
|
public static class FederationHelpers
|
|
{
|
|
public const string ActivityJson = "application/activity+json";
|
|
public const string LdJson = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
|
public const string Browser = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
|
|
public static string ActorUri(this Persona persona) => $"{PrivaPubHost.Base}/peasants/{persona.UserName}";
|
|
|
|
public static async Task<Fetched> Fetch(this HttpClient client, string path, string accept = ActivityJson)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
|
if (accept != default)
|
|
request.Headers.TryAddWithoutValidation("Accept", accept);
|
|
return await client.Fetch(request);
|
|
}
|
|
|
|
public static async Task<Fetched> Fetch(this HttpClient client, HttpRequestMessage request)
|
|
{
|
|
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
|
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
return new Fetched(response.StatusCode, response.Content.Headers.ContentType?.MediaType, text, response);
|
|
}
|
|
|
|
public static async Task<LocalActor> Actor(this PrivaPubHost host, Persona persona) =>
|
|
await host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
|
|
|
|
public static async Task<Post> Publish(this PrivaPubHost host, Persona persona, StatusDraft draft)
|
|
{
|
|
var outcome = await host.Get<IStatusService>().Publish(await host.Actor(persona), draft, TestContext.Current.CancellationToken);
|
|
Assert.True(outcome.Ok, outcome.Error);
|
|
return outcome.Post;
|
|
}
|
|
|
|
public static Task<Post> Publish(this PrivaPubHost host, Persona persona, string text, PostVisibility visibility = PostVisibility.Public) =>
|
|
host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Visibility = visibility });
|
|
|
|
public static Task<Post> PublishLocated(this PrivaPubHost host, Persona persona, string text) =>
|
|
host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Latitude = 45.46, Longitude = 9.19, RangeKm = 5 });
|
|
|
|
public static async Task Remove(this PrivaPubHost host, Persona persona, Post post)
|
|
{
|
|
var outcome = await host.Get<IStatusService>().Remove(await host.Actor(persona), post.ID, TestContext.Current.CancellationToken);
|
|
Assert.True(outcome.Ok, outcome.Error);
|
|
}
|
|
|
|
public static async Task<Post> Reblog(this PrivaPubHost host, Persona persona, Post original)
|
|
{
|
|
var outcome = await host.Get<IStatusService>().Reblog(await host.Actor(persona), original.ID, true, PostVisibility.Public, TestContext.Current.CancellationToken);
|
|
Assert.True(outcome.Ok, outcome.Error);
|
|
return outcome.Post;
|
|
}
|
|
|
|
public static async Task<Post> FederatedCopy(this PrivaPubHost host, Persona persona)
|
|
{
|
|
var post = new Post
|
|
{
|
|
GroupUserId = persona.Id,
|
|
AuthorAccountId = persona.Id,
|
|
IsFederatedCopy = true,
|
|
Visibility = PostVisibility.Public,
|
|
ObjectURI = $"https://remote.example/notes/{Guid.NewGuid():N}",
|
|
ActorURI = "https://remote.example/users/someone",
|
|
ContentHtml = "<p>a copy that is not ours to serve</p>"
|
|
};
|
|
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
|
|
return post;
|
|
}
|
|
|
|
public static async Task<LocalGroup> Group(this PrivaPubHost host, Persona owner, bool community, string name = default)
|
|
{
|
|
var userName = $"{name ?? (community ? "community" : "circle")}{Guid.NewGuid():N}"[..20];
|
|
using var client = host.As(owner.Root.Jwt);
|
|
var response = await client.PostAsJsonAsync("/clientapi/group/insert",
|
|
new { avatarId = owner.Id, userName, name = userName, description = "testing", isCommunity = community, postingPolicy = community ? "anyone" : default },
|
|
TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var group = (await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken))!;
|
|
return new LocalGroup(group["id"]!.GetValue<string>(), userName);
|
|
}
|
|
|
|
public static async Task AddForeignMember(this LocalGroup group, string actorUri) =>
|
|
await DB.Default.Update<GroupEntity>().MatchID(group.Id)
|
|
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = actorUri, IsForeign = true }))
|
|
.ExecuteAsync(TestContext.Current.CancellationToken);
|
|
|
|
public static IEnumerable<JsonNode> Nodes(JsonNode node)
|
|
{
|
|
if (node == default)
|
|
yield break;
|
|
yield return node;
|
|
switch (node)
|
|
{
|
|
case JsonObject obj:
|
|
foreach (var (_, value) in obj)
|
|
foreach (var inner in Nodes(value))
|
|
yield return inner;
|
|
break;
|
|
case JsonArray array:
|
|
foreach (var item in array)
|
|
foreach (var inner in Nodes(item))
|
|
yield return inner;
|
|
break;
|
|
}
|
|
}
|
|
|
|
public static IEnumerable<string> Keys(JsonNode node) =>
|
|
Nodes(node).OfType<JsonObject>().SelectMany(o => o.Select(p => p.Key));
|
|
|
|
public static IEnumerable<string> Strings(JsonNode node) =>
|
|
Nodes(node).OfType<JsonValue>().Where(v => v.TryGetValue<string>(out _)).Select(v => v.GetValue<string>());
|
|
|
|
public static string PathOf(string url) => new Uri(url).PathAndQuery;
|
|
}
|
|
}
|