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,208 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InboxRouteTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
HttpClient _client;
|
||||
Peer _peer;
|
||||
Persona _persona;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
_client = _host.Client();
|
||||
_peer = await Peer.Start();
|
||||
_persona = await _host.Persona(await _host.SignUp(), "inbox");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
if (_peer != default)
|
||||
await _peer.DisposeAsync();
|
||||
}
|
||||
|
||||
public static TheoryData<string> Inboxes() => new() { "personal", "personal-shared", "shared" };
|
||||
|
||||
string Route(string inbox) => inbox switch
|
||||
{
|
||||
"personal" => $"/peasants/{_persona.UserName}/mouth",
|
||||
"personal-shared" => $"/peasants/{_persona.UserName}/human-centipede",
|
||||
_ => "/human-centipede"
|
||||
};
|
||||
|
||||
JsonObject Direct(RemoteActor sender)
|
||||
{
|
||||
var noteId = $"{new Uri(sender.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}";
|
||||
var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{_persona.UserName}");
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = noteId + "/activity",
|
||||
["type"] = "Create",
|
||||
["actor"] = sender.Id,
|
||||
["to"] = to.DeepClone(),
|
||||
["object"] = new JsonObject
|
||||
{
|
||||
["id"] = noteId,
|
||||
["type"] = "Note",
|
||||
["attributedTo"] = sender.Id,
|
||||
["to"] = to.DeepClone(),
|
||||
["content"] = "<p>knock knock</p>",
|
||||
["published"] = DateTime.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
|
||||
|
||||
async Task<bool> Queued(JsonObject activity) =>
|
||||
await DB.Default.Find<Job>().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
async Task<HttpResponseMessage> Send(HttpRequestMessage request) => await _client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Inboxes))]
|
||||
public async Task A_signed_delivery_is_accepted_and_queued(string inbox)
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "sender");
|
||||
var activity = Direct(sender);
|
||||
|
||||
var response = await Send(sender.SignedPost(Route(inbox), activity));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal(1, await _host.RunInbox(Id(activity), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Inboxes))]
|
||||
public async Task Junk_is_400_and_an_unsigned_activity_401(string inbox)
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "unsigned");
|
||||
foreach (var junk in new[] { "not json", "[1,2,3]", "\"Create\"", "{}", "{\"type\":\"Create\"}" })
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) { Content = new StringContent(junk, Encoding.UTF8, "application/activity+json") };
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await Send(request)).StatusCode);
|
||||
}
|
||||
var activity = Direct(sender);
|
||||
using var unsigned = new HttpRequestMessage(HttpMethod.Post, Route(inbox))
|
||||
{
|
||||
Content = new StringContent(activity.ToJsonString(), Encoding.UTF8, "application/activity+json")
|
||||
};
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(unsigned)).StatusCode);
|
||||
Assert.False(await Queued(activity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_persona_has_no_mouth()
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "lost");
|
||||
var activity = Direct(sender);
|
||||
|
||||
var response = await Send(sender.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..28] + "/mouth", activity));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.False(await Queued(activity));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Inboxes))]
|
||||
public async Task A_tampered_stale_or_misdirected_signature_is_401(string inbox)
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "forger");
|
||||
var path = Route(inbox);
|
||||
|
||||
var tampered = Direct(sender);
|
||||
var badDigest = sender.SignedPost(path, tampered);
|
||||
badDigest.Headers.Remove("Digest");
|
||||
badDigest.Headers.TryAddWithoutValidation("Digest", "SHA-256=" + Convert.ToBase64String(new byte[32]));
|
||||
var stale = Direct(sender);
|
||||
var twoHoursAgo = DateTimeOffset.UtcNow.AddHours(-2).ToString("r", CultureInfo.InvariantCulture);
|
||||
var elsewhere = Direct(sender);
|
||||
var otherBody = Direct(sender);
|
||||
var swapped = sender.SignedPost(path, Direct(sender));
|
||||
swapped.Content = new StringContent(otherBody.ToJsonString(), Encoding.UTF8, "application/activity+json");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(badDigest)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, stale, date: twoHoursAgo))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, elsewhere, host: "elsewhere.example"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(swapped)).StatusCode);
|
||||
foreach (var activity in new[] { tampered, stale, elsewhere, otherBody })
|
||||
Assert.False(await Queued(activity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ld_json_with_the_activitystreams_profile_is_accepted()
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "ldjson");
|
||||
var activity = Direct(sender);
|
||||
var request = sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity);
|
||||
request.Content!.Headers.Remove("Content-Type");
|
||||
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");
|
||||
|
||||
var response = await Send(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.True(await Queued(activity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_signing_key_asks_the_sender_to_retry()
|
||||
{
|
||||
var sender = new RemoteActor(_peer, "flaky");
|
||||
_peer.Answer(new Uri(sender.Id).AbsolutePath, 503);
|
||||
var activity = Direct(sender);
|
||||
|
||||
var response = await Send(sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity));
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.True(response.Headers.RetryAfter?.Delta is { TotalSeconds: > 0 }, "no Retry-After");
|
||||
Assert.False(await Queued(activity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsigned_posts_from_one_address_are_limited()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
using var client = _host.Client();
|
||||
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
|
||||
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"fd7e:{Random.Shared.Next(0x10000):x}:{Random.Shared.Next(0x10000):x}::1");
|
||||
var body = Direct(new RemoteActor(_peer, "flood")).ToJsonString();
|
||||
async Task<HttpStatusCode> Post()
|
||||
{
|
||||
using var content = new StringContent(body, Encoding.UTF8, "application/activity+json");
|
||||
using var response = await client.PostAsync("/human-centipede", content, token);
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
var first = new List<HttpStatusCode>();
|
||||
for (var i = 0; i < 300; i++)
|
||||
first.Add(await Post());
|
||||
var limited = await Post();
|
||||
// The bucket refills 50 every ten seconds: a slow machine may have earned a few more before the limit bites.
|
||||
for (var refilled = (int)((clock.Elapsed.TotalSeconds + 1) / 10) * 50; limited != HttpStatusCode.TooManyRequests && refilled > 0; refilled--)
|
||||
limited = await Post();
|
||||
|
||||
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, first);
|
||||
Assert.All(first, status => Assert.Equal(HttpStatusCode.Unauthorized, status));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, limited);
|
||||
var polite = new RemoteActor(_peer, "polite");
|
||||
using var signed = await client.SendAsync(polite.SignedPost("/human-centipede", Direct(polite)), token);
|
||||
Assert.Equal(HttpStatusCode.Accepted, signed.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user