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
95 lines
4.9 KiB
C#
95 lines
4.9 KiB
C#
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.User;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Domain
|
|
{
|
|
public class ContentRendererTests
|
|
{
|
|
const string Base = "https://privapub.test";
|
|
readonly ContentRenderer _renderer = new(new StubLocalActors(), new StubRemoteActors());
|
|
|
|
[Fact]
|
|
public async Task Decorates_local_and_remote_mentions_and_hashtags()
|
|
{
|
|
var rendered = await _renderer.Markdown("hello @alice and @bob@m.example about #Fedi **now** <script>x</script>", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Contains("<span class=\"h-card\" translate=\"no\"><a href=\"https://privapub.test/peasants/alice\" class=\"u-url mention\"", rendered.Html);
|
|
Assert.Contains("href=\"https://m.example/@bob\"", rendered.Html);
|
|
Assert.Contains("href=\"https://privapub.test/tags/fedi\" class=\"mention hashtag\"", rendered.Html);
|
|
Assert.Contains("<strong>now</strong>", rendered.Html);
|
|
Assert.DoesNotContain("<script>", rendered.Html);
|
|
Assert.Equal(new[] { "https://m.example/users/bob", "https://privapub.test/peasants/alice" }, rendered.Mentions.Select(m => m.ActorUri).Order(StringComparer.Ordinal));
|
|
Assert.Equal("fedi", Assert.Single(rendered.Tags));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("`@alice` in code")]
|
|
[InlineData("mail me@alice.example")]
|
|
[InlineData("@nobody is unknown")]
|
|
[InlineData("see https://x.example/page#alice")]
|
|
public async Task Leaves_what_is_not_a_mention_alone(string text)
|
|
{
|
|
var rendered = await _renderer.Markdown(text, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Empty(rendered.Mentions);
|
|
Assert.DoesNotContain("h-card", rendered.Html);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("issue #123")]
|
|
[InlineData("it's fine")]
|
|
[InlineData("[link](https://x.example/#tag)")]
|
|
public async Task Leaves_what_is_not_a_hashtag_alone(string text) =>
|
|
Assert.Empty((await _renderer.Markdown(text, TestContext.Current.CancellationToken)).Tags);
|
|
|
|
[Fact]
|
|
public async Task Renders_plain_text_like_mastodon()
|
|
{
|
|
var rendered = await _renderer.PlainText("line one\nline <two>\n\nsee https://x.example/a?b=1. #Tag", TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal("<p>line one<br>line <two></p><p>see <a href=\"https://x.example/a?b=1\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">https://x.example/a?b=1</a>. "
|
|
+ "<a href=\"https://privapub.test/tags/tag\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>Tag</span></a></p>", rendered.Html);
|
|
}
|
|
|
|
sealed class StubLocalActors : ILocalActorService
|
|
{
|
|
public string BaseAddress => Base;
|
|
|
|
public Task<GoneActor> Gone(string userName, CancellationToken token) => Task.FromResult<GoneActor>(default);
|
|
public Task<LocalActor> FindByUserName(string userName, CancellationToken token) =>
|
|
Task.FromResult(userName.Equals("alice", StringComparison.OrdinalIgnoreCase)
|
|
? new LocalActor { Id = "a1", UserName = "alice", BaseAddress = Base, Kind = LocalActorKind.Person }
|
|
: default);
|
|
|
|
public Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> FindByAddress(string address, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> GetInstanceActor(CancellationToken token) => throw new NotSupportedException();
|
|
public Task<LocalActor> GetReporterActor(CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> IsUserNameTaken(string userName, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token) => throw new NotSupportedException();
|
|
public LocalActor FromAvatar(Avatar avatar) => throw new NotSupportedException();
|
|
public LocalActor FromGroup(GroupEntity group) => throw new NotSupportedException();
|
|
}
|
|
|
|
sealed class StubRemoteActors : IRemoteActorService
|
|
{
|
|
public Task<string> ResolveHandle(string handle, CancellationToken token) =>
|
|
Task.FromResult(handle == "bob@m.example" ? "https://m.example/users/bob" : default);
|
|
|
|
public Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token) =>
|
|
Task.FromResult(actorUri == "https://m.example/users/bob"
|
|
? new ForeignAvatar { ID = "f1", ActorURI = actorUri, UserName = "bob", Url = "https://m.example/@bob", InboxURL = actorUri + "/inbox" }
|
|
: default);
|
|
|
|
public Task<FetchedJson> FetchObject(string uri, CancellationToken token) => throw new NotSupportedException();
|
|
public Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token) => throw new NotSupportedException();
|
|
}
|
|
}
|
|
}
|