M2: every inbox answer is recorded

InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json,
not-activity, missing-type-or-actor, no-signature, the signature check's own codes
(headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired,
algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable,
id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued,
duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object
type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown
/mouth and a rate-limited inbox (429, from OnRejected) are recorded too.

Until the signature verifies, the host is only claimed, so it is kept only if the server is
already known. A suspended server is recorded under its own name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 10:59:55 +02:00
1 parent 15cd034b29
commit 4fa53f63bd
7 files changed
+385 -24

No files matched your search

@@ -0,0 +1,85 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Statistics
{
[Trait("Category", "Integration")]
public sealed class LedgerOverHttpTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
JsonObject DirectNote(RemoteActor sender, Persona persona, string text)
{
var noteId = $"{_peer.A}/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"] = text }
};
}
async Task<List<BsonDocument>> StoredSince(DateTime since, CancellationToken token)
{
await _host.Get<InteractionLedger>().Flush(token);
return await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent))
.Find(Builders<BsonDocument>.Filter.Gte(nameof(InteractionEvent.At), since) & Builders<BsonDocument>.Filter.In(nameof(InteractionEvent.Host), new[] { "127.0.0.1", Interactions.Unknown }))
.ToListAsync(token);
}
[Fact]
public async Task A_delivery_and_its_refusals_are_recorded_naming_only_the_server()
{
var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var persona = await _host.Persona(await _host.SignUp(), "ledger");
var bob = new RemoteActor(_peer, "bob");
using var client = _host.Client();
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", DirectNote(bob, persona, "psst")), token)).StatusCode);
var forged = bob.SignedPost("/human-centipede", DirectNote(bob, persona, "forged"));
forged.Headers.Remove("Signature");
forged.Headers.TryAddWithoutValidation("Signature", bob.SignedPost("/elsewhere", new JsonObject()).Headers.GetValues("Signature").Single());
Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(forged, token)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.SendAsync(bob.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..20] + "/mouth", DirectNote(bob, persona, "lost")), token)).StatusCode);
var stored = await StoredSince(since, token);
var reasons = stored.Select(e => e.GetValue(nameof(InteractionEvent.Reason), BsonNull.Value).ToString()).ToList();
Assert.Contains("queued", reasons);
Assert.Contains("signature-invalid", reasons);
Assert.Contains("unknown-recipient", reasons);
var everything = string.Join("\n", stored.Select(e => e.ToJson()));
Assert.DoesNotContain(persona.UserName, everything);
Assert.DoesNotContain(persona.Id, everything);
Assert.DoesNotContain(persona.Root.Id, everything);
Assert.DoesNotContain(bob.Name, everything);
Assert.DoesNotContain("/notes/", everything);
}
}
}