using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.Models; 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; DomainBlocks _blocks; 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(); _blocks = new DomainBlocks(NullLogger.Instance); _receiver = new InboxReceiver(_local, remote, queue, _blocks, 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, _blocks), new DeleteHandler(db, _local, remote, delivery), new UpdateHandler(db, _local, remote) }, 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 && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token); var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId); var bobConversation = await DB.Default.Find().OneAsync(bobDm.ConversationId, 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 && p.Visibility == PostVisibility.Direct).ExecuteAsync(token); Assert.Equal(2, dms.Count); Assert.Single(dms.Select(d => d.ConversationId).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)); } static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc) { var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}"; var note = new JsonObject { ["id"] = id, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = "

hello

", ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray()) }; if (inReplyTo != default) note["inReplyTo"] = inReplyTo; return new JsonObject { ["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; } [Fact] public async Task A_public_reply_to_a_local_post_is_kept_and_counted() { var token = TestContext.Current.CancellationToken; var alice = await LocalAvatar("alice"); var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" }; parent.ID = (string)parent.GenerateNewID(); parent.ObjectURI = alice.PostUri(parent.ID); await DB.Default.SaveAsync(parent, token); var bob = new RemoteActor(_peer, "bob"); await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI)); var reply = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); Assert.Equal(PostVisibility.Public, reply.Visibility); Assert.Equal(parent.ID, reply.AnsweringToPostId); Assert.Equal(alice.Id, reply.InReplyToAccountId); Assert.Equal(1, (await DB.Default.Find().OneAsync(parent.ID, token)).RepliesCount); } [Fact] public async Task A_public_post_nobody_here_asked_for_is_dropped() { var token = TestContext.Current.CancellationToken; var bob = new RemoteActor(_peer, "bob"); await Deliver(bob, "/human-centipede", PublicCreate(bob)); Assert.False(await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); } [Fact] public async Task A_mention_is_kept_and_linked_to_the_local_persona() { var token = TestContext.Current.CancellationToken; var alice = await LocalAvatar("alice"); var bob = new RemoteActor(_peer, "bob"); var create = PublicCreate(bob, default, alice.Uri); create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle }); await Deliver(bob, "/human-centipede", create); var post = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); var mention = Assert.Single(post.Mentions); Assert.True(mention.IsLocal); Assert.Equal(alice.Id, mention.AccountId); } [Fact] public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched() { var token = TestContext.Current.CancellationToken; var alice = await LocalAvatar("alice"); var bob = new RemoteActor(_peer, "bob", _peer.B); await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token); try { await _blocks.Reload(token); var before = _peer.Requests.Count; var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); Assert.Equal(202, result.StatusCode); Assert.Equal(before, _peer.Requests.Count); Assert.False(await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); } finally { await DB.Default.DeleteAsync(b => b.Domain == "localhost"); await _blocks.Reload(token); } } [Fact] public void A_block_covers_subdomains_but_not_lookalikes() { var blocks = new DomainBlocks(NullLogger.Instance); typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(blocks, new Dictionary { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } }); typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(blocks, DateTime.UtcNow); Assert.NotNull(blocks.Find("evil.example")); Assert.NotNull(blocks.Find("A.Evil.Example.")); Assert.Null(blocks.Find("notevil.example")); Assert.False(blocks.IsSuspended("evil.example")); } [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); } } }