M3: what each handler did with an activity

Arrival carries a verdict that handlers set with one line before an existing return
(Arrival.Drop, Reject, Accept, About), so no handler signature changes. InboxProcessor
records it as an 'in' event, with the time taken, the wait since the inbox accepted it, the
attempt, the audience (from the post's visibility, or else the activity's addressing), the
local actor kind, the object's age for updates, deletes and reactions, and the FEP features
of a delivered object. It also records types nothing handles (unknown-type), actors that
cannot be loaded (deferred) and handler failures.

Drop reasons: fetch-failed, unparseable, misattributed, duplicate, deleted, not-addressed,
not-followed, not-public, not-visible, not-deleted, unknown-object, unknown-recipient,
cross-origin, unsupported. Accepted sub-reasons: stored, poll-vote, edit, refresh,
actor-refresh, actor-delete, removed, tombstone-only, auto-accepted, pending,
follow-answer, quote-answer, quote-granted, undone, reaction, reported. Rejected: blocked,
ignored, quote-refused.

A circle's traffic is private and kindless, and a stranger posting into one is just
not-addressed, so the event store cannot reveal a circle.

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 11:04:42 +02:00
1 parent 4fa53f63bd
commit ac7cbd136b
20 files changed
+544 -26

No files matched your search

@@ -0,0 +1,211 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Statistics;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Statistics
{
public class ArrivalVerdictTests
{
[Fact]
public void The_first_decision_wins_and_a_circle_has_no_kind()
{
var verdict = new ArrivalVerdict();
verdict.Drop("not-addressed");
verdict.Accept("stored");
verdict.About("Note", PostVisibility.Circle, DateTime.UtcNow.AddMinutes(-2), new LocalActor { Kind = LocalActorKind.Group, IsCircle = true });
Assert.Equal(("dropped", "not-addressed"), (verdict.Outcome, verdict.Reason));
Assert.Equal("private", verdict.Audience);
Assert.Null(verdict.LocalKind);
Assert.InRange(verdict.AgeSeconds!.Value, 110, 130);
}
[Theory]
[InlineData(PostVisibility.Public, "public")]
[InlineData(PostVisibility.Unlisted, "unlisted")]
[InlineData(PostVisibility.FollowersOnly, "private")]
[InlineData(PostVisibility.Direct, "private")]
[InlineData(PostVisibility.Circle, "private")]
[InlineData(PostVisibility.LocalGeo, "private")]
public void Every_visibility_but_public_and_unlisted_is_private(PostVisibility visibility, string audience) =>
Assert.Equal(audience, ArrivalVerdict.AudienceOf(visibility));
[Fact]
public void Outside_an_arrival_nothing_happens()
{
Arrival.Current = default;
Arrival.Drop("x");
Arrival.About("Note");
}
}
[Trait("Category", "Integration")]
public sealed class HandlerVerdictTests : 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();
}
InteractionEvent Processed(string activityType) =>
_harness.Ledger.Of("in").Last(e => e.Activity == activityType);
JsonObject Create(RemoteActor author, IEnumerable<string> to, JsonObject extra = default)
{
var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority);
var note = new JsonObject
{
["id"] = $"{origin}/notes/{Guid.NewGuid():N}",
["type"] = "Note",
["attributedTo"] = author.Id,
["content"] = "<p>hello</p>",
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray())
};
foreach (var (key, value) in extra ?? new JsonObject())
note[key] = value?.DeepClone();
return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note };
}
[Fact]
public async Task A_direct_message_is_stored_private_and_without_a_local_kind()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { alice.Uri }));
var e = Processed("Create");
Assert.Equal(("accepted", "stored", "private"), (e.Outcome, e.Reason, e.Audience));
Assert.Equal("Note", e.Object);
Assert.Null(e.LocalKind);
Assert.Equal("127.0.0.1", e.Host);
Assert.NotNull(e.Attempt);
Assert.NotNull(e.WaitMs);
Assert.Equal(bob.Id, _harness.Ledger.Events.Last(x => x.Event == e).ActorUri);
}
[Fact]
public async Task A_public_reply_to_a_persona_names_its_kind_and_its_features()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var parent = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "first" }, TestContext.Current.CancellationToken);
await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { Addressing.Public, alice.Uri },
new JsonObject { ["inReplyTo"] = parent.Post.ObjectURI, ["interactionPolicy"] = new JsonObject(), ["quoteUrl"] = "https://elsewhere.example/1" }));
var e = Processed("Create");
Assert.Equal(("accepted", "public", "person"), (e.Outcome, e.Audience, e.LocalKind));
Assert.Contains("interaction-policy", e.Features);
Assert.Contains("legacy-quote", e.Features);
}
[Fact]
public async Task Unwanted_things_are_dropped_with_a_reason()
{
var stranger = new RemoteActor(_harness.Peer, "stranger");
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { Addressing.Public }));
var unaddressed = Processed("Create");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/likes/" + Guid.NewGuid().ToString("N"),
["type"] = "Like",
["actor"] = stranger.Id,
["object"] = $"{Harness.Base}/peasants/x/scribbles/{Guid.NewGuid():N}"
});
var like = Processed("Like");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/x/" + Guid.NewGuid().ToString("N"),
["type"] = "Bite",
["actor"] = stranger.Id,
["object"] = stranger.Id
});
var bite = Processed("Bite");
await _harness.Deliver(stranger, "/human-centipede", new JsonObject
{
["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/undo/" + Guid.NewGuid().ToString("N"),
["type"] = "Undo",
["actor"] = stranger.Id,
["object"] = new JsonObject { ["id"] = stranger.Id + "/likes/never", ["type"] = "Like", ["actor"] = stranger.Id, ["object"] = "https://nowhere.example/1" }
});
var undo = Processed("Undo");
Assert.Equal(("dropped", "not-addressed", "public"), (unaddressed.Outcome, unaddressed.Reason, unaddressed.Audience));
Assert.Equal(("dropped", "unknown-object"), (like.Outcome, like.Reason));
Assert.Equal(("dropped", "unknown-type"), (bite.Outcome, bite.Reason));
Assert.Equal(("dropped", "unknown-object", "Like"), (undo.Outcome, undo.Reason, undo.Object));
}
[Fact]
public async Task A_stranger_posting_into_a_circle_never_reveals_the_circle()
{
var (_, owner) = await _harness.Persona("owner");
var (privateKey, publicKey) = Keys.NewKeyPair();
var entity = new GroupEntity
{
UserName = $"circle{Guid.NewGuid():N}"[..20],
Kind = GroupKind.Circle,
PostingPolicy = PostingPolicy.Followers,
PrivateKey = privateKey,
PublicKey = publicKey,
Members = new() { new GroupMember { AvatarId = owner.Id, Role = GroupRole.Owner } }
};
await DB.Default.SaveAsync(entity);
var circle = _harness.Local.FromGroup(entity);
var outsider = new RemoteActor(_harness.Peer, "outsider");
await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock }));
var e = Processed("Create");
Assert.Equal(("dropped", "not-addressed", "private"), (e.Outcome, e.Reason, e.Audience));
Assert.Null(e.LocalKind);
Assert.DoesNotContain(_harness.Ledger.Events, x => System.Text.Json.JsonSerializer.Serialize(x.Event).Contains("circle", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task A_follow_is_accepted_and_its_undo_is_recorded()
{
var (_, alice) = await _harness.Persona("alice");
var bob = new RemoteActor(_harness.Peer, "bob");
var follow = new JsonObject { ["id"] = bob.Id + "/follows/" + Guid.NewGuid().ToString("N"), ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri };
await _harness.Deliver(bob, "/human-centipede", follow);
var followed = Processed("Follow");
await _harness.Deliver(bob, "/human-centipede", new JsonObject
{
["id"] = bob.Id + "/undo/" + Guid.NewGuid().ToString("N"),
["type"] = "Undo",
["actor"] = bob.Id,
["object"] = follow.DeepClone()
});
var undone = Processed("Undo");
Assert.Equal(("accepted", "auto-accepted", "none"), (followed.Outcome, followed.Reason, followed.Audience));
Assert.Equal(("accepted", "undone", "Follow"), (undone.Outcome, undone.Reason, undone.Object));
}
}
}
@@ -33,7 +33,7 @@ namespace PrivaPub.Tests.Statistics
Audience = "everyone",
Inbox = "front-door",
Signature = "cavage:rsa-sha256",
Features = new() { "fep-044f", "Bad Feature", "fep-044f" }
Features = new() { "fep-044f", "Bad Feature", "fep-044f", "source:text/markdown" }
});
Assert.Equal("mastodon.social", e.Host);
@@ -45,7 +45,7 @@ namespace PrivaPub.Tests.Statistics
Assert.Null(e.Audience);
Assert.Null(e.Inbox);
Assert.Equal("cavage:rsa-sha256", e.Signature);
Assert.Equal(new[] { "fep-044f", "other" }, e.Features);
Assert.Equal(new[] { "fep-044f", "other", "source:text/markdown" }, e.Features);
Assert.Equal("-", Interactions.Host("evil host/with path"));
Assert.Equal("-", Interactions.Host(default));
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x"));