- A fetched ObjectRecord no longer wears the signature, key, signed headers and @context of the activity that caused the fetch, and its ReceivedAt is the fetch's own time. Its activity fields now name the trigger. Provenance answers signature null and fetchedBy "instance-actor". Migration _008 clears the old fetched records. - ObjectRecords store their ObjectFeatures extensions and context namespaces (migration _009 fills older records in batches), so statistics can count them. Provenance reads the stored lists. - ForeignAvatar.Features records what an actor document uses (ActorFeatures): featured, shared inbox, FEP-521a, FEP-8b32, identity proofs, Misskey fields, indexable, undiscoverable, locked, key size, and more. - RemoteEdits.Apply and RemoteDeletes.Remove are shared by the Update, Delete and Announce handlers. A community-wrapped Update is now an edit only when newer, refreshes polls and media otherwise, revises the ObjectRecord and re-resolves quotes. A community-wrapped Delete now tombstones the object, removes its ObjectRecord, reblogs and timeline rows, and lowers the reply count. Any deleted quoting post lowers the quoted post's quote count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
255 lines
9.1 KiB
C#
255 lines
9.1 KiB
C#
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<AppConfiguration>(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<AppConfiguration>(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));
|
|
}
|
|
}
|