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:
1 parent
c5e4934ba6
commit
2cfea7b60c
8 files changed
+1295
-3
No files matched your search
@@ -0,0 +1,261 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user