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
262 lines
10 KiB
C#
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();
|
|
}
|
|
}
|