Files
SocialPub/PrivaPub.Tests/Federation/CommunityModerationTests.cs
T
thepraandClaude Opus 5.5 587997dd67
Build / Build (push) Successful in 8m37s
P7: Lemmy's Warn and Resolve{Flag}
- A Warn from a community's moderator about a persona's own post there becomes that persona's moderation_warning
  notification (Mastodon's AccountWarning, with the reason and the post), believed from the community's own server or
  from an account the community's moderators collection lists. Remote communities keep that collection's address
  (attributedTo) as ForeignAvatar.ModeratorsURL; it is read only when a warning needs it.
- A Resolve{Flag} for one of our reports (`/grunts/flag-<report id>`) is kept as the report's remote resolution when it
  comes from the server holding what was reported, or the community it was reported to; our own moderators'
  resolution is never replaced. The moderators' report list shows both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:10:58 +02:00

116 lines
5.4 KiB
C#

using MongoDB.Entities;
using PrivaPub.ClientModels.Post;
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 CommunityModerationTests : 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(string id) => new Uri(id).GetLeftPart(UriPartial.Authority);
// a Lemmy community on the peer's first origin whose moderators collection lists the given accounts
RemoteActor Community(params RemoteActor[] moderators)
{
var community = new RemoteActor(_harness.Peer, "comm", type: "Group");
var document = community.Document();
document["attributedTo"] = community.Id + "/moderators";
_harness.Peer.Serve($"/users/{community.Name}", document.ToJsonString());
_harness.Peer.Serve($"/users/{community.Name}/moderators", new JsonObject
{
["id"] = community.Id + "/moderators", ["type"] = "OrderedCollection",
["orderedItems"] = new JsonArray(moderators.Select(m => (JsonNode)m.Id).ToArray())
}.ToJsonString());
return community;
}
async Task<(Post Post, PrivaPub.Federation.Actors.LocalActor Alice)> PostIn(RemoteActor community)
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = "in the community" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await DB.Default.Update<Post>().MatchID(post.ID).Modify(p => p.AudienceURI, community.Id).ExecuteAsync(token);
await _harness.Remote.GetActor(community.Id, refresh: true, token);
return (post, alice);
}
JsonObject Warn(RemoteActor moderator, RemoteActor community, string persona, string post) => new()
{
["id"] = $"{Origin(moderator.Id)}/activities/warn/{Guid.NewGuid():N}", ["type"] = "Warn", ["actor"] = moderator.Id,
["to"] = new JsonArray(persona), ["object"] = post, ["summary"] = "please keep it civil", ["audience"] = community.Id
};
[Fact]
public async Task A_warning_from_the_communitys_moderators_reaches_the_persona_and_a_strangers_does_not()
{
var token = TestContext.Current.CancellationToken;
var listed = new RemoteActor(_harness.Peer, "listedmod", origin: _harness.Peer.B);
var stranger = new RemoteActor(_harness.Peer, "stranger", origin: _harness.Peer.B);
var community = Community(listed);
var admin = new RemoteActor(_harness.Peer, "admin");
var (post, alice) = await PostIn(community);
await _harness.Deliver(stranger, "/human-centipede", Warn(stranger, community, alice.Uri, post.ObjectURI));
Assert.False(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteAnyAsync(token));
await _harness.Deliver(listed, "/human-centipede", Warn(listed, community, alice.Uri, post.ObjectURI));
var warning = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token);
Assert.Equal("please keep it civil", warning.Text);
Assert.Equal(post.ID, warning.PostId);
var adminWarning = Warn(admin, community, alice.Uri, post.ObjectURI);
adminWarning["summary"] = "from the server's admin";
await DB.Default.DeleteAsync<Notification>(n => n.AvatarId == alice.Id);
await _harness.Deliver(admin, "/human-centipede", adminWarning);
Assert.Equal("from the server's admin", (await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.ModerationWarning).ExecuteFirstAsync(token)).Text);
}
[Fact]
public async Task A_reports_resolution_is_kept_only_from_the_server_holding_what_was_reported()
{
var token = TestContext.Current.CancellationToken;
var community = Community();
var (post, _) = await PostIn(community);
var report = new Report { PostIds = new() { post.ID }, Comment = "spam" };
await DB.Default.SaveAsync(report, token);
var flag = $"{_harness.Local.BaseAddress}/peasants/privapub_reports/grunts/flag-{report.ID}-1";
JsonObject Resolve(RemoteActor by) => new()
{
["id"] = $"{Origin(by.Id)}/activities/resolve/{Guid.NewGuid():N}", ["type"] = "Resolve", ["actor"] = by.Id, ["to"] = new JsonArray(community.Id),
["object"] = new JsonObject { ["id"] = flag, ["type"] = "Flag", ["actor"] = $"{_harness.Local.BaseAddress}/peasants/privapub_reports", ["object"] = post.ObjectURI }
};
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", origin: _harness.Peer.B);
var moderator = new RemoteActor(_harness.Peer, "moderator");
await _harness.Deliver(elsewhere, "/human-centipede", Resolve(elsewhere));
Assert.Null((await DB.Default.Find<Report>().OneAsync(report.ID, token)).RemoteResolvedAt);
await _harness.Deliver(moderator, "/human-centipede", Resolve(moderator));
var resolved = await DB.Default.Find<Report>().OneAsync(report.ID, token);
Assert.NotNull(resolved.RemoteResolvedAt);
Assert.Equal(moderator.Id, resolved.RemoteResolvedBy);
Assert.False(resolved.IsResolved);
}
}
}