using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Outbox; 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; InboxService _inbox; 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()); _inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities()), 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 }; } 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 _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token); var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token); 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 _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token); 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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token); 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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token); 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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token); 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 _inbox.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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).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 _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode); } } }