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
170 lines
7.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|