M6: provenance fixes, stored features, community edits that are real edits
- 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
This commit is contained in:
1 parent
d37999970c
commit
90a7e38ce9
19 files changed
+505
-109
No files matched your search
@@ -0,0 +1,50 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class ActorFeaturesTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_rich_actor_reports_the_extensions_it_uses()
|
||||
{
|
||||
using var rsa = RSA.Create(4096);
|
||||
var actor = new JsonObject
|
||||
{
|
||||
["id"] = "https://social.example/users/a",
|
||||
["type"] = "Person",
|
||||
["featured"] = "https://social.example/users/a/featured",
|
||||
["alsoKnownAs"] = new JsonArray("https://old.example/users/a"),
|
||||
["endpoints"] = new JsonObject { ["sharedInbox"] = "https://social.example/inbox" },
|
||||
["assertionMethod"] = new JsonArray(),
|
||||
["attachment"] = new JsonArray(new JsonObject { ["type"] = "PropertyValue", ["name"] = "x", ["value"] = "y" }),
|
||||
["manuallyApprovesFollowers"] = true,
|
||||
["discoverable"] = false,
|
||||
["indexable"] = true,
|
||||
["isCat"] = true,
|
||||
["_misskey_summary"] = "hi",
|
||||
["publicKey"] = new JsonObject { ["id"] = "https://social.example/users/a#main-key", ["publicKeyPem"] = rsa.ExportSubjectPublicKeyInfoPem() }
|
||||
};
|
||||
|
||||
var features = ActorFeatures.Detect(JsonDocument.Parse(actor.ToJsonString()).RootElement);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"featured", "also-known-as", "shared-inbox", "fep-521a-assertion-method", "property-value", "misskey", "is-cat",
|
||||
"indexable", "undiscoverable", "locked", "key:rsa-4096"
|
||||
}, features);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_bare_actor_reports_nothing_and_junk_is_survived()
|
||||
{
|
||||
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("{\"id\":\"https://a.example/u\",\"type\":\"Person\"}").RootElement));
|
||||
Assert.Equal(new[] { "public-key-array" },
|
||||
ActorFeatures.Detect(JsonDocument.Parse("{\"publicKey\":[{\"publicKeyPem\":\"not a key\"}]}").RootElement));
|
||||
Assert.Empty(ActorFeatures.Detect(JsonDocument.Parse("[1,2]").RootElement));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels.Social;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class CommunityEditsTests : IAsyncLifetime
|
||||
{
|
||||
Harness _harness;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_harness = await Harness.Start();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_harness != default)
|
||||
await _harness.DisposeAsync();
|
||||
}
|
||||
|
||||
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
||||
|
||||
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (root, alice) = await _harness.Persona("alice");
|
||||
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
||||
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
|
||||
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(poster)}/notes/{Guid.NewGuid():N}",
|
||||
["type"] = "Page",
|
||||
["name"] = "a title",
|
||||
["attributedTo"] = poster.Id,
|
||||
["content"] = "<p>first</p>",
|
||||
["to"] = new JsonArray(community.Id, Addressing.Public),
|
||||
["audience"] = community.Id,
|
||||
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
|
||||
};
|
||||
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, note.ToJsonString());
|
||||
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/create/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = poster.Id, ["object"] = note.DeepClone() });
|
||||
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(token);
|
||||
return (community, poster, note, post);
|
||||
}
|
||||
|
||||
Task Announce(RemoteActor community, JsonObject inner) =>
|
||||
_harness.Deliver(community, "/human-centipede", new JsonObject
|
||||
{
|
||||
["id"] = $"{Origin(community)}/announce/{Guid.NewGuid():N}",
|
||||
["type"] = "Announce",
|
||||
["actor"] = community.Id,
|
||||
["to"] = new JsonArray(Addressing.Public),
|
||||
["object"] = inner
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task A_community_edit_is_an_edit_only_when_newer_and_keeps_its_history()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (community, poster, note, post) = await Announced();
|
||||
|
||||
var refresh = (JsonObject)note.DeepClone();
|
||||
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, refresh.ToJsonString());
|
||||
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
||||
var afterRefresh = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
||||
|
||||
var edit = (JsonObject)note.DeepClone();
|
||||
edit["content"] = "<p>second</p>";
|
||||
edit["updated"] = DateTime.UtcNow.ToString("O");
|
||||
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, edit.ToJsonString());
|
||||
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
||||
var afterEdit = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
||||
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
|
||||
|
||||
Assert.Empty(afterRefresh.Revisions);
|
||||
Assert.Null(afterRefresh.EditedAt);
|
||||
Assert.Equal("<p>second</p>", afterEdit.ContentHtml);
|
||||
Assert.Equal("a title", afterEdit.Title);
|
||||
Assert.Single(afterEdit.Revisions);
|
||||
Assert.NotNull(afterEdit.EditedAt);
|
||||
Assert.Equal(2, record.Revisions.Count);
|
||||
Assert.Equal(new[] { "refresh", "edit" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Announce" && e.Object == "Update").Select(e => e.Reason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_community_delete_leaves_a_tombstone_and_no_trace()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (community, poster, note, post) = await Announced();
|
||||
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
|
||||
|
||||
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
||||
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
|
||||
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var (_, _, _, post) = await Announced();
|
||||
|
||||
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
|
||||
|
||||
Assert.Equal(ObjectPath.Fetched, record.Path);
|
||||
Assert.Null(record.KeyId);
|
||||
Assert.Null(record.SignatureScheme);
|
||||
Assert.Empty(record.SignedHeaders);
|
||||
Assert.Null(record.ActivityContext);
|
||||
Assert.Equal("Announce", record.ActivityType);
|
||||
Assert.InRange(record.ReceivedAt, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
|
||||
Assert.Contains("fep-1b12-audience", record.Extensions);
|
||||
Assert.NotNull(record.ContextNamespaces);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,16 @@ namespace PrivaPub.Tests.Federation
|
||||
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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user