Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
246 lines
11 KiB
C#
246 lines
11 KiB
C#
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" };
|
|
|
|
Harness _harness;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_harness = await Harness.Start();
|
|
await DB.Default.DeleteAsync<RemoteInstance>(i => PeerHosts.Contains(i.Host));
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_harness == default)
|
|
return;
|
|
await DB.Default.DeleteAsync<RemoteInstance>(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<RemoteInstance>(i => i.Host == host);
|
|
await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = software, SoftwareVersion = "1.0.0" }, Token);
|
|
}
|
|
|
|
async Task<ForeignAvatar> Known(RemoteActor actor) => await _harness.Remote.GetActor(actor.Id, refresh: false, Token);
|
|
|
|
static async Task<Post> 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 = "<p>reported</p>"
|
|
};
|
|
await DB.Default.SaveAsync(post, Token);
|
|
return post;
|
|
}
|
|
|
|
async Task<List<(DeliveryPayload Payload, JsonObject Body)>> Queued() =>
|
|
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= DateTime.UtcNow.AddMinutes(-5)).ExecuteAsync(Token))
|
|
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
|
.Select(p => (p, JsonNode.Parse(p.Body)!.AsObject()))
|
|
.ToList();
|
|
|
|
// a community on a Lemmy (the peer as localhost), and one of its posters there
|
|
async Task<(RemoteActor Community, RemoteActor Poster)> OnLemmy()
|
|
{
|
|
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", "lemmy");
|
|
return (community, poster);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_post_in_a_lemmy_community_is_reported_to_its_community_by_the_reporter_and_nowhere_else()
|
|
{
|
|
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 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<string>());
|
|
Assert.Equal(reporter.Uri, flag["actor"]!.GetValue<string>());
|
|
Assert.EndsWith("/peasants/privapub_reports", reporter.Uri);
|
|
Assert.StartsWith(reporter.Uri + "/", flag["id"]!.GetValue<string>());
|
|
Assert.Equal(new[] { community.Id }, flag["to"]!.AsArray().Select(t => t!.GetValue<string>()));
|
|
Assert.Equal(community.Id, flag["audience"]!.GetValue<string>());
|
|
Assert.Equal(post.ObjectURI, flag["object"]!.GetValue<string>());
|
|
Assert.Equal("a slur in the title", flag["summary"]!.GetValue<string>());
|
|
Assert.Equal("a slur in the title", flag["content"]!.GetValue<string>());
|
|
Assert.Empty(await _harness.Outgoing(poster.Id + "/inbox"));
|
|
Assert.DoesNotContain(await Queued(), q => q.Payload.Inbox != community.SharedInbox && q.Body.ToJsonString().Contains(post.ObjectURI));
|
|
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
|
|
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
|
|
var queued = Assert.Single(await Queued(), q => q.Body["actor"]!.GetValue<string>() == reporter.Uri && q.Body["object"]!.GetValue<string>() == post.ObjectURI);
|
|
Assert.Equal(LocalActorKind.Reporter, queued.Payload.SignerKind);
|
|
}
|
|
|
|
[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<Job>()
|
|
.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<DeliveryJobHandler>.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<string>());
|
|
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<string>()).Order());
|
|
Assert.All(flags, f => Assert.Equal(community.Id, f["to"]![0]!.GetValue<string>()));
|
|
Assert.Equal(2, flags.Select(f => f["id"]!.GetValue<string>()).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<string>(), "a spammer", "spam", forward: true, Token);
|
|
|
|
Assert.False(report.Forwarded);
|
|
Assert.False((await DB.Default.Find<PrivaPub.Models.Social.Report>().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<string>());
|
|
var toAuthor = Assert.Single(await _harness.Outgoing(mastodonian.Id + "/inbox"));
|
|
Assert.Equal(instance.Uri, toAuthor["actor"]!.GetValue<string>());
|
|
Assert.Equal(new[] { mastodonian.Id, inCommunity.ObjectURI, elsewhere.ObjectURI }.Order(),
|
|
toAuthor["object"]!.AsArray().Select(o => o!.GetValue<string>()).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<string>());
|
|
}
|
|
|
|
[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<Follower>().Match(f => f.ActorURI == follower.Id).ExecuteAnyAsync(Token));
|
|
}
|
|
}
|
|
}
|