T7: the federation surface over HTTP

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:53:23 +02:00
1 parent c5e4934ba6
commit 2cfea7b60c
8 files changed
+1295 -3

No files matched your search

@@ -0,0 +1,141 @@
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;
}
}