T7: the federation surface over HTTP

Tests through the real routes of PeasantsController, WellKnownController and
UsersController, on the whole server under test (34 new tests):

- FederationGetTests: the actor document (activity+json, SPKI key at
  #main-key owned by the actor, sharedInbox, published = PublishedOn's day,
  no creation date, no root); ld+json; browsers sent to /@name; Vary: Accept;
  /users 301; the outbox's totalItems and ?page=true&max_id paging across
  the 20-item boundary, boosts as Announces, and no followers-only, direct,
  located, federated-copy or deleted post; /groupies and /stalking naming
  nobody; /trophies (public pins, newest first) and /tattoos; /scribbles
  (public and unlisted 200, browsers redirected, followers-only, direct and
  located 404, deleted 410 Tombstone); a circle post only for a signed member
  or its instance actor; a circle's /groupies, /flock and /wardens only for
  members; /grunts create- and announce- ids; /parrot-licences 200, revoked
  410, wrong author 404; secure mode's 401 for every unsigned GET but the
  instance actor's.
- WellKnownTests: WebFinger by acct:, @-prefixed, bare, upper-case and actor
  URI; other domains, unknown names, a root's login name and no resource;
  the instance actor, a community, a circle (answered: current behaviour);
  NodeInfo links, 2.0 and 2.1 naming no root, unknown versions 404; usage
  counting only public, unlisted, non-boost local posts (Exclusive).
- InboxRouteTests: all three inboxes accept a signed delivery and refuse
  junk (400), unsigned (401), a bad Digest, a two-hour-old Date, a signature
  for another host and a swapped body (401); an unknown persona's /mouth is
  404; ld+json with the ActivityStreams profile is accepted; a signer whose
  actor answers 503 gets 503 with Retry-After; the 301st unsigned POST from
  one address is 429 while a signed server from it is not.
- PersonaSeparationHttpTests: with a sibling persona and its community on
  the same login, every GET under /api (filled with the persona's ids) plus
  search, lookup and relationships, and a crawl of everything federation
  publishes about the persona (actor, outbox pages, collections, scribbles,
  grunts, WebFinger, NodeInfo, /@ pages), never name the sibling, its
  community or the login.

Fixed:
- A circle's /groupies told anyone how many followers (members) it has,
  while its /flock and /wardens were already for members only; it now
  answers 404 to anyone but a signed member or a member's instance actor.
- WebFinger answered 404 to a bare user@domain or @user@domain resource,
  which Mastodon, GoToSocial and Pleroma all accept; it now treats them as
  acct: (noted in docs/INTEROP.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:53:23 +02:00
1 parent c5e4934ba6
commit 2cfea7b60c
8 files changed
+1295 -3

No files matched your search

+468
View File
@@ -0,0 +1,468 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class FederationGetTests : IAsyncLifetime
{
const string Base = PrivaPubHost.Base;
PrivaPubHost _host;
HttpClient _client;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
_client?.Dispose();
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task The_actor_document_is_activity_json_with_an_spki_key_on_the_actor()
{
var persona = await _host.Persona(await _host.SignUp(), "actor");
var avatar = await DB.Default.Find<Avatar>().MatchID(persona.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
var fetched = await _client.Fetch($"/peasants/{persona.UserName}");
Assert.Equal(HttpStatusCode.OK, fetched.Status);
Assert.Equal(ActivityJson, fetched.MediaType);
Assert.Contains("Accept", fetched.Response.Headers.Vary);
var actor = fetched.Json;
var id = persona.ActorUri();
Assert.Equal(id, actor["id"]!.GetValue<string>());
Assert.Equal("Person", actor["type"]!.GetValue<string>());
Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue<string>());
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
Assert.Equal(id + "/groupies", actor["followers"]!.GetValue<string>());
Assert.Equal(id + "/stalking", actor["following"]!.GetValue<string>());
Assert.Equal(id + "/trophies", actor["featured"]!.GetValue<string>());
Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue<string>());
Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue<string>());
Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
var key = actor["publicKey"]!;
Assert.Equal(id + "#main-key", key["id"]!.GetValue<string>());
Assert.Equal(id, key["owner"]!.GetValue<string>());
var pem = key["publicKeyPem"]!.GetValue<string>();
Assert.StartsWith("-----BEGIN PUBLIC KEY-----", pem);
using var rsa = RSA.Create();
rsa.ImportFromPem(pem);
Assert.True(rsa.KeySize >= 2048);
Assert.Equal(ActivityPubRenderer.Day(avatar.PublishedOn), actor["published"]!.GetValue<string>());
Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue<string>());
Assert.DoesNotContain(Keys(actor), k => k.Contains("created", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(ActivityPubRenderer.Timestamp(avatar.CreatedAt)[..16], fetched.Text);
Assert.DoesNotContain(persona.Root.Id, fetched.Text);
Assert.DoesNotContain(persona.Root.UserName, fetched.Text);
}
[Fact]
public async Task The_actor_answers_ld_json_and_sends_browsers_to_the_profile_page()
{
var persona = await _host.Persona(await _host.SignUp(), "browser");
var ld = await _client.Fetch($"/peasants/{persona.UserName}", LdJson);
var browser = await _client.Fetch($"/peasants/{persona.UserName}", Browser);
var instance = await _client.Fetch("/peasants/privapub", Browser);
Assert.Equal(HttpStatusCode.OK, ld.Status);
Assert.Equal(persona.ActorUri(), ld.Json["id"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
Assert.Equal($"{Base}/@{persona.UserName}", browser.Location);
Assert.Contains("Accept", browser.Response.Headers.Vary);
Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
}
[Fact]
public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404()
{
var persona = await _host.Persona(await _host.SignUp(), "users");
var moved = await _client.Fetch($"/users/{persona.UserName}");
var unknown = await _client.Fetch($"/peasants/nobody{Guid.NewGuid():N}"[..28]);
Assert.Equal(HttpStatusCode.MovedPermanently, moved.Status);
Assert.Equal($"/peasants/{persona.UserName}", moved.Location);
Assert.Equal(HttpStatusCode.NotFound, unknown.Status);
}
[Fact]
public async Task The_outbox_pages_public_posts_and_boosts_and_nothing_else()
{
var persona = await _host.Persona(await _host.SignUp(), "outbox");
var friend = await _host.Persona(await _host.SignUp(), "friend");
var boosted = await _host.Publish(friend, "worth a boost");
var shown = new List<Post>();
for (var i = 0; i < 20; i++)
shown.Add(await _host.Publish(persona, $"public number {i}"));
shown.Add(await _host.Publish(persona, "quietly public", PostVisibility.Unlisted));
var hidden = new List<Post>
{
await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly),
await _host.Publish(persona, $"@{friend.UserName} just you", PostVisibility.Direct),
await _host.PublishLocated(persona, "only around here"),
await _host.FederatedCopy(persona)
};
var deleted = await _host.Publish(persona, "regretted");
await _host.Remove(persona, deleted);
hidden.Add(deleted);
var reblog = await _host.Reblog(persona, boosted);
Assert.Equal(PostVisibility.LocalGeo, hidden[2].Visibility);
var outbox = await _client.Fetch($"/peasants/{persona.UserName}/anus");
Assert.Equal(HttpStatusCode.OK, outbox.Status);
Assert.Equal(ActivityJson, outbox.MediaType);
Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>());
Assert.Equal(22, outbox.Json["totalItems"]!.GetValue<int>());
Assert.Null(outbox.Json["orderedItems"]);
var first = outbox.Json["first"]!.GetValue<string>();
Assert.Equal(persona.ActorUri() + "/anus?page=true", first);
var page1 = await _client.Fetch(PathOf(first));
Assert.Equal(HttpStatusCode.OK, page1.Status);
Assert.Equal("OrderedCollectionPage", page1.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue<string>());
var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList();
Assert.Equal(20, items1.Count);
var next = page1.Json["next"]!.GetValue<string>();
Assert.StartsWith(persona.ActorUri() + "/anus?page=true&max_id=", next);
var page2 = await _client.Fetch(PathOf(next));
Assert.Equal(HttpStatusCode.OK, page2.Status);
Assert.Equal(next, page2.Json["id"]!.GetValue<string>());
var items2 = page2.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList();
Assert.Equal(2, items2.Count);
Assert.Null(page2.Json["next"]);
var items = items1.Concat(items2).ToList();
Assert.Equal(22, items.Select(i => i["id"]!.GetValue<string>()).Distinct().Count());
var announce = items[0];
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue<string>());
Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
var creates = items.Skip(1).ToList();
Assert.All(creates, c =>
{
Assert.Equal("Create", c["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue<string>());
});
Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue<string>()));
var everything = page1.Text + page2.Text;
foreach (var post in hidden)
Assert.DoesNotContain(post.ID, everything);
var junk = await _client.Fetch($"/peasants/{persona.UserName}/anus?page=true&max_id=not-an-id");
Assert.True((int)junk.Status < 500, $"a junk max_id answered {(int)junk.Status}");
}
[Fact]
public async Task Followers_and_following_give_no_names_away()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "groupies");
var friend = await _host.Persona(await _host.SignUp(), "stalked");
var accepted = $"https://remote.example/users/fan{Guid.NewGuid():N}";
var pending = $"https://remote.example/users/hopeful{Guid.NewGuid():N}";
await DB.Default.SaveAsync(new[]
{
new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = accepted, InboxURL = accepted + "/inbox" },
new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = pending, InboxURL = pending + "/inbox", IsAccepted = false }
}, token);
await DB.Default.SaveAsync(new Following
{
AvatarId = persona.Id,
TargetAccountId = friend.Id,
TargetActorURI = friend.ActorUri(),
TargetIsLocal = true,
State = FollowState.Accepted
}, token);
var followers = await _client.Fetch($"/peasants/{persona.UserName}/groupies");
var following = await _client.Fetch($"/peasants/{persona.UserName}/stalking");
Assert.Equal(HttpStatusCode.OK, followers.Status);
Assert.Equal("OrderedCollection", followers.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue<string>());
Assert.Equal(1, followers.Json["totalItems"]!.GetValue<int>());
Assert.DoesNotContain(accepted, followers.Text);
Assert.DoesNotContain(pending, followers.Text);
Assert.Equal(HttpStatusCode.OK, following.Status);
Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue<string>());
Assert.Equal(0, following.Json["totalItems"]!.GetValue<int>());
Assert.DoesNotContain(friend.UserName, following.Text);
}
[Fact]
public async Task Trophies_are_the_public_pins_newest_first_and_tattoos_is_empty()
{
var token = TestContext.Current.CancellationToken;
var persona = await _host.Persona(await _host.SignUp(), "trophies");
var older = await _host.Publish(persona, "pinned first");
var newer = await _host.Publish(persona, "pinned second");
var followersOnly = await _host.Publish(persona, "pinned but private", PostVisibility.FollowersOnly);
var removed = await _host.Publish(persona, "pinned then deleted");
foreach (var post in new[] { older, followersOnly, removed, newer })
await DB.Default.SaveAsync(new Pin { AvatarId = persona.Id, PostId = post.ID }, token);
await _host.Remove(persona, removed);
var featured = await _client.Fetch($"/peasants/{persona.UserName}/trophies");
var tags = await _client.Fetch($"/peasants/{persona.UserName}/tattoos");
Assert.Equal(HttpStatusCode.OK, featured.Status);
Assert.Equal(persona.ActorUri() + "/trophies", featured.Json["id"]!.GetValue<string>());
Assert.Equal(2, featured.Json["totalItems"]!.GetValue<int>());
var notes = featured.Json["orderedItems"]!.AsArray();
Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue<string>()));
Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue<string>()));
Assert.DoesNotContain(followersOnly.ID, featured.Text);
Assert.DoesNotContain(removed.ID, featured.Text);
Assert.Equal(HttpStatusCode.OK, tags.Status);
Assert.Equal(persona.ActorUri() + "/tattoos", tags.Json["id"]!.GetValue<string>());
Assert.Equal(0, tags.Json["totalItems"]!.GetValue<int>());
Assert.Empty(tags.Json["orderedItems"]!.AsArray());
}
[Fact]
public async Task A_scribble_is_served_while_public_and_a_tombstone_once_deleted()
{
var persona = await _host.Persona(await _host.SignUp(), "scribble");
var other = await _host.Persona(await _host.SignUp(), "other");
var shown = await _host.Publish(persona, "hello fediverse");
var unlisted = await _host.Publish(persona, "hello quietly", PostVisibility.Unlisted);
var followersOnly = await _host.Publish(persona, "hello followers", PostVisibility.FollowersOnly);
var direct = await _host.Publish(persona, $"@{other.UserName} hello you", PostVisibility.Direct);
var located = await _host.PublishLocated(persona, "hello neighbours");
var deleted = await _host.Publish(persona, "goodbye");
await _host.Remove(persona, deleted);
string Path(Post post) => $"/peasants/{persona.UserName}/scribbles/{post.ID}";
var note = await _client.Fetch(Path(shown));
Assert.Equal(HttpStatusCode.OK, note.Status);
Assert.Equal(ActivityJson, note.MediaType);
Assert.Equal(shown.ObjectURI, note.Json["id"]!.GetValue<string>());
Assert.Equal("Note", note.Json["type"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue<string>());
Assert.NotNull(note.Json["@context"]);
Assert.Contains(ActivityPubRenderer.Public, Strings(note.Json["to"]));
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(Path(unlisted))).Status);
var browser = await _client.Fetch(Path(shown), Browser);
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
Assert.Equal($"{Base}/@{persona.UserName}/{shown.ID}", browser.Location);
foreach (var post in new[] { followersOnly, direct, located })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(post))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{other.UserName}/scribbles/{shown.ID}")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{persona.UserName}/scribbles/{Guid.NewGuid():N}"[..48])).Status);
var tombstone = await _client.Fetch(Path(deleted));
Assert.Equal(HttpStatusCode.Gone, tombstone.Status);
Assert.Equal(ActivityJson, tombstone.MediaType);
Assert.Equal("Tombstone", tombstone.Json["type"]!.GetValue<string>());
Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue<string>());
Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue<string>());
Assert.NotNull(tombstone.Json["deleted"]);
Assert.DoesNotContain("goodbye", tombstone.Text);
}
[Fact]
public async Task A_circle_post_is_served_only_to_a_member_or_its_instance_actor()
{
var owner = await _host.Persona(await _host.SignUp(), "circleowner");
var circle = await _host.Group(owner, community: false);
var member = new RemoteActor(_peer, "member");
var memberServer = new RemoteActor(_peer, "instance", type: "Application");
var neighbour = new RemoteActor(_peer, "neighbour");
var stranger = new RemoteActor(_peer, "stranger", _peer.B);
var strangerServer = new RemoteActor(_peer, "instance", _peer.B, "Application");
await circle.AddForeignMember(member.Id);
var post = await _host.Publish(owner, new StatusDraft { Text = "just us", PlainText = true, GroupId = circle.Id });
Assert.Equal(PostVisibility.Circle, post.Visibility);
var path = $"/peasants/{owner.UserName}/scribbles/{post.ID}";
var asMember = await _client.Fetch(member.SignedGet(path));
var asMemberServer = await _client.Fetch(memberServer.SignedGet(path));
Assert.Equal(HttpStatusCode.OK, asMember.Status);
Assert.Equal(post.ObjectURI, asMember.Json["id"]!.GetValue<string>());
Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"]));
Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text);
Assert.Equal(HttpStatusCode.OK, asMemberServer.Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status);
foreach (var outsider in new[] { neighbour, stranger, strangerServer })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{owner.UserName}/grunts/create-{post.ID}")).Status);
}
[Fact]
public async Task A_circles_followers_members_and_wardens_are_shown_only_to_its_members()
{
var token = TestContext.Current.CancellationToken;
var owner = await _host.Persona(await _host.SignUp(), "circlelists");
var circle = await _host.Group(owner, community: false);
var community = await _host.Group(owner, community: true);
var member = new RemoteActor(_peer, "member");
var stranger = new RemoteActor(_peer, "stranger", _peer.B);
await circle.AddForeignMember(member.Id);
foreach (var group in new[] { circle, community })
await DB.Default.SaveAsync(new Follower
{
LocalActorId = group.Id,
LocalActorKind = LocalActorKind.Group,
ActorURI = member.Id,
InboxURL = member.Id + "/inbox"
}, token);
foreach (var collection in new[] { "groupies", "flock", "wardens" })
{
var path = $"/peasants/{circle.UserName}/{collection}";
Assert.True((await _client.Fetch(path)).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered an unsigned GET");
Assert.True((await _client.Fetch(stranger.SignedGet(path))).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered a stranger");
var asMember = await _client.Fetch(member.SignedGet(path));
Assert.True(asMember.Status == HttpStatusCode.OK, $"the circle's {collection} refused a member");
Assert.Equal(circle.Uri + "/" + collection, asMember.Json["id"]!.GetValue<string>());
var open = await _client.Fetch($"/peasants/{community.UserName}/{collection}");
Assert.True(open.Status == HttpStatusCode.OK, $"the community's {collection} answered {(int)open.Status}");
}
Assert.Equal(1, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/groupies"))).Json["totalItems"]!.GetValue<int>());
Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue<int>());
var wardens = await _client.Fetch($"/peasants/{community.UserName}/wardens");
Assert.Equal(new[] { owner.ActorUri() }, Strings(wardens.Json["orderedItems"]));
Assert.Equal(community.Uri + "/wardens", (await _client.Fetch($"/peasants/{community.UserName}")).Json["attributedTo"]!.GetValue<string>());
Assert.Null((await _client.Fetch($"/peasants/{circle.UserName}")).Json["attributedTo"]);
}
[Fact]
public async Task Grunts_resolve_creates_and_boosts()
{
var persona = await _host.Persona(await _host.SignUp(), "grunts");
var friend = await _host.Persona(await _host.SignUp(), "boosted");
var post = await _host.Publish(persona, "a grunt");
var followersOnly = await _host.Publish(persona, "a private grunt", PostVisibility.FollowersOnly);
var original = await _host.Publish(friend, "boost me");
var reblog = await _host.Reblog(persona, original);
string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}";
var create = await _client.Fetch(Path($"create-{post.ID}"));
var announce = await _client.Fetch(Path($"announce-{original.ID}"));
Assert.Equal(HttpStatusCode.OK, create.Status);
Assert.Equal(ActivityJson, create.MediaType);
Assert.Equal("Create", create.Json["type"]!.GetValue<string>());
Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue<string>());
Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue<string>());
Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.OK, announce.Status);
Assert.Equal("Announce", announce.Json["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue<string>());
foreach (var unknown in new[] { $"create-{followersOnly.ID}", $"create-{Guid.NewGuid():N}"[..31], $"announce-{post.ID}", $"like-{post.ID}", "x" })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(unknown))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{original.ID}")).Status);
}
[Fact]
public async Task A_parrot_licence_is_served_by_its_author_until_revoked()
{
var token = TestContext.Current.CancellationToken;
var quoted = await _host.Persona(await _host.SignUp(), "quoted");
var quoter = await _host.Persona(await _host.SignUp(), "quoter");
var original = await _host.Publish(quoted, "quote me");
var quoting = await _host.Publish(quoter, new StatusDraft { Text = "well said", PlainText = true, QuotedStatusId = original.ID });
Assert.Equal(QuoteState.Accepted, quoting.QuoteState);
var licenceUri = quoting.QuoteAuthorizationURI;
Assert.StartsWith(quoted.ActorUri() + "/parrot-licences/", licenceUri);
var licenceId = licenceUri.Split('/')[^1];
var licence = await _client.Fetch(PathOf(licenceUri));
var wrongAuthor = await _client.Fetch($"/peasants/{quoter.UserName}/parrot-licences/{licenceId}");
var unknown = await _client.Fetch($"/peasants/{quoted.UserName}/parrot-licences/{Guid.NewGuid():N}"[..48]);
Assert.Equal(HttpStatusCode.OK, licence.Status);
Assert.Equal(ActivityJson, licence.MediaType);
Assert.Equal("QuoteAuthorization", licence.Json["type"]!.GetValue<string>());
Assert.Equal(licenceUri, licence.Json["id"]!.GetValue<string>());
Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue<string>());
Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue<string>());
Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status);
Assert.Equal(HttpStatusCode.NotFound, unknown.Status);
Assert.True(await _host.Get<IQuoteService>().RevokeLicence(original, quoting, token));
var revoked = await _client.Fetch(PathOf(licenceUri));
Assert.Equal(HttpStatusCode.Gone, revoked.Status);
Assert.Equal(ActivityJson, revoked.MediaType);
Assert.Equal("Tombstone", revoked.Json["type"]!.GetValue<string>());
Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue<string>());
Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue<string>());
}
[Fact]
public async Task Secure_mode_refuses_unsigned_gets_except_for_the_instance_actor()
{
var secure = await SecureModeHost.Shared();
using var client = secure.Client();
var persona = await _host.Persona(await _host.SignUp(), "secure");
var post = await _host.Publish(persona, "only for the signed");
var reader = new RemoteActor(_peer, "reader");
var paths = new[]
{
$"/peasants/{persona.UserName}",
$"/peasants/{persona.UserName}/anus",
$"/peasants/{persona.UserName}/anus?page=true",
$"/peasants/{persona.UserName}/groupies",
$"/peasants/{persona.UserName}/stalking",
$"/peasants/{persona.UserName}/trophies",
$"/peasants/{persona.UserName}/tattoos",
$"/peasants/{persona.UserName}/scribbles/{post.ID}",
$"/peasants/{persona.UserName}/grunts/create-{post.ID}",
$"/peasants/nobody{Guid.NewGuid():N}"[..28]
};
foreach (var path in paths)
Assert.True((await client.Fetch(path)).Status == HttpStatusCode.Unauthorized, $"{path} answered an unsigned GET");
var instance = await client.Fetch("/peasants/privapub");
Assert.Equal(HttpStatusCode.OK, instance.Status);
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
}
}
}
+208
View File
@@ -0,0 +1,208 @@
using MongoDB.Entities;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed class InboxRouteTests : IAsyncLifetime
{
PrivaPubHost _host;
HttpClient _client;
Peer _peer;
Persona _persona;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_client = _host.Client();
_peer = await Peer.Start();
_persona = await _host.Persona(await _host.SignUp(), "inbox");
}
public async ValueTask DisposeAsync()
{
_client?.Dispose();
if (_peer != default)
await _peer.DisposeAsync();
}
public static TheoryData<string> Inboxes() => new() { "personal", "personal-shared", "shared" };
string Route(string inbox) => inbox switch
{
"personal" => $"/peasants/{_persona.UserName}/mouth",
"personal-shared" => $"/peasants/{_persona.UserName}/human-centipede",
_ => "/human-centipede"
};
JsonObject Direct(RemoteActor sender)
{
var noteId = $"{new Uri(sender.Id).GetLeftPart(UriPartial.Authority)}/notes/{Guid.NewGuid():N}";
var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{_persona.UserName}");
return new JsonObject
{
["id"] = noteId + "/activity",
["type"] = "Create",
["actor"] = sender.Id,
["to"] = to.DeepClone(),
["object"] = new JsonObject
{
["id"] = noteId,
["type"] = "Note",
["attributedTo"] = sender.Id,
["to"] = to.DeepClone(),
["content"] = "<p>knock knock</p>",
["published"] = DateTime.UtcNow.ToString("O")
}
};
}
static string Id(JsonObject activity) => activity["id"]!.GetValue<string>();
async Task<bool> Queued(JsonObject activity) =>
await DB.Default.Find<Job>().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken);
async Task<HttpResponseMessage> Send(HttpRequestMessage request) => await _client.SendAsync(request, TestContext.Current.CancellationToken);
[Theory]
[MemberData(nameof(Inboxes))]
public async Task A_signed_delivery_is_accepted_and_queued(string inbox)
{
var sender = new RemoteActor(_peer, "sender");
var activity = Direct(sender);
var response = await Send(sender.SignedPost(Route(inbox), activity));
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Equal(1, await _host.RunInbox(Id(activity), TestContext.Current.CancellationToken));
}
[Theory]
[MemberData(nameof(Inboxes))]
public async Task Junk_is_400_and_an_unsigned_activity_401(string inbox)
{
var sender = new RemoteActor(_peer, "unsigned");
foreach (var junk in new[] { "not json", "[1,2,3]", "\"Create\"", "{}", "{\"type\":\"Create\"}" })
{
using var request = new HttpRequestMessage(HttpMethod.Post, Route(inbox)) { Content = new StringContent(junk, Encoding.UTF8, "application/activity+json") };
Assert.Equal(HttpStatusCode.BadRequest, (await Send(request)).StatusCode);
}
var activity = Direct(sender);
using var unsigned = new HttpRequestMessage(HttpMethod.Post, Route(inbox))
{
Content = new StringContent(activity.ToJsonString(), Encoding.UTF8, "application/activity+json")
};
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(unsigned)).StatusCode);
Assert.False(await Queued(activity));
}
[Fact]
public async Task An_unknown_persona_has_no_mouth()
{
var sender = new RemoteActor(_peer, "lost");
var activity = Direct(sender);
var response = await Send(sender.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..28] + "/mouth", activity));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.False(await Queued(activity));
}
[Theory]
[MemberData(nameof(Inboxes))]
public async Task A_tampered_stale_or_misdirected_signature_is_401(string inbox)
{
var sender = new RemoteActor(_peer, "forger");
var path = Route(inbox);
var tampered = Direct(sender);
var badDigest = sender.SignedPost(path, tampered);
badDigest.Headers.Remove("Digest");
badDigest.Headers.TryAddWithoutValidation("Digest", "SHA-256=" + Convert.ToBase64String(new byte[32]));
var stale = Direct(sender);
var twoHoursAgo = DateTimeOffset.UtcNow.AddHours(-2).ToString("r", CultureInfo.InvariantCulture);
var elsewhere = Direct(sender);
var otherBody = Direct(sender);
var swapped = sender.SignedPost(path, Direct(sender));
swapped.Content = new StringContent(otherBody.ToJsonString(), Encoding.UTF8, "application/activity+json");
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(badDigest)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, stale, date: twoHoursAgo))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(sender.SignedPost(path, elsewhere, host: "elsewhere.example"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await Send(swapped)).StatusCode);
foreach (var activity in new[] { tampered, stale, elsewhere, otherBody })
Assert.False(await Queued(activity));
}
[Fact]
public async Task Ld_json_with_the_activitystreams_profile_is_accepted()
{
var sender = new RemoteActor(_peer, "ldjson");
var activity = Direct(sender);
var request = sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity);
request.Content!.Headers.Remove("Content-Type");
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");
var response = await Send(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.True(await Queued(activity));
}
[Fact]
public async Task An_unreachable_signing_key_asks_the_sender_to_retry()
{
var sender = new RemoteActor(_peer, "flaky");
_peer.Answer(new Uri(sender.Id).AbsolutePath, 503);
var activity = Direct(sender);
var response = await Send(sender.SignedPost($"/peasants/{_persona.UserName}/mouth", activity));
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.True(response.Headers.RetryAfter?.Delta is { TotalSeconds: > 0 }, "no Retry-After");
Assert.False(await Queued(activity));
}
[Fact]
public async Task Unsigned_posts_from_one_address_are_limited()
{
var token = TestContext.Current.CancellationToken;
using var client = _host.Client();
client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader);
client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, $"fd7e:{Random.Shared.Next(0x10000):x}:{Random.Shared.Next(0x10000):x}::1");
var body = Direct(new RemoteActor(_peer, "flood")).ToJsonString();
async Task<HttpStatusCode> Post()
{
using var content = new StringContent(body, Encoding.UTF8, "application/activity+json");
using var response = await client.PostAsync("/human-centipede", content, token);
return response.StatusCode;
}
var clock = System.Diagnostics.Stopwatch.StartNew();
var first = new List<HttpStatusCode>();
for (var i = 0; i < 300; i++)
first.Add(await Post());
var limited = await Post();
// The bucket refills 50 every ten seconds: a slow machine may have earned a few more before the limit bites.
for (var refilled = (int)((clock.Elapsed.TotalSeconds + 1) / 10) * 50; limited != HttpStatusCode.TooManyRequests && refilled > 0; refilled--)
limited = await Post();
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, first);
Assert.All(first, status => Assert.Equal(HttpStatusCode.Unauthorized, status));
Assert.Equal(HttpStatusCode.TooManyRequests, limited);
var polite = new RemoteActor(_peer, "polite");
using var signed = await client.SendAsync(polite.SignedPost("/human-centipede", Direct(polite)), token);
Assert.Equal(HttpStatusCode.Accepted, signed.StatusCode);
}
}
}
@@ -0,0 +1,261 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Entities;
using PrivaPub.ClientModels.Social;
using PrivaPub.Domain.Social;
using PrivaPub.Domain.Statuses;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using static PrivaPub.Tests.Support.Host.FederationHelpers;
namespace PrivaPub.Tests.Http
{
[Trait("Category", "Integration")]
public sealed partial class PersonaSeparationHttpTests : IAsyncLifetime
{
const string Base = PrivaPubHost.Base;
const int MaxDocuments = 300;
PrivaPubHost _host;
Root _root;
Persona _alpha;
Persona _bravo;
Persona _charlie;
LocalGroup _community;
string _tag;
Post _shown;
Post _poll;
List<Post> _alphaPosts;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
var token = TestContext.Current.CancellationToken;
_host = await PrivaPubHost.Shared();
_root = await _host.SignUp("sep");
_alpha = await _host.Persona(_root, "sepalpha");
_bravo = await _host.Persona(_root, "sepbravo");
_charlie = await _host.Persona(await _host.SignUp(), "sepcharlie");
_community = await _host.Group(_bravo, community: true, "sepcommunity");
_tag = $"septag{Guid.NewGuid():N}"[..14];
await _host.Publish(_bravo, "the sibling's own words", PostVisibility.FollowersOnly);
var follows = _host.Get<IFollowService>();
Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _bravo.Id, Target = _charlie.UserName }, token)).IsValid);
_shown = await _host.Publish(_alpha, $"hello from alpha #{_tag}");
_poll = await _host.Publish(_alpha, new StatusDraft
{
Text = "which one?",
PlainText = true,
Poll = new PollDraft(new[] { "this", "that" }, 3600, false, false)
});
var charlieSays = await _host.Publish(_charlie, "worth sharing");
_alphaPosts = new List<Post>
{
_shown,
_poll,
await _host.Publish(_alpha, "quietly", PostVisibility.Unlisted),
await _host.Publish(_alpha, "for my followers", PostVisibility.FollowersOnly),
await _host.Publish(_alpha, $"@{_charlie.UserName} between us", PostVisibility.Direct),
await _host.PublishLocated(_alpha, "around here"),
await _host.Publish(_alpha, new StatusDraft { Text = "quoting charlie", PlainText = true, QuotedStatusId = charlieSays.ID }),
await _host.Reblog(_alpha, charlieSays)
};
await DB.Default.SaveAsync(new Pin { AvatarId = _alpha.Id, PostId = _shown.ID }, token);
Assert.True((await follows.Follow(_root.Id, new FollowForm { AvatarId = _alpha.Id, Target = _charlie.UserName }, token)).IsValid);
Assert.True((await follows.Follow(_charlie.Root.Id, new FollowForm { AvatarId = _charlie.Id, Target = _alpha.UserName }, token)).IsValid);
Assert.True((await _host.Get<IStatusService>().Favourite(await _host.Actor(_charlie), _shown.ID, true, token)).Ok);
await _host.Publish(_charlie, new StatusDraft { Text = $"@{_alpha.UserName} nice", PlainText = true, InReplyTo = _shown.ID });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
string[] Forbidden() => new[] { _bravo.Id, _bravo.UserName, _root.Id, _root.UserName, _community.Id, _community.UserName };
[Fact]
public async Task Nothing_the_api_tells_a_persona_names_its_sibling_or_its_login()
{
var bearer = await _host.MastodonToken(_alpha);
using var client = _host.As(bearer);
var notifications = await client.Fetch("/api/v1/notifications", "application/json");
Assert.Equal(HttpStatusCode.OK, notifications.Status);
var notificationIds = JsonNode.Parse(notifications.Text)!.AsArray().Select(n => n!["id"]!.GetValue<string>()).ToList();
Assert.NotEmpty(notificationIds);
var paths = Routes().Select(Fill).Concat(new[]
{
$"/api/v2/search?q={_alpha.UserName}",
$"/api/v2/search?q={Uri.EscapeDataString($"@{_alpha.UserName}@{PrivaPubHost.Host}")}",
$"/api/v2/search?q={Uri.EscapeDataString(_alpha.ActorUri())}",
$"/api/v2/search?q={Uri.EscapeDataString(_shown.ObjectURI)}",
$"/api/v2/search?q=%23{_tag}",
$"/api/v1/accounts/search?q={_alpha.UserName}",
$"/api/v1/accounts/lookup?acct={_alpha.UserName}",
$"/api/v1/accounts/relationships?id[]={_alpha.Id}&id[]={_charlie.Id}",
$"/api/v1/accounts/familiar_followers?id[]={_charlie.Id}",
$"/api/v1/accounts/{_alpha.Id}/statuses?pinned=true",
$"/api/v1/accounts/{_alpha.Id}/statuses?exclude_replies=true&limit=40",
$"/api/v1/statuses?{string.Join("&", _alphaPosts.Select(p => "id[]=" + p.ID))}",
$"/api/v1/statuses/{_poll.ID}",
$"/api/v1/polls/{_poll.ID}",
$"/api/v1/notifications/{notificationIds[0]}",
"/api/v1/timelines/home?limit=40",
"/api/v1/timelines/public?local=true&limit=40"
}).Concat(_alphaPosts.Select(p => $"/api/v1/statuses/{p.ID}/context"))
.Distinct()
.ToList();
var answered = 0;
var failures = new List<string>();
foreach (var path in paths)
{
var fetched = await client.Fetch(path, "application/json");
if ((int)fetched.Status >= 500)
failures.Add($"{path} answered {(int)fetched.Status}");
if (fetched.Status == HttpStatusCode.OK)
answered++;
foreach (var secret in Forbidden())
if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase) || fetched.Response.Headers.ToString().Contains(secret, StringComparison.OrdinalIgnoreCase))
failures.Add($"{path} names {secret}: {Short(fetched.Text)}");
}
Assert.True(failures.Count == 0, string.Join("\n", failures));
Assert.True(answered >= 40, $"only {answered} of {paths.Count} answers were 200");
var me = await client.Fetch("/api/v1/accounts/verify_credentials", "application/json");
Assert.Contains(_alpha.Id, me.Text);
Assert.Contains(_shown.ID, (await client.Fetch("/api/v1/timelines/home?limit=40", "application/json")).Text);
}
[Fact]
public async Task Nothing_published_about_a_persona_names_its_sibling_or_its_login()
{
using var client = _host.Client();
var seeds = new[]
{
$"/peasants/{_alpha.UserName}",
$"/users/{_alpha.UserName}",
$"/peasants/{_alpha.UserName}/anus?page=true",
$"/peasants/{_alpha.UserName}/scribbles/{_shown.ID}",
$"/peasants/{_alpha.UserName}/grunts/create-{_shown.ID}",
$"/.well-known/webfinger?resource=acct:{_alpha.UserName}@{PrivaPubHost.Host}",
"/.well-known/nodeinfo",
$"/@{_alpha.UserName}",
$"/@{_alpha.UserName}/{_shown.ID}"
};
var queue = new Queue<string>(seeds);
var seen = new HashSet<string>(seeds);
var failures = new List<string>();
var fetchedOk = 0;
while (queue.Count > 0 && seen.Count <= MaxDocuments)
{
var path = queue.Dequeue();
var html = path.StartsWith("/@", StringComparison.Ordinal);
var fetched = await client.Fetch(path, html ? Browser : path.StartsWith("/peasants/", StringComparison.Ordinal) ? ActivityJson : "application/json");
if ((int)fetched.Status >= 500)
failures.Add($"{path} answered {(int)fetched.Status}");
if (fetched.Status == HttpStatusCode.OK)
fetchedOk++;
foreach (var secret in Forbidden())
if (fetched.Text.Contains(secret, StringComparison.OrdinalIgnoreCase))
failures.Add($"{path} names {secret}: {Short(fetched.Text)}");
var links = Links(fetched.Text).ToList();
if (fetched.Location != default)
links.Add(fetched.Location.StartsWith('/') ? Base + fetched.Location : fetched.Location);
foreach (var link in links)
{
if (!link.StartsWith(Base + "/", StringComparison.Ordinal))
continue;
var next = PathOf(link.Split('#')[0]);
if (Followed(next) && seen.Add(next))
queue.Enqueue(next);
}
}
Assert.True(failures.Count == 0, string.Join("\n", failures));
Assert.Contains($"/peasants/{_alpha.UserName}/groupies", seen);
Assert.Contains($"/peasants/{_alpha.UserName}/trophies", seen);
Assert.Contains("/nodeinfo/2.1", seen);
Assert.True(fetchedOk >= 15, $"only {fetchedOk} of {seen.Count} documents were served");
}
bool Followed(string path) =>
path.StartsWith($"/peasants/{_alpha.UserName}", StringComparison.Ordinal)
|| path.StartsWith($"/@{_alpha.UserName}", StringComparison.Ordinal)
|| path.StartsWith("/.well-known/", StringComparison.Ordinal)
|| path.StartsWith("/nodeinfo/", StringComparison.Ordinal);
static IEnumerable<string> Links(string text)
{
JsonNode json = default;
try
{
json = JsonNode.Parse(text);
}
catch (System.Text.Json.JsonException)
{
}
if (json != default)
return Strings(json);
return Href().Matches(text).Select(m => WebUtility.HtmlDecode(m.Groups[1].Value));
}
IEnumerable<string> Routes()
{
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
foreach (var endpoint in endpoints)
{
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/');
if (template.StartsWith("/api/", StringComparison.Ordinal) && methods is { } verbs && verbs.Contains("GET"))
yield return template;
}
}
string Fill(string template)
{
var builder = new StringBuilder();
foreach (var segment in RoutePatternFactory.Parse(template).PathSegments)
{
builder.Append('/');
foreach (var part in segment.Parts)
builder.Append(part switch
{
RoutePatternLiteralPart literal => literal.Content,
RoutePatternSeparatorPart separator => separator.Content,
RoutePatternParameterPart parameter => Value(template, parameter.Name),
_ => string.Empty
});
}
return builder.ToString();
}
string Value(string template, string parameter) => parameter switch
{
"hashtag" => _tag,
"host" => PrivaPubHost.Host,
_ when template.StartsWith("/api/v1/statuses/", StringComparison.Ordinal)
|| template.Contains("/statuses/{id}", StringComparison.Ordinal) => _shown.ID,
_ when template.StartsWith("/api/v1/polls/", StringComparison.Ordinal) => _poll.ID,
_ => _alpha.Id
};
static string Short(string text) => text.Length > 400 ? text[..400] + "…" : text;
[GeneratedRegex("(?:href|src)=\"([^\"]+)\"")]
private static partial Regex Href();
}
}
+206
View File
@@ -0,0 +1,206 @@
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.Group(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.Group(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.Group(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());
}
}
}
@@ -0,0 +1,141 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Support.Host
{
public sealed record Fetched(HttpStatusCode Status, string MediaType, string Text, HttpResponseMessage Response)
{
public JsonObject Json => JsonNode.Parse(Text)!.AsObject();
public string Location => Response.Headers.Location?.OriginalString;
}
public sealed record LocalGroup(string Id, string UserName)
{
public string Uri => $"{PrivaPubHost.Base}/peasants/{UserName}";
}
public static class FederationHelpers
{
public const string ActivityJson = "application/activity+json";
public const string LdJson = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
public const string Browser = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
public static string ActorUri(this Persona persona) => $"{PrivaPubHost.Base}/peasants/{persona.UserName}";
public static async Task<Fetched> Fetch(this HttpClient client, string path, string accept = ActivityJson)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
if (accept != default)
request.Headers.TryAddWithoutValidation("Accept", accept);
return await client.Fetch(request);
}
public static async Task<Fetched> Fetch(this HttpClient client, HttpRequestMessage request)
{
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
return new Fetched(response.StatusCode, response.Content.Headers.ContentType?.MediaType, text, response);
}
public static async Task<LocalActor> Actor(this PrivaPubHost host, Persona persona) =>
await host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
public static async Task<Post> Publish(this PrivaPubHost host, Persona persona, StatusDraft draft)
{
var outcome = await host.Get<IStatusService>().Publish(await host.Actor(persona), draft, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
public static Task<Post> Publish(this PrivaPubHost host, Persona persona, string text, PostVisibility visibility = PostVisibility.Public) =>
host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Visibility = visibility });
public static Task<Post> PublishLocated(this PrivaPubHost host, Persona persona, string text) =>
host.Publish(persona, new StatusDraft { Text = text, PlainText = true, Latitude = 45.46, Longitude = 9.19, RangeKm = 5 });
public static async Task Remove(this PrivaPubHost host, Persona persona, Post post)
{
var outcome = await host.Get<IStatusService>().Remove(await host.Actor(persona), post.ID, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
}
public static async Task<Post> Reblog(this PrivaPubHost host, Persona persona, Post original)
{
var outcome = await host.Get<IStatusService>().Reblog(await host.Actor(persona), original.ID, true, PostVisibility.Public, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
public static async Task<Post> FederatedCopy(this PrivaPubHost host, Persona persona)
{
var post = new Post
{
GroupUserId = persona.Id,
AuthorAccountId = persona.Id,
IsFederatedCopy = true,
Visibility = PostVisibility.Public,
ObjectURI = $"https://remote.example/notes/{Guid.NewGuid():N}",
ActorURI = "https://remote.example/users/someone",
ContentHtml = "<p>a copy that is not ours to serve</p>"
};
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
public static async Task<LocalGroup> Group(this PrivaPubHost host, Persona owner, bool community, string name = default)
{
var userName = $"{name ?? (community ? "community" : "circle")}{Guid.NewGuid():N}"[..20];
using var client = host.As(owner.Root.Jwt);
var response = await client.PostAsJsonAsync("/clientapi/group/insert",
new { avatarId = owner.Id, userName, name = userName, description = "testing", isCommunity = community, postingPolicy = community ? "anyone" : default },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var group = (await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken))!;
return new LocalGroup(group["id"]!.GetValue<string>(), userName);
}
public static async Task AddForeignMember(this LocalGroup group, string actorUri) =>
await DB.Default.Update<GroupEntity>().MatchID(group.Id)
.Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = actorUri, IsForeign = true }))
.ExecuteAsync(TestContext.Current.CancellationToken);
public static IEnumerable<JsonNode> Nodes(JsonNode node)
{
if (node == default)
yield break;
yield return node;
switch (node)
{
case JsonObject obj:
foreach (var (_, value) in obj)
foreach (var inner in Nodes(value))
yield return inner;
break;
case JsonArray array:
foreach (var item in array)
foreach (var inner in Nodes(item))
yield return inner;
break;
}
}
public static IEnumerable<string> Keys(JsonNode node) =>
Nodes(node).OfType<JsonObject>().SelectMany(o => o.Select(p => p.Key));
public static IEnumerable<string> Strings(JsonNode node) =>
Nodes(node).OfType<JsonValue>().Where(v => v.TryGetValue<string>(out _)).Select(v => v.GetValue<string>());
public static string PathOf(string url) => new Uri(url).PathAndQuery;
}
}
@@ -110,6 +110,9 @@ namespace PrivaPub.Federation.Controllers
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true })
return NotFound();
if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token),
await _fetches.Requester(Request, token)))
return NotFound();
var count = await DB.Default.CountAsync<Follower>(
f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token);
return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default));
@@ -34,9 +34,13 @@ namespace PrivaPub.Federation.Controllers
return BadRequest();
LocalActor actor;
if (resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase))
// Mastodon, GoToSocial and Pleroma also take a bare user@domain or @user@domain, so we do too.
var acct = resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase) ? resource[5..]
: !resource.Contains("://", StringComparison.Ordinal) && resource.Contains('@') ? resource
: default;
if (acct != default)
{
var parts = resource[5..].TrimStart('@').Split('@');
var parts = acct.TrimStart('@').Split('@');
var domain = new Uri(_localActors.BaseAddress).Authority;
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
return NotFound();
+2 -1
View File
@@ -26,7 +26,8 @@ Priorities, used throughout:
- Served documents are `application/activity+json; charset=utf-8`, which is on Lemmy's exact allowlist and is
accepted by GoToSocial and Misskey.
- WebFinger answers `application/jrd+json`, and answers for the actor URL as well as `acct:`. Akkoma and
Iceshrimp.NET require both.
Iceshrimp.NET require both. A bare `user@domain` or `@user@domain` is taken as an `acct:` too, as Mastodon,
GoToSocial and Pleroma take it (2026-10-03).
- **SSRF guard:**
- Its IPv6 rule is an allowlist (`2000::/3` only), so IPv4-compatible `::a.b.c.d`, NAT64 and the other tricks behind
Mastodon's GHSA-vwhj (Jul 2026) are already refused.