Files
SocialPub/PrivaPub.Tests/Http/InboxRouteTests.cs
T
thepraandClaude Opus 5.5 c8a305ae92 M8: every server we touch is described, located and snapshotted weekly
- Touches: the ledger marks a server as touched when it sends us a verified activity, when
  we exchange activities with it, or when we read its actors, keys, objects or WebFinger.
  It upserts RemoteInstance.Seen, FirstSeenAt and LastSeenAt at most hourly per server, and
  queues one DescribeInstance a week with the same dedupe key ObjectRecords uses. Suspended
  servers and pages behind link previews are never described. Migration _010 marks the
  servers already known as touched, with their dates.
- InstanceDescriber.Describe(host, crawled, allowed) reads:
  - NodeInfo 2.2/2.1/2.0, now with its published user counts, posts, comments,
    description, languages and schema version;
  - for software with a Mastodon API, /api/v2/instance falling back to v1: title,
    languages, registration mode, character limit, API version, source URL.

  It never keeps a contact as a field; the raw document is kept for the admin only. It
  locates the server from the address our connection reached (DB-IP Lite city and ASN, the
  CDN named when fronted) and writes a RemoteInstanceSnapshot per ISO week, unreachable
  weeks included. A crawled server is upserted as crawled only on insert, so it never
  downgrades a touched one, and robots.txt can deny any path.
- PublicGeo.Project is the only public form of a location: a CDN-fronted server shows its
  CDN only, a server reporting at least ten users shows its city, coordinates and network,
  any other only its country.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 12:07:57 +02:00

206 lines
7.7 KiB
C#

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;
}
// The bucket holds 300 and refills 50 every ten seconds on its own timer, so a refill can land at any moment:
// the first refusal comes after at least 300 posts and within a few refills.
var answers = new List<HttpStatusCode>();
while (answers.Count < 600 && (answers.Count == 0 || answers[^1] != HttpStatusCode.TooManyRequests))
answers.Add(await Post());
Assert.Equal(HttpStatusCode.TooManyRequests, answers[^1]);
Assert.True(answers.Count > 300, $"limited after only {answers.Count - 1} posts");
Assert.All(answers.SkipLast(1), status => Assert.Equal(HttpStatusCode.Unauthorized, status));
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);
}
}
}