Files
SocialPub/PrivaPub.Tests/Http/WellKnownTests.cs
T
thepraandClaude Opus 5.5 f66c280b0b Reports reach Lemmy's moderators, from an anonymous reporter
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
2026-10-06 19:23:17 +02:00

238 lines
11 KiB
C#

using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class WellKnownTests : IAsyncLifetime
{
const string Base = PrivaPubHost.Base;
const string Domain = PrivaPubHost.Host;
PrivaPubHost _host;
HttpClient _client;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
}
public ValueTask DisposeAsync()
{
_client?.Dispose();
return ValueTask.CompletedTask;
}
Task<Fetched> WebFinger(string resource) =>
_client.Fetch("/.well-known/webfinger" + (resource == default ? string.Empty : "?resource=" + Uri.EscapeDataString(resource)), "application/jrd+json");
static string Link(JsonObject document, string rel) =>
document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue<string>() == rel)!["href"]!.GetValue<string>();
// FEP-d556, FEP-2677 and FEP-844e: the server's own actor is found from its origin and from NodeInfo, and says what
// the server reads that its documents do not show
[Fact]
public async Task The_server_actor_is_found_from_the_origin_and_nodeinfo_and_tells_what_it_implements()
{
var instance = $"{Base}/peasants/privapub";
foreach (var resource in new[] { Base, Base + "/" })
{
var found = await WebFinger(resource);
Assert.Equal(HttpStatusCode.OK, found.Status);
Assert.Equal(resource, found.Json["subject"]!.GetValue<string>());
Assert.Equal(instance, Link(found.Json, "https://www.w3.org/ns/activitystreams#Service"));
}
var nodeinfo = await _client.Fetch("/.well-known/nodeinfo", "application/json");
Assert.Equal(instance, Link(nodeinfo.Json, "https://www.w3.org/ns/activitystreams#Application"));
var actor = (await _client.Fetch("/peasants/privapub")).Json;
Assert.Contains(actor["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
var persona = await _host.Persona(await _host.SignUp(), "generated");
var personaActor = (await _client.Fetch($"/peasants/{persona.UserName}")).Json;
Assert.Equal("Application", personaActor["generator"]!["type"]!.GetValue<string>());
Assert.Contains(personaActor["generator"]!["implements"]!.AsArray(), i => i!["href"]!.GetValue<string>() == "https://datatracker.ietf.org/doc/html/rfc9421");
Assert.StartsWith("z6Mk", personaActor["assertionMethod"]![0]!["publicKeyMultibase"]!.GetValue<string>());
}
[Fact]
public async Task WebFinger_finds_a_persona_by_acct_and_by_actor_uri()
{
var persona = await _host.Persona(await _host.SignUp(), "finger");
var resources = new[]
{
$"acct:{persona.UserName}@{Domain}",
$"acct:@{persona.UserName}@{Domain}",
$"acct:{persona.UserName.ToUpperInvariant()}@{Domain.ToUpperInvariant()}",
$"{persona.UserName}@{Domain}",
$"@{persona.UserName}@{Domain}",
persona.ActorUri()
};
foreach (var resource in resources)
{
var found = await WebFinger(resource);
Assert.True(found.Status == HttpStatusCode.OK, $"{resource} answered {(int)found.Status}");
Assert.Equal("application/jrd+json", found.MediaType);
var document = found.Json;
Assert.Equal($"acct:{persona.UserName}@{Domain}", document["subject"]!.GetValue<string>());
Assert.Equal(new[] { $"{Base}/@{persona.UserName}", persona.ActorUri() }, Strings(document["aliases"]));
Assert.Equal(persona.ActorUri(), Link(document, "self"));
Assert.Equal("application/activity+json", document["links"]!.AsArray().Single(l => l!["rel"]!.GetValue<string>() == "self")!["type"]!.GetValue<string>());
Assert.Equal($"{Base}/@{persona.UserName}", Link(document, "http://webfinger.net/rel/profile-page"));
Assert.DoesNotContain(persona.Root.Id, found.Text);
Assert.DoesNotContain(persona.Root.UserName, found.Text);
}
}
[Fact]
public async Task WebFinger_refuses_other_domains_unknown_names_and_a_missing_resource()
{
var persona = await _host.Persona(await _host.SignUp(), "nofinger");
Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.UserName}@elsewhere.example")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.UserName}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:nobody{Guid.NewGuid():N}"[..20] + "@" + Domain)).Status);
Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"https://elsewhere.example/peasants/{persona.UserName}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await WebFinger($"acct:{persona.Root.UserName}@{Domain}")).Status);
Assert.Equal(HttpStatusCode.BadRequest, (await WebFinger(default)).Status);
Assert.Equal(HttpStatusCode.BadRequest, (await WebFinger(string.Empty)).Status);
}
[Fact]
public async Task WebFinger_finds_the_servers_own_actors_and_a_community()
{
var owner = await _host.Persona(await _host.SignUp(), "fingerowner");
var community = await _host.FederatedGroup(owner, community: true);
var instance = await WebFinger($"acct:privapub@{Domain}");
var group = await WebFinger($"acct:{community.UserName}@{Domain}");
Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal($"acct:privapub@{Domain}", instance.Json["subject"]!.GetValue<string>());
Assert.Equal($"{Base}/peasants/privapub", Link(instance.Json, "self"));
Assert.Single(instance.Json["links"]!.AsArray());
var reporter = await WebFinger($"acct:privapub_reports@{Domain}");
Assert.Equal(HttpStatusCode.OK, reporter.Status);
Assert.Equal($"{Base}/peasants/privapub_reports", Link(reporter.Json, "self"));
Assert.Single(reporter.Json["links"]!.AsArray());
Assert.Equal(HttpStatusCode.OK, group.Status);
Assert.Equal($"acct:{community.UserName}@{Domain}", group.Json["subject"]!.GetValue<string>());
Assert.Equal(community.Uri, Link(group.Json, "self"));
Assert.DoesNotContain(owner.UserName, group.Text);
Assert.DoesNotContain(owner.Id, group.Text);
}
// Current behaviour, for the owner to decide: WebFinger answers for a circle, because Mastodon resolves a handle
// through WebFinger before it can send the Follow a circle takes members through, while /api/v1/accounts/lookup,
// search and the profile page do not show circles at all.
[Fact]
public async Task WebFinger_answers_for_a_circle()
{
var owner = await _host.Persona(await _host.SignUp(), "circlefinger");
var circle = await _host.FederatedGroup(owner, community: false);
var found = await WebFinger($"acct:{circle.UserName}@{Domain}");
Assert.Equal(HttpStatusCode.OK, found.Status);
Assert.Equal(circle.Uri, Link(found.Json, "self"));
Assert.DoesNotContain(owner.UserName, found.Text);
}
[Fact]
public async Task NodeInfo_is_linked_and_served_in_both_versions_without_naming_anyone()
{
var root = await _host.SignUp();
var persona = await _host.Persona(root, "nodeinfo");
await _host.Publish(persona, "counted");
var links = await _client.Fetch("/.well-known/nodeinfo", "application/json");
Assert.Equal(HttpStatusCode.OK, links.Status);
Assert.Equal("application/json", links.MediaType);
var hrefs = links.Json["links"]!.AsArray().ToDictionary(l => l!["rel"]!.GetValue<string>(), l => l!["href"]!.GetValue<string>());
Assert.Equal($"{Base}/nodeinfo/2.1", hrefs["http://nodeinfo.diaspora.software/ns/schema/2.1"]);
Assert.Equal($"{Base}/nodeinfo/2.0", hrefs["http://nodeinfo.diaspora.software/ns/schema/2.0"]);
foreach (var version in new[] { "2.0", "2.1" })
{
var info = await _client.Fetch(PathOf(hrefs[$"http://nodeinfo.diaspora.software/ns/schema/{version}"]), "application/json");
Assert.Equal(HttpStatusCode.OK, info.Status);
Assert.Equal("application/json", info.MediaType);
Assert.Contains($"profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"", info.Response.Content.Headers.ContentType!.ToString());
var document = info.Json;
Assert.Equal(version, document["version"]!.GetValue<string>());
Assert.Equal("privapub", document["software"]!["name"]!.GetValue<string>());
Assert.Equal(version == "2.1", document["software"]!["repository"] != default);
Assert.Equal(new[] { "activitypub" }, Strings(document["protocols"]));
Assert.True(document["usage"]!["users"]!["total"]!.GetValue<long>() >= 1);
Assert.True(document["usage"]!["localPosts"]!.GetValue<long>() >= 1);
Assert.DoesNotContain(root.Id, info.Text);
Assert.DoesNotContain(root.UserName, info.Text);
Assert.DoesNotContain(persona.UserName, info.Text);
}
}
[Fact]
public async Task Unknown_nodeinfo_versions_are_404()
{
foreach (var version in new[] { "1.0", "2.2", "3.0", "2.0.json", "2" })
Assert.True((await _client.Fetch($"/nodeinfo/{version}", "application/json")).Status == HttpStatusCode.NotFound, $"/nodeinfo/{version} was served");
}
}
[Trait("Category", "Integration")]
[Collection(nameof(Exclusive))]
public sealed class NodeInfoUsageTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
async Task<long> LocalPosts()
{
using var client = _host.Client();
return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue<long>();
}
// localPosts is Mastodon's: the sum of statuses_count, every local post but DMs and deleted ones, boosts included,
// and circle and located posts too (owner decision 2026-10-04)
[Fact]
public async Task Local_posts_count_as_mastodon_counts_statuses()
{
var persona = await _host.Persona(await _host.SignUp(), "usage");
var friend = await _host.Persona(await _host.SignUp(), "usagefriend");
var start = await LocalPosts();
var shown = await _host.Publish(persona, "counted");
await _host.Publish(persona, "counted too", PostVisibility.Unlisted);
Assert.Equal(start + 2, await LocalPosts());
await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly);
await _host.Publish(persona, $"@{friend.UserName} between us", PostVisibility.Direct);
await _host.PublishLocated(persona, "around here");
await _host.FederatedCopy(persona);
await _host.Reblog(friend, shown);
var circle = await _host.FederatedGroup(persona, community: false);
await _host.Publish(persona, new StatusDraft { Text = "circle only", PlainText = true, GroupId = circle.Id });
Assert.Equal(start + 6, await LocalPosts());//followers-only, located, the boost and the circle post; not the DM nor the copy
await _host.Remove(persona, shown);
Assert.Equal(start + 4, await LocalPosts());//the post and the boost of it
}
}
}