InboxService is split the way the roadmap lays out Federation/Inbox:
- InboxReceiver reads and verifies the request exactly as before, runs
the checks that need no fetch (the activity id's origin, a Follow of a
missing or local-only actor, an Undo of someone else's activity, an
embedded object attributed to someone else), queues a ProcessInbox job
and answers 202. The job's dedupe key is the activity id, so a peer that
delivers the same activity twice is processed once.
- InboxProcessor (two at a time, eight attempts) loads the verified actor
and hands the activity to the handler for its type.
- Handlers/{Follow,Undo,Create,Delete,Update}Handler are the old methods,
unchanged except that they no longer produce status codes; the JSON
helpers live in Objects/ActivityJson and the group membership helpers in
Inbox/ForeignMembers.
A slow fetch of an object or a remote actor now delays the job, not the
sender's HTTP request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
230 lines
8.6 KiB
C#
230 lines
8.6 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Inbox.Handlers;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InboxScenarioTests : IAsyncLifetime
|
|
{
|
|
const string Host = "privapub.test";
|
|
const string Base = "https://" + Host;
|
|
|
|
Peer _peer;
|
|
LocalActorService _local;
|
|
InboxReceiver _receiver;
|
|
InboxProcessor _processor;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_peer = await Peer.Start();
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
|
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
|
|
var queue = new JobQueue();
|
|
var delivery = new DeliveryService(new DbEntities(), queue);
|
|
var db = new DbEntities();
|
|
_receiver = new InboxReceiver(_local, remote, queue, NullLogger<InboxReceiver>.Instance);
|
|
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
|
{
|
|
new FollowHandler(db, _local, remote, delivery),
|
|
new UndoHandler(db, _local, remote, delivery),
|
|
new CreateHandler(db, _local, remote, delivery),
|
|
new DeleteHandler(db, _local, remote, delivery),
|
|
new UpdateHandler(db, _local, remote, delivery)
|
|
}, NullLogger<InboxProcessor>.Instance);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_peer != default)
|
|
await _peer.DisposeAsync();
|
|
}
|
|
|
|
async Task<LocalActor> LocalAvatar(string name)
|
|
{
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
|
|
return _local.FromAvatar(avatar);
|
|
}
|
|
|
|
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
|
|
{
|
|
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = id,
|
|
["type"] = "Note",
|
|
["attributedTo"] = attributedTo ?? author.Id,
|
|
["content"] = "<p>psst</p>",
|
|
["to"] = new JsonArray(to),
|
|
["cc"] = new JsonArray()
|
|
};
|
|
if (context != default)
|
|
note["context"] = context;
|
|
return new JsonObject
|
|
{
|
|
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
|
["type"] = "Create",
|
|
["actor"] = author.Id,
|
|
["to"] = new JsonArray(to),
|
|
["object"] = note
|
|
};
|
|
}
|
|
|
|
async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var result = await _receiver.Receive(sender.Post(Host, path, activity), default, token);
|
|
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(token);
|
|
if (job != default)
|
|
Assert.Equal(JobResult.Done, (await _processor.Handle(job, token)).Result);
|
|
return result;
|
|
}
|
|
|
|
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
|
|
|
|
[Fact]
|
|
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
|
|
|
|
var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context));
|
|
var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context));
|
|
|
|
Assert.Equal(202, first.StatusCode);
|
|
Assert.Equal(202, injected.StatusCode);
|
|
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
|
var malloryDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
|
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId);
|
|
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, token);
|
|
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
|
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
|
|
|
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, dms.Count);
|
|
Assert.Single(dms.Select(d => d.GroupId).Distinct());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_activity_id_on_another_origin_is_refused()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var create = DirectCreate(mallory, alice.Uri);
|
|
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create);
|
|
|
|
Assert.Equal(400, result.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_note_put_in_someone_elses_mouth_is_refused()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var victim = new RemoteActor(_peer, "victim");
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
|
|
|
Assert.Equal(400, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
|
|
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_bad_signature_is_a_401()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
|
|
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
|
|
|
|
Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Junk_is_a_400_never_a_500()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
|
|
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
|
|
Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circle_is_not_a_federated_actor()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(circle, token);
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var actor = _local.FromGroup(circle);
|
|
var follow = new JsonObject
|
|
{
|
|
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
|
|
["type"] = "Follow",
|
|
["actor"] = bob.Id,
|
|
["object"] = actor.Uri
|
|
};
|
|
|
|
Assert.False(actor.IsFederated);
|
|
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
|
}
|
|
}
|
|
}
|