Files
SocialPub/PrivaPub.Tests/Federation/CommunityEditsTests.cs
T
thepraandClaude Opus 5.5 47d6e22988 PieFed joins the pasture; the instance actor answers at the root
PieFed 1.7.17 (dockurr's image of the release) runs in the pasture with its
Celery worker on the shared Postgres and Redis, and scenarios/piefed.sh
checks it both ways: 29 checks, communities, titled threads, comments, votes
up and down, a community poll and a vote in it, private messages, a
moderator's lock, unlock and removal, the unfollow and statistics.

What it showed:
- PieFed sends a community's announces to the inbox of the Application at a
  peer's root (as Lemmy serves its site actor) and to /inbox otherwise.
  PrivaPub answered 404 at its root, so every announce went to an /inbox it
  does not have. The instance actor now answers at / for ActivityPub
  requests, unsigned under SecureMode as at its own address.
- PieFed keeps serving a thread its moderator removed, so the removal could
  never be checked against the post's origin. A community on the post's own
  server now speaks for it; one elsewhere still waits for the origin to say
  the post is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 15:44:13 +02:00

170 lines
7.8 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);
// a post in a community the persona follows; the community on the poster's server unless another origin is given
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, 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));
}
// PieFed keeps serving a thread its moderator removed: a community on the post's own server speaks for it
[Fact]
public async Task A_removal_a_community_on_the_posts_server_announces_is_taken_though_the_post_is_still_served()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced();
await Announce(community, new JsonObject
{
["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id,
["object"] = note["id"]!.GetValue<string>(), ["summary"] = "off topic"
});
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
}
[Fact]
public async Task A_removal_a_community_elsewhere_announces_waits_for_the_posts_server_to_say_it_is_gone()
{
var token = TestContext.Current.CancellationToken;
var (community, poster, note, post) = await Announced(_harness.Peer.B);
var delete = new JsonObject
{
["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = community.Id,
["object"] = note["id"]!.GetValue<string>()
};
await Announce(community, (JsonObject)delete.DeepClone());
var kept = await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token);
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
delete["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}";
await Announce(community, delete);
Assert.True(kept);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).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);
}
}
}