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.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.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); } } }