using Microsoft.Extensions.Caching.Memory; using PrivaPub.Federation.Actors; using PrivaPub.Models; using PrivaPub.StaticServices; using PrivaPub.Tests.Support; using System.Text.Json; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Federation { [Trait("Category", "Integration")] public sealed class RemoteActorServiceTests : IAsyncLifetime { Peer _peer; RemoteActorService _service; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _peer = await Peer.Start(); var cache = new MemoryCache(new MemoryCacheOptions()); var local = new LocalActorService(new DbEntities(), new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })); _service = new RemoteActorService(Peer.Http(cache), local, cache, new DbEntities()); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } [Fact] public async Task A_stored_actor_remembers_the_extensions_its_document_uses() { var bob = new RemoteActor(_peer, "bob"); var stored = await _service.GetActor(bob.Id, refresh: true, TestContext.Current.CancellationToken); Assert.Contains("key:rsa-2048", stored.Features); } static string Actor(string id, string keyId = default, string owner = default, string pem = default, string type = "Person") => new JsonObject { ["id"] = id, ["type"] = type, ["preferredUsername"] = id[(id.LastIndexOf('/') + 1)..], ["inbox"] = id + "/inbox", ["publicKey"] = new JsonObject { ["id"] = keyId ?? id + "#main-key", ["owner"] = owner ?? id, ["publicKeyPem"] = pem ?? Keys.NewKeyPair().PublicKeyPem } }.ToJsonString(); string Unique(string name) => $"/users/{name}{Guid.NewGuid():N}"; [Fact] public async Task Accepts_an_actor_whose_document_names_its_own_address() { var path = Unique("alice"); _peer.Serve(path, Actor("{A}" + path)); var actor = await _service.GetActorByKeyId($"{_peer.A}{path}#main-key", refresh: false, TestContext.Current.CancellationToken); Assert.NotNull(actor); Assert.Equal(_peer.A + path, actor.ActorURI); Assert.Equal($"{_peer.A}{path}#main-key", actor.PublicKeyId); } [Fact] public async Task Refuses_a_document_that_claims_another_origin() { var victim = Unique("bob"); var mallory = Unique("mallory"); _peer.Serve(mallory, Actor("{B}" + victim)); var actor = await _service.GetActor(_peer.A + mallory, refresh: false, TestContext.Current.CancellationToken); var byKey = await _service.GetActorByKeyId($"{_peer.B}{victim}#main-key", refresh: false, TestContext.Current.CancellationToken); Assert.Null(actor); Assert.Null(byKey); } [Fact] public async Task Refuses_a_key_owned_by_someone_else() { var eve = Unique("eve"); _peer.Serve(eve, Actor("{A}" + eve, owner: "{A}/users/alice")); Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{eve}#main-key", refresh: false, TestContext.Current.CancellationToken)); } [Fact] public async Task Refuses_a_key_document_whose_owner_is_on_another_origin() { var alice = Unique("alice"); var key = $"/keys/{Guid.NewGuid():N}"; _peer.Serve(alice, Actor("{B}" + alice)); _peer.Serve(key, new JsonObject { ["id"] = "{A}" + key, ["owner"] = "{B}" + alice, ["publicKeyPem"] = Keys.NewKeyPair().PublicKeyPem }.ToJsonString()); Assert.Null(await _service.GetActorByKeyId(_peer.A + key, refresh: false, TestContext.Current.CancellationToken)); } [Fact] public async Task Refuses_a_key_the_actor_does_not_list() { var alice = Unique("alice"); _peer.Serve(alice, Actor("{A}" + alice)); Assert.Null(await _service.GetActorByKeyId($"{_peer.A}{alice}#other-key", refresh: false, TestContext.Current.CancellationToken)); } [Fact] public async Task Follows_a_same_origin_alias_to_the_actor_it_names() { var alice = Unique("alice"); var alias = $"/@alias{Guid.NewGuid():N}"; _peer.Serve(alice, Actor("{A}" + alice)); _peer.Serve(alias, Actor("{A}" + alice)); var actor = await _service.GetActor(_peer.A + alias, refresh: false, TestContext.Current.CancellationToken); Assert.NotNull(actor); Assert.Equal(_peer.A + alice, actor.ActorURI); } [Fact] public async Task Resolves_a_gotosocial_style_key_address() { var gts = Unique("gts"); var keyPath = gts + "/main-key"; var pem = Keys.NewKeyPair().PublicKeyPem; _peer.Serve(gts, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); _peer.Serve(keyPath, Actor("{A}" + gts, keyId: "{A}" + keyPath, pem: pem)); var actor = await _service.GetActorByKeyId(_peer.A + keyPath, refresh: false, TestContext.Current.CancellationToken); Assert.NotNull(actor); Assert.Equal(_peer.A + gts, actor.ActorURI); Assert.Equal(pem, actor.PublicKey); } [Fact] public async Task A_rotated_key_replaces_the_stored_one_but_only_once_per_interval() { var alice = Unique("alice"); var keyId = $"{_peer.A}{alice}#main-key"; var first = Keys.NewKeyPair().PublicKeyPem; var second = Keys.NewKeyPair().PublicKeyPem; var third = Keys.NewKeyPair().PublicKeyPem; _peer.Serve(alice, Actor("{A}" + alice, pem: first)); var token = TestContext.Current.CancellationToken; Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: false, token)).PublicKey); _peer.Serve(alice, Actor("{A}" + alice, pem: second)); Assert.Equal(first, (await _service.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); var fresh = new RemoteActorService(Peer.Http(), new LocalActorService(new DbEntities(), new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" })), new MemoryCache(new MemoryCacheOptions()), new DbEntities()); Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); _peer.Serve(alice, Actor("{A}" + alice, pem: third)); Assert.Equal(second, (await fresh.GetActorByKeyId(keyId, refresh: true, token)).PublicKey); Assert.Single(await new DbEntities().ForeignAvatars.Match(a => a.ActorURI == _peer.A + alice).ExecuteAsync(token)); } [Fact] public async Task Signs_every_fetch() { var alice = Unique("alice"); _peer.Serve(alice, Actor("{A}" + alice)); await _service.GetActor(_peer.A + alice, refresh: false, TestContext.Current.CancellationToken); var request = Assert.Single(_peer.Requests, r => r.Path == alice); Assert.Contains("keyId=\"https://privapub.test/peasants/privapub#main-key\"", request.Signature); } } public class ActorDocumentTests { [Fact] public void Reads_a_mastodon_actor() { var document = ActorDocument.Parse(JsonDocument.Parse(""" { "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], "id": "https://mastodon.example/users/alice", "type": "Person", "preferredUsername": "alice", "name": "Alice", "inbox": "https://mastodon.example/users/alice/inbox", "outbox": "https://mastodon.example/users/alice/outbox", "url": "https://mastodon.example/@alice", "discoverable": false, "endpoints": { "sharedInbox": "https://mastodon.example/inbox" }, "icon": { "type": "Image", "url": "https://files.mastodon.example/a.png" }, "publicKey": { "id": "https://mastodon.example/users/alice#main-key", "owner": "https://mastodon.example/users/alice", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n" } } """).RootElement); Assert.Equal("alice", document.PreferredUsername); Assert.Equal("https://mastodon.example/inbox", document.SharedInbox); Assert.Equal("https://files.mastodon.example/a.png", document.Icon); Assert.False(document.Discoverable); Assert.NotNull(document.Key("https://mastodon.example/users/alice#main-key")); } [Fact] public void Keeps_only_keys_owned_by_the_actor_on_its_origin() { var document = ActorDocument.Parse(JsonDocument.Parse(""" { "id": "https://a.example/users/alice", "type": "Person", "publicKey": [ { "id": "https://a.example/users/alice#main-key", "owner": "https://a.example/users/alice", "publicKeyPem": "x" }, { "id": "https://b.example/keys/1", "owner": "https://a.example/users/alice", "publicKeyPem": "y" }, { "id": "https://a.example/users/alice#other", "owner": "https://a.example/users/bob", "publicKeyPem": "z" }, { "id": "https://a.example/users/alice#unowned", "publicKeyPem": "w" } ] } """).RootElement); Assert.Equal("https://a.example/users/alice#main-key", Assert.Single(document.Keys).Id); } [Fact] public void Refuses_an_inbox_on_another_origin() { var document = ActorDocument.Parse(JsonDocument.Parse(""" { "id": "https://a.example/users/alice", "type": "Person", "inbox": "https://b.example/inbox" } """).RootElement); Assert.Null(document.Inbox); } [Theory] [InlineData("Note")] [InlineData("Tombstone")] [InlineData("Collection")] public void Refuses_non_actor_types(string type) => Assert.Null(ActorDocument.Parse(JsonDocument.Parse($$"""{ "id": "https://a.example/x", "type": "{{type}}" }""").RootElement)); } }