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(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.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.Instance); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } async Task 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"] = "

psst

", ["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 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() : default); var job = await DB.Default.Find().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().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId); var bobConversation = await DB.Default.Find().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().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().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().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); } } }