Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
207 lines
8.7 KiB
C#
207 lines
8.7 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>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Local_posts_count_only_what_anyone_may_see()
|
|
{
|
|
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 + 2, await LocalPosts());
|
|
|
|
await _host.Remove(persona, shown);
|
|
Assert.Equal(start + 1, await LocalPosts());
|
|
}
|
|
}
|
|
}
|