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:
1 parent
15cd034b29
commit
4fa53f63bd
7 files changed
+385
-24
No files matched your search
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Moderation;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Statistics;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Statistics
|
||||
{
|
||||
public class SignatureProblemCodeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("unsupported signature algorithm 'ed25519'", "algorithm-unsupported")]
|
||||
[InlineData("(request-target) is not signed", "headers-unsigned")]
|
||||
[InlineData("host is not signed", "headers-unsigned")]
|
||||
[InlineData("the digest is not signed", "headers-unsigned")]
|
||||
[InlineData("neither date nor (created) is signed", "headers-unsigned")]
|
||||
[InlineData("the digest does not match the body", "digest-mismatch")]
|
||||
[InlineData("unreadable Date header", "header-unreadable")]
|
||||
[InlineData("unreadable (created)", "header-unreadable")]
|
||||
[InlineData("unreadable (expires)", "header-unreadable")]
|
||||
[InlineData("the Date header is outside the allowed window", "date-skew")]
|
||||
[InlineData("(created) is outside the allowed window", "date-skew")]
|
||||
[InlineData("the signature has expired", "expired")]
|
||||
[InlineData("something new", "signature-problem")]
|
||||
public void Every_signature_problem_has_a_reason_code(string problem, string code) =>
|
||||
Assert.Equal(code, HttpSignatures.ProblemCode(problem));
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class InboxAnswerTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
JsonObject Create(RemoteActor sender, string to)
|
||||
{
|
||||
var origin = new Uri(sender.Id).GetLeftPart(UriPartial.Authority);
|
||||
var id = $"{origin}/notes/{Guid.NewGuid():N}";
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = id + "/activity",
|
||||
["type"] = "Create",
|
||||
["actor"] = sender.Id,
|
||||
["to"] = new JsonArray(to),
|
||||
["object"] = new JsonObject { ["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(to), ["content"] = "hi" }
|
||||
};
|
||||
}
|
||||
|
||||
(InboxResult Result, InteractionEvent Event, string ActorUri, bool Claimed) Last(InboxResult result)
|
||||
{
|
||||
var recorded = _harness.Ledger.Events.Last(e => e.Event.Channel == "recv");
|
||||
return (result, recorded.Event, recorded.ActorUri, recorded.HostClaimed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_verified_delivery_is_queued_once_and_then_a_duplicate()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var bob = new RemoteActor(_harness.Peer, "bob");
|
||||
var activity = Create(bob, alice.Uri);
|
||||
|
||||
var first = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None));
|
||||
var second = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None));
|
||||
|
||||
Assert.Equal(202, first.Event.Status);
|
||||
Assert.Equal("queued", first.Event.Reason);
|
||||
Assert.Equal("queued", first.Event.Outcome);
|
||||
Assert.Equal("127.0.0.1", first.Event.Host);
|
||||
Assert.Equal("Create", first.Event.Activity);
|
||||
Assert.Equal("Note", first.Event.Object);
|
||||
Assert.Equal("shared", first.Event.Inbox);
|
||||
Assert.Equal("cavage:rsa-sha256", first.Event.Signature);
|
||||
Assert.True(first.Event.Bytes > 0);
|
||||
Assert.Equal(bob.Id, first.ActorUri);
|
||||
Assert.False(first.Claimed);
|
||||
Assert.Equal("duplicate", second.Event.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusals_carry_their_reason_and_an_unverified_host_is_only_claimed()
|
||||
{
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
||||
|
||||
var unsigned = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
|
||||
unsigned.Headers.Remove("Signature");
|
||||
var noSignature = Last(await _harness.Receiver.Receive(unsigned, default, CancellationToken.None));
|
||||
|
||||
var tampered = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
|
||||
tampered.Headers["Digest"] = "SHA-256=AAAA";
|
||||
var badDigest = Last(await _harness.Receiver.Receive(tampered, default, CancellationToken.None));
|
||||
|
||||
var forged = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri));
|
||||
forged.Headers["Signature"] = forged.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
|
||||
var badSignature = Last(await _harness.Receiver.Receive(forged, default, CancellationToken.None));
|
||||
|
||||
var junk = Last(await _harness.Deliver(mallory, "/human-centipede", new JsonArray(1, 2)));
|
||||
|
||||
Assert.Equal((401, "no-signature", "none"), (noSignature.Event.Status!.Value, noSignature.Event.Reason, noSignature.Event.Signature));
|
||||
Assert.Equal((401, "digest-mismatch"), (badDigest.Event.Status!.Value, badDigest.Event.Reason));
|
||||
Assert.Equal((401, "signature-invalid"), (badSignature.Event.Status!.Value, badSignature.Event.Reason));
|
||||
Assert.Equal((400, "not-activity"), (junk.Event.Status!.Value, junk.Event.Reason));
|
||||
foreach (var refused in new[] { noSignature, badDigest, badSignature })
|
||||
{
|
||||
Assert.Equal("refused", refused.Event.Outcome);
|
||||
Assert.True(refused.Claimed);
|
||||
Assert.Null(refused.ActorUri);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_follow_of_nobody_is_a_404_and_a_self_delete_with_an_unknown_key_is_a_silent_202()
|
||||
{
|
||||
var stranger = new RemoteActor(_harness.Peer, "stranger");
|
||||
var follow = new JsonObject
|
||||
{
|
||||
["id"] = stranger.Id + "/follows/" + Guid.NewGuid().ToString("N"),
|
||||
["type"] = "Follow",
|
||||
["actor"] = stranger.Id,
|
||||
["object"] = $"{Harness.Base}/peasants/nobody{Guid.NewGuid():N}"
|
||||
};
|
||||
var notFound = Last(await _harness.Receiver.Receive(stranger.Post(Harness.Host, "/human-centipede", follow), default, CancellationToken.None));
|
||||
|
||||
var gone = new RemoteActor(_harness.Peer, "gone");
|
||||
_harness.Peer.Answer(new Uri(gone.Id).AbsolutePath, 410);
|
||||
var delete = new JsonObject { ["id"] = gone.Id + "#delete", ["type"] = "Delete", ["actor"] = gone.Id, ["object"] = gone.Id };
|
||||
var deleted = Last(await _harness.Receiver.Receive(gone.Post(Harness.Host, "/human-centipede", delete), default, CancellationToken.None));
|
||||
|
||||
Assert.Equal((404, "unknown-recipient"), (notFound.Event.Status!.Value, notFound.Event.Reason));
|
||||
Assert.Equal((202, "self-delete-unknown-key"), (deleted.Event.Status!.Value, deleted.Event.Reason));
|
||||
Assert.True(deleted.Claimed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspended_server_is_recorded_under_its_own_name()
|
||||
{
|
||||
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
||||
blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } });
|
||||
var ledger = new MemoryLedger();
|
||||
var receiver = new InboxReceiver(_harness.Local, _harness.Remote, _harness.Queue, blocks, NullLogger<InboxReceiver>.Instance, ledger);
|
||||
var (_, alice) = await _harness.Persona("alice");
|
||||
var spammer = new RemoteActor(_harness.Peer, "spammer", _harness.Peer.B);
|
||||
|
||||
var result = await receiver.Receive(spammer.Post(Harness.Host, "/human-centipede", Create(spammer, alice.Uri)), default, CancellationToken.None);
|
||||
|
||||
Assert.Equal(202, result.StatusCode);
|
||||
var (recorded, actorUri, claimed) = Assert.Single(ledger.Events);
|
||||
Assert.Equal(("localhost", "suspended", "queued"), (recorded.Host, recorded.Reason, recorded.Outcome));
|
||||
Assert.False(claimed);
|
||||
Assert.Null(actorUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user