Files
SocialPub/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs
T
thepraandClaude Opus 5.5 2cfea7b60c 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
2026-10-03 11:53:23 +02:00

262 lines
10 KiB
C#

using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed partial class PersonaSeparationHttpTests : IAsyncLifetime
{
const string Base = PrivaPubHost.Base;
const int MaxDocuments = 300;
PrivaPubHost _host;
Root _root;
Persona _alpha;
Persona _bravo;
Persona _charlie;
LocalGroup _community;
string _tag;
Post _shown;
Post _poll;
List<Post> _alphaPosts;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
_host = await PrivaPubHost.Shared();
_root = await _host.SignUp("sep");
_alpha = await _host.Persona(_root, "sepalpha");
_bravo = await _host.Persona(_root, "sepbravo");
_charlie = await _host.Persona(await _host.SignUp(), "sepcharlie");
_community = await _host.Group(_bravo, community: true, "sepcommunity");
_tag = $"septag{Guid.NewGuid():N}"[..14];
await _host.Publish(_bravo, "the sibling's own words", PostVisibility.FollowersOnly);
var follows = _host.Get<IFollowService>();
Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _bravo.Id, Target = _charlie.UserName }, token)).IsValid);
_shown = await _host.Publish(_alpha, $"hello from alpha #{_tag}");
_poll = await _host.Publish(_alpha, new StatusDraft
{
Text = "which one?",
PlainText = true,
Poll = new PollDraft(new[] { "this", "that" }, 3600, false, false)
});
var charlieSays = await _host.Publish(_charlie, "worth sharing");
_alphaPosts = new List<Post>
{
_shown,
_poll,
await _host.Publish(_alpha, "quietly", PostVisibility.Unlisted),
await _host.Publish(_alpha, "for my followers", PostVisibility.FollowersOnly),
await _host.Publish(_alpha, $"@{_charlie.UserName} between us", PostVisibility.Direct),
await _host.PublishLocated(_alpha, "around here"),
await _host.Publish(_alpha, new StatusDraft { Text = "quoting charlie", PlainText = true, QuotedStatusId = charlieSays.ID }),
await _host.Reblog(_alpha, charlieSays)
};
await DB.Default.SaveAsync(new Pin { AvatarId = _alpha.Id, PostId = _shown.ID }, token);
Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _alpha.Id, Target = _charlie.UserName }, token)).IsValid);
Assert.True((await follows.Follow(_charlie.Root.Id, new FollowForm { AvatarId = _charlie.Id, Target = _alpha.UserName }, token)).IsValid);
Assert.True((await _host.Get<IStatusService>().Favourite(await _host.Actor(_charlie), _shown.ID, true, token)).Ok);
await _host.Publish(_charlie, new StatusDraft { Text = $"@{_alpha.UserName} nice", PlainText = true, InReplyTo = _shown.ID });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
string[] Forbidden() => new[] { _bravo.Id, _bravo.UserName, _root.Id, _root.UserName, _community.Id, _community.UserName };
[Fact]
public async Task Nothing_the_api_tells_a_persona_names_its_sibling_or_its_login()
{
var bearer = await _host.MastodonToken(_alpha);
using var client = _host.As(bearer);
var notifications = await client.Fetch("/api/v1/notifications", "application/json");
Assert.Equal(HttpStatusCode.OK, notifications.Status);
var notificationIds = JsonNode.Parse(notifications.Text)!.AsArray().Select(n => n!["id"]!.GetValue<string>()).ToList();
Assert.NotEmpty(notificationIds);
var paths = Routes().Select(Fill).Concat(new[]
{
$"/api/v2/search?q={_alpha.UserName}",
$"/api/v2/search?q={Uri.EscapeDataString($"@{_alpha.UserName}@{PrivaPubHost.Host}")}",
$"/api/v2/search?q={Uri.EscapeDataString(_alpha.ActorUri())}",
$"/api/v2/search?q={Uri.EscapeDataString(_shown.ObjectURI)}",
$"/api/v2/search?q=%23{_tag}",
$"/api/v1/accounts/search?q={_alpha.UserName}",
$"/api/v1/accounts/lookup?acct={_alpha.UserName}",
$"/api/v1/accounts/relationships?id[]={_alpha.Id}&id[]={_charlie.Id}",
$"/api/v1/accounts/familiar_followers?id[]={_charlie.Id}",
$"/api/v1/accounts/{_alpha.Id}/statuses?pinned=true",
$"/api/v1/accounts/{_alpha.Id}/statuses?exclude_replies=true&limit=40",
$"/api/v1/statuses?{string.Join("&", _alphaPosts.Select(p => "id[]=" + p.ID))}",
$"/api/v1/statuses/{_poll.ID}",
$"/api/v1/polls/{_poll.ID}",
$"/api/v1/notifications/{notificationIds[0]}",
"/api/v1/timelines/home?limit=40",
"/api/v1/timelines/public?local=true&limit=40"
}).Concat(_alphaPosts.Select(p => $"/api/v1/statuses/{p.ID}/context"))
.Distinct()
.ToList();
var answered = 0;
var failures = new List<string>();
foreach (var path in paths)
{
var fetched = await client.Fetch(path, "application/json");
if ((int)fetched.Status >= 500)
failures.Add($"{path} answered {(int)fetched.Status}");
if (fetched.Status == HttpStatusCode.OK)
answered++;
foreach (var secret in Forbidden())
if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase) || fetched.Response.Headers.ToString().Contains(secret, StringComparison.OrdinalIgnoreCase))
failures.Add($"{path} names {secret}: {Short(fetched.Text)}");
}
Assert.True(failures.Count == 0, string.Join("\n", failures));
Assert.True(answered >= 40, $"only {answered} of {paths.Count} answers were 200");
var me = await client.Fetch("/api/v1/accounts/verify_credentials", "application/json");
Assert.Contains(_alpha.Id, me.Text);
Assert.Contains(_shown.ID, (await client.Fetch("/api/v1/timelines/home?limit=40", "application/json")).Text);
}
[Fact]
public async Task Nothing_published_about_a_persona_names_its_sibling_or_its_login()
{
using var client = _host.Client();
var seeds = new[]
{
$"/peasants/{_alpha.UserName}",
$"/users/{_alpha.UserName}",
$"/peasants/{_alpha.UserName}/anus?page=true",
$"/peasants/{_alpha.UserName}/scribbles/{_shown.ID}",
$"/peasants/{_alpha.UserName}/grunts/create-{_shown.ID}",
$"/.well-known/webfinger?resource=acct:{_alpha.UserName}@{PrivaPubHost.Host}",
"/.well-known/nodeinfo",
$"/@{_alpha.UserName}",
$"/@{_alpha.UserName}/{_shown.ID}"
};
var queue = new Queue<string>(seeds);
var seen = new HashSet<string>(seeds);
var failures = new List<string>();
var fetchedOk = 0;
while (queue.Count > 0 && seen.Count <= MaxDocuments)
{
var path = queue.Dequeue();
var html = path.StartsWith("/@", StringComparison.Ordinal);
var fetched = await client.Fetch(path, html ? Browser : path.StartsWith("/peasants/", StringComparison.Ordinal) ? ActivityJson : "application/json");
if ((int)fetched.Status >= 500)
failures.Add($"{path} answered {(int)fetched.Status}");
if (fetched.Status == HttpStatusCode.OK)
fetchedOk++;
foreach (var secret in Forbidden())
if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase))
failures.Add($"{path} names {secret}: {Short(fetched.Text)}");
var links = Links(fetched.Text).ToList();
if (fetched.Location != default)
links.Add(fetched.Location.StartsWith('/') ? Base + fetched.Location : fetched.Location);
foreach (var link in links)
{
if (!link.StartsWith(Base + "/", StringComparison.Ordinal))
continue;
var next = PathOf(link.Split('#')[0]);
if (Followed(next) && seen.Add(next))
queue.Enqueue(next);
}
}
Assert.True(failures.Count == 0, string.Join("\n", failures));
Assert.Contains($"/peasants/{_alpha.UserName}/groupies", seen);
Assert.Contains($"/peasants/{_alpha.UserName}/trophies", seen);
Assert.Contains("/nodeinfo/2.1", seen);
Assert.True(fetchedOk >= 15, $"only {fetchedOk} of {seen.Count} documents were served");
}
bool Followed(string path) =>
path.StartsWith($"/peasants/{_alpha.UserName}", StringComparison.Ordinal)
|| path.StartsWith($"/@{_alpha.UserName}", StringComparison.Ordinal)
|| path.StartsWith("/.well-known/", StringComparison.Ordinal)
|| path.StartsWith("/nodeinfo/", StringComparison.Ordinal);
static IEnumerable<string> Links(string text)
{
JsonNode json = default;
try
{
json = JsonNode.Parse(text);
}
catch (System.Text.Json.JsonException)
{
}
if (json != default)
return Strings(json);
return Href().Matches(text).Select(m => WebUtility.HtmlDecode(m.Groups[1].Value));
}
IEnumerable<string> Routes()
{
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
foreach (var endpoint in endpoints)
{
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/');
if (template.StartsWith("/api/", StringComparison.Ordinal) && methods is { } verbs && verbs.Contains("GET"))
yield return template;
}
}
string Fill(string template)
{
var builder = new StringBuilder();
foreach (var segment in RoutePatternFactory.Parse(template).PathSegments)
{
builder.Append('/');
foreach (var part in segment.Parts)
builder.Append(part switch
{
RoutePatternLiteralPart literal => literal.Content,
RoutePatternSeparatorPart separator => separator.Content,
RoutePatternParameterPart parameter => Value(template, parameter.Name),
_ => string.Empty
});
}
return builder.ToString();
}
string Value(string template, string parameter) => parameter switch
{
"hashtag" => _tag,
"host" => PrivaPubHost.Host,
_ when template.StartsWith("/api/v1/statuses/", StringComparison.Ordinal)
|| template.Contains("/statuses/{id}", StringComparison.Ordinal) => _shown.ID,
_ when template.StartsWith("/api/v1/polls/", StringComparison.Ordinal) => _poll.ID,
_ => _alpha.Id
};
static string Short(string text) => text.Length > 400 ? text[..400] + "…" : text;
[GeneratedRegex("(?:href|src)=\"([^\"]+)\"")]
private static partial Regex Href();
}
}