- 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
132 lines
5.9 KiB
C#
132 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|