using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Federation { // Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community (owner // decision 2026-10-06): a report about a post in a community on a Lemmy leaves from the server's reporter, one per post. // Alone in its collection: the server rows it writes for the peer's hosts decide for whoever reports there. [Trait("Category", "Integration")] [Xunit.Collection(nameof(Exclusive))] public sealed class ServiceReportTests : IAsyncLifetime { static readonly string[] PeerHosts = { "localhost", "127.0.0.1" }; // (a test sees only the deliveries queued since it began: the others' share the database) readonly DateTime _started = DateTime.UtcNow; Harness _harness; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _harness = await Harness.Start(); await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); } public async ValueTask DisposeAsync() { if (_harness == default) return; await DB.Default.DeleteAsync(i => PeerHosts.Contains(i.Host)); await _harness.DisposeAsync(); } static CancellationToken Token => TestContext.Current.CancellationToken; static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); // what NodeInfo said the server at that host runs static async Task Runs(string host, string software) { await DB.Default.DeleteAsync(i => i.Host == host); await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token); } async Task Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token); static async Task Held(RemoteActor author, string community = default, Post parent = default) { var post = new Post { ObjectURI = $"{Origin(author)}/post/{Guid.NewGuid():N}", ActorURI = author.Id, AudienceURI = community, AnsweringToPostId = parent?.ID, ContentHtml = "

reported

" }; await DB.Default.SaveAsync(post, Token); return post; } async Task> Queued() => (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= _started).ExecuteAsync(Token)) .Select(j => JsonSerializer.Deserialize(j.Payload)) .ToList(); // a community on a Lemmy, or on a server that takes reports as Lemmy does (the peer as localhost), and one of its posters there async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy(string software = "lemmy") { var community = new RemoteActor(_harness.Peer, "cats", _harness.Peer.B, type: "Group", sharedInbox: true); var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); await Known(community); await Known(poster); await Runs("localhost", software); return (community, poster); } // PieFed and Mbin take it too, proven live (2026-10-06); NodeInfo names Lemmy as "lemmy" and kbin's heir as "mbin" [Theory] [InlineData("lemmy")] [InlineData("Lemmy")] [InlineData("piefed")] [InlineData("mbin")] public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else(string software) { var (_, alice) = await _harness.Persona("alice"); var (community, poster) = await OnLemmy(software); var post = await Held(poster, community.Id); var reporter = await _harness.Local.GetReporterActor(Token); var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, " a slur in the title ", "violation", forward: true, Token); Assert.True(report.Forwarded); var flag = Assert.Single(await _harness.Outgoing(community.SharedInbox)); Assert.Equal("Flag", flag["type"]!.GetValue()); Assert.Equal(reporter.Uri, flag["actor"]!.GetValue()); Assert.EndsWith("/peasants/privapub_reports", reporter.Uri); Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue()); Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue())); Assert.Equal(community.Id, flag["audience"]!.GetValue()); Assert.Equal(post.ObjectURI, flag["object"]!.GetValue()); Assert.Equal("a slur in the title", flag["summary"]!.GetValue()); Assert.Equal("a slur in the title", flag["content"]!.GetValue()); Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); Assert.DoesNotContain(await Queued(), q => q.Inbox != community.SharedInbox && q.Body.Contains(post.ObjectURI)); Assert.DoesNotContain(alice.UserName, flag.ToJsonString()); Assert.DoesNotContain(alice.Id, flag.ToJsonString()); var queued = Assert.Single(await Queued(), q => q.Inbox == community.SharedInbox); Assert.Equal(LocalActorKind.Reporter, queued.SignerKind); Assert.Equal(reporter.Id, queued.SignerId); } [Fact] public async Task The_reporter_signs_its_flag_with_its_own_key() { var (_, alice) = await _harness.Persona("alice"); var (community, poster) = await OnLemmy(); var post = await Held(poster, community.Id); var reporter = await _harness.Local.GetReporterActor(Token); var instance = await _harness.Local.GetInstanceActor(Token); _harness.Peer.Answer("/inbox", 202); await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { post.ID }, default, "spam", forward: true, Token); var job = await DB.Default.Find() .Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(reporter.ActivityUri($"flag-"))) .Sort(j => j.CreatedAt, Order.Descending).ExecuteFirstAsync(Token); var handler = new DeliveryJobHandler(_harness.Local, Peer.Http(), new HostCircuitBreaker(new MemoryCache(new MemoryCacheOptions())), NullLogger.Instance); var outcome = await handler.Handle(job, Token); Assert.Equal(JobResult.Done, outcome.Result); var received = Assert.Single(_harness.Peer.Requests, r => r.Path == "/inbox" && r.Method == "POST"); // no words: Lemmy takes no report without a reason, so the category is it Assert.Equal("spam", JsonNode.Parse(received.Body)!["summary"]!.GetValue()); var signature = HttpSignatures.Parse(received.Signature); Assert.Equal(reporter.KeyId, signature.KeyId); Assert.NotEqual(instance.PublicKeyPem, reporter.PublicKeyPem); var signingString = $"(request-target): post /inbox\nhost: {received.Headers["Host"]}\ndate: {received.Headers["Date"]}\ndigest: {received.Headers["Digest"]}"; using var key = RSA.Create(); key.ImportFromPem(reporter.PublicKeyPem); Assert.True(key.VerifyData(Encoding.UTF8.GetBytes(signingString), signature.Signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); } [Fact] public async Task A_comment_finds_its_community_through_its_thread_and_two_posts_are_two_flags() { var (_, alice) = await _harness.Persona("alice"); var (community, poster) = await OnLemmy(); var thread = await Held(poster, community.Id); var comment = await Held(poster, parent: thread); var answer = await Held(poster, parent: comment); var report = await _harness.Reports.File(alice, (await Known(poster)).ID, new[] { comment.ID, answer.ID }, "harassment", "other", forward: true, Token); Assert.True(report.Forwarded); var flags = await _harness.Outgoing(community.SharedInbox); Assert.Equal(2, flags.Count); Assert.Equal(new[] { comment.ObjectURI, answer.ObjectURI }.Order(), flags.Select(f => f["object"]!.GetValue()).Order()); Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue())); Assert.Equal(2, flags.Select(f => f["id"]!.GetValue()).Distinct().Count()); } [Fact] public async Task An_account_alone_on_a_lemmy_is_reported_to_nobody() { var (_, alice) = await _harness.Persona("alice"); var (community, poster) = await OnLemmy(); var report = await _harness.Reports.File(alice, (await Known(poster)).ID, Array.Empty(), "a spammer", "spam", forward: true, Token); Assert.False(report.Forwarded); Assert.False((await DB.Default.Find().OneAsync(report.ID, Token)).Forwarded); Assert.Empty(await _harness.Outgoing(community.SharedInbox)); Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox")); } [Fact] public async Task Any_other_server_still_gets_the_instance_flag_and_a_lemmy_community_its_own() { var (_, alice) = await _harness.Persona("alice"); var (community, _) = await OnLemmy(); // an account on a Mastodon (the peer as 127.0.0.1) that wrote in the community on the Lemmy, and elsewhere var mastodonian = new RemoteActor(_harness.Peer, "masto"); await Runs("127.0.0.1", "mastodon"); var inCommunity = await Held(mastodonian, community.Id); var elsewhere = await Held(mastodonian); var instance = await _harness.Local.GetInstanceActor(Token); var report = await _harness.Reports.File(alice, (await Known(mastodonian)).ID, new[] { inCommunity.ID, elsewhere.ID }, "spam", "spam", forward: true, Token); Assert.True(report.Forwarded); var toCommunity = Assert.Single(await _harness.Outgoing(community.SharedInbox)); Assert.Equal(inCommunity.ObjectURI, toCommunity["object"]!.GetValue()); var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox")); Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue()); Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(), toAuthor["object"]!.AsArray().Select(o => o!.GetValue()).Order()); Assert.Null(toAuthor["to"]); } [Fact] public async Task A_community_on_a_server_that_takes_no_service_reports_gets_nothing_new() { var (_, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "forum", _harness.Peer.B, type: "Group", sharedInbox: true); var poster = new RemoteActor(_harness.Peer, "poster", _harness.Peer.B); await Known(community); var foreign = await Known(poster); await Runs("localhost", "friendica"); var post = await Held(poster, community.Id); var report = await _harness.Reports.File(alice, foreign.ID, new[] { post.ID }, "spam", "spam", forward: true, Token); Assert.True(report.Forwarded); Assert.Empty(await _harness.Outgoing(community.SharedInbox)); var flag = Assert.Single(await _harness.Outgoing(poster.Id + "/inbox")); Assert.EndsWith("/peasants/privapub", flag["actor"]!.GetValue()); } [Fact] public async Task Nobody_follows_the_reporter() { var follower = new RemoteActor(_harness.Peer, "follower"); var reporter = await _harness.Local.GetReporterActor(Token); var result = await _harness.Deliver(follower, "/human-centipede", new JsonObject { ["id"] = $"{Origin(follower)}/follow/{Guid.NewGuid():N}", ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = reporter.Uri }); Assert.Equal(404, result.StatusCode); Assert.False(await DB.Default.Find().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token)); } } }