From 2cfea7b60cce10328306927687ed081570aca3d1 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 11:43:32 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- PrivaPub.Tests/Http/FederationGetTests.cs | 468 ++++++++++++++++++ PrivaPub.Tests/Http/InboxRouteTests.cs | 208 ++++++++ .../Http/PersonaSeparationHttpTests.cs | 261 ++++++++++ PrivaPub.Tests/Http/WellKnownTests.cs | 206 ++++++++ .../Support/Host/FederationHelpers.cs | 141 ++++++ .../Controllers/PeasantsController.cs | 3 + .../Controllers/WellKnownController.cs | 8 +- docs/INTEROP.md | 3 +- 8 files changed, 1295 insertions(+), 3 deletions(-) create mode 100644 PrivaPub.Tests/Http/FederationGetTests.cs create mode 100644 PrivaPub.Tests/Http/InboxRouteTests.cs create mode 100644 PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs create mode 100644 PrivaPub.Tests/Http/WellKnownTests.cs create mode 100644 PrivaPub.Tests/Support/Host/FederationHelpers.cs diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs new file mode 100644 index 0000000..74a46d0 --- /dev/null +++ b/PrivaPub.Tests/Http/FederationGetTests.cs @@ -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().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()); + Assert.Equal("Person", actor["type"]!.GetValue()); + Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue()); + Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue()); + Assert.Equal(id + "/anus", actor["outbox"]!.GetValue()); + Assert.Equal(id + "/groupies", actor["followers"]!.GetValue()); + Assert.Equal(id + "/stalking", actor["following"]!.GetValue()); + Assert.Equal(id + "/trophies", actor["featured"]!.GetValue()); + Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue()); + Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue()); + Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue()); + + var key = actor["publicKey"]!; + Assert.Equal(id + "#main-key", key["id"]!.GetValue()); + Assert.Equal(id, key["owner"]!.GetValue()); + var pem = key["publicKeyPem"]!.GetValue(); + 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()); + Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue()); + 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()); + 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()); + } + + [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(); + 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 + { + 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()); + Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue()); + Assert.Equal(22, outbox.Json["totalItems"]!.GetValue()); + Assert.Null(outbox.Json["orderedItems"]); + var first = outbox.Json["first"]!.GetValue(); + 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()); + Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue()); + var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList(); + Assert.Equal(20, items1.Count); + var next = page1.Json["next"]!.GetValue(); + 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()); + 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()).Distinct().Count()); + var announce = items[0]; + Assert.Equal("Announce", announce["type"]!.GetValue()); + Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue()); + Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue()); + Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue()); + Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue()); + + var creates = items.Skip(1).ToList(); + Assert.All(creates, c => + { + Assert.Equal("Create", c["type"]!.GetValue()); + Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue()); + Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue()); + }); + Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue())); + 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()); + Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue()); + Assert.Equal(1, followers.Json["totalItems"]!.GetValue()); + 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()); + Assert.Equal(0, following.Json["totalItems"]!.GetValue()); + 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()); + Assert.Equal(2, featured.Json["totalItems"]!.GetValue()); + var notes = featured.Json["orderedItems"]!.AsArray(); + Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue())); + Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue())); + 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()); + Assert.Equal(0, tags.Json["totalItems"]!.GetValue()); + 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()); + Assert.Equal("Note", note.Json["type"]!.GetValue()); + Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue()); + 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()); + Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue()); + Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue()); + 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()); + 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()); + 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()); + Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue()); + 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()); + 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()); + Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue()); + Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue()); + Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue()); + Assert.Equal(HttpStatusCode.OK, announce.Status); + Assert.Equal("Announce", announce.Json["type"]!.GetValue()); + Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue()); + Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue()); + + 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()); + Assert.Equal(licenceUri, licence.Json["id"]!.GetValue()); + Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue()); + Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue()); + Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status); + Assert.Equal(HttpStatusCode.NotFound, unknown.Status); + + Assert.True(await _host.Get().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()); + Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue()); + Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue()); + } + + [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()); + foreach (var path in paths[..^1]) + Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); + } + } +} diff --git a/PrivaPub.Tests/Http/InboxRouteTests.cs b/PrivaPub.Tests/Http/InboxRouteTests.cs new file mode 100644 index 0000000..10f1322 --- /dev/null +++ b/PrivaPub.Tests/Http/InboxRouteTests.cs @@ -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 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"] = "

knock knock

", + ["published"] = DateTime.UtcNow.ToString("O") + } + }; + } + + static string Id(JsonObject activity) => activity["id"]!.GetValue(); + + async Task Queued(JsonObject activity) => + await DB.Default.Find().Match(j => j.DedupeKey == "inbox|" + Id(activity)).ExecuteAnyAsync(TestContext.Current.CancellationToken); + + async Task 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 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(); + 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); + } + } +} diff --git a/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs b/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs new file mode 100644 index 0000000..255ca43 --- /dev/null +++ b/PrivaPub.Tests/Http/PersonaSeparationHttpTests.cs @@ -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 _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(); + 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 + { + _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().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()).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(); + 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(seeds); + var seen = new HashSet(seeds); + var failures = new List(); + 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 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 Routes() + { + var endpoints = _host.Services.GetRequiredService().Endpoints.OfType(); + foreach (var endpoint in endpoints) + { + var methods = endpoint.Metadata.GetMetadata()?.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(); + } +} diff --git a/PrivaPub.Tests/Http/WellKnownTests.cs b/PrivaPub.Tests/Http/WellKnownTests.cs new file mode 100644 index 0000000..fcadf20 --- /dev/null +++ b/PrivaPub.Tests/Http/WellKnownTests.cs @@ -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 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() == rel)!["href"]!.GetValue(); + + [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()); + 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() == "self")!["type"]!.GetValue()); + 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()); + 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()); + 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(), l => l!["href"]!.GetValue()); + 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()); + Assert.Equal("privapub", document["software"]!["name"]!.GetValue()); + Assert.Equal(version == "2.1", document["software"]!["repository"] != default); + Assert.Equal(new[] { "activitypub" }, Strings(document["protocols"])); + Assert.True(document["usage"]!["users"]!["total"]!.GetValue() >= 1); + Assert.True(document["usage"]!["localPosts"]!.GetValue() >= 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 LocalPosts() + { + using var client = _host.Client(); + return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue(); + } + + [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()); + } + } +} diff --git a/PrivaPub.Tests/Support/Host/FederationHelpers.cs b/PrivaPub.Tests/Support/Host/FederationHelpers.cs new file mode 100644 index 0000000..8a5fe82 --- /dev/null +++ b/PrivaPub.Tests/Support/Host/FederationHelpers.cs @@ -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 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 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 Actor(this PrivaPubHost host, Persona persona) => + await host.Get().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken); + + public static async Task Publish(this PrivaPubHost host, Persona persona, StatusDraft draft) + { + var outcome = await host.Get().Publish(await host.Actor(persona), draft, TestContext.Current.CancellationToken); + Assert.True(outcome.Ok, outcome.Error); + return outcome.Post; + } + + public static Task 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 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().Remove(await host.Actor(persona), post.ID, TestContext.Current.CancellationToken); + Assert.True(outcome.Ok, outcome.Error); + } + + public static async Task Reblog(this PrivaPubHost host, Persona persona, Post original) + { + var outcome = await host.Get().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 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 = "

a copy that is not ours to serve

" + }; + await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken); + return post; + } + + public static async Task 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(TestContext.Current.CancellationToken))!; + return new LocalGroup(group["id"]!.GetValue(), userName); + } + + public static async Task AddForeignMember(this LocalGroup group, string actorUri) => + await DB.Default.Update().MatchID(group.Id) + .Modify(b => b.Push(g => g.Members, new GroupMember { AvatarId = actorUri, IsForeign = true })) + .ExecuteAsync(TestContext.Current.CancellationToken); + + public static IEnumerable 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 Keys(JsonNode node) => + Nodes(node).OfType().SelectMany(o => o.Select(p => p.Key)); + + public static IEnumerable Strings(JsonNode node) => + Nodes(node).OfType().Where(v => v.TryGetValue(out _)).Select(v => v.GetValue()); + + public static string PathOf(string url) => new Uri(url).PathAndQuery; + } +} diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index b2b6767..430ed61 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -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( f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted, token); return Activity(ActivityPubRenderer.OrderedCollection(local.Followers, (int)count, default)); diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index b877a8d..e269589 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -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(); diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 1af708e..37c06d8 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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.