Files
SocialPub/PrivaPub.Tests/Http/WellKnownTests.cs
T
thepraandClaude Opus 5.5 8f25bf056d Everything on, phase 4a: one answer everywhere for counts, search, collections and the instance API
Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.

- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
  totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
  counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
  decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
  activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
  Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
  reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
  notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
  accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
  always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
  are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
  Link.
- The instance API advertises what is enforced:
  - max_characters, now enforced with a 422;
  - max_pinned_statuses = MaxPins;
  - the media types and limits MediaService and MediaOptions accept;
  - PollService's limits;
  - the configured languages;
  - no streaming URL until streaming exists.

  domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
  - directory, tags/{name}, timelines/link and identity_proofs;
  - instance/languages, translation_languages, domain_blocks and privacy_policy;
  - the v1 and v2 notification policy, and notification requests.

Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.

671 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:48:40 +02:00

209 lines
9.0 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>();
[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_instance_actor_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());
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
}
}
}