Files
SocialPub/PrivaPub.Tests/Web/PublicPagesTests.cs
T
thepraandClaude Opus 5.5 2645dea26f T8: inbox gaps, jobs, migrations and pages; a deleted remote account's posts are hidden
Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
  post whose ActorURI is the actor (one update-many), besides dropping its follows and
  timeline rows as before. RemotePosts.Build sets it on a post stored later for an
  account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
  CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
  bookmarks, favourites, polls, reactions, search); provenance, account statuses,
  home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
  home and post/DM lists, a community's outbox and our Announces filter on IsShown or
  IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.

Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
  gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
  seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
  and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
  object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
  Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
  FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
  PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
  the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
  DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
  a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
  OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
  Referrer-Policy and nosniff), circle 404, community page, the instance actor,
  ActivityPub redirects, and markup escaped in posts, titles and bios.

Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
  Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
  the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.

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

223 lines
10 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using MongoDB.Entities;
using PrivaPub.Domain.Statuses;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Tests.Web
{
[Trait("Category", "Integration")]
public sealed class PublicPagesTests : IAsyncLifetime
{
const string Csp = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
async Task<LocalActor> Author(string name = "author")
{
var persona = await _host.Persona(await _host.SignUp(), name);
return await _host.Get<ILocalActorService>().FindById(LocalActorKind.Person, persona.Id, TestContext.Current.CancellationToken);
}
async Task<Post> Publish(LocalActor author, string text, PostVisibility visibility = PostVisibility.Public, string groupId = default)
{
using var scope = _host.Services.CreateScope();
var outcome = await scope.ServiceProvider.GetRequiredService<IStatusService>()
.Publish(author, new StatusDraft { Text = text, Visibility = visibility, GroupId = groupId }, TestContext.Current.CancellationToken);
Assert.True(outcome.Ok, outcome.Error);
return outcome.Post;
}
async Task<Post> Seeded(LocalActor author, string text, PostVisibility visibility)
{
var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, ActorURI = author.Uri, Text = text, Visibility = visibility };
post.ID = (string)post.GenerateNewID();
post.ObjectURI = author.PostUri(post.ID);
await DB.Default.SaveAsync(post, TestContext.Current.CancellationToken);
return post;
}
async Task<(HttpResponseMessage Response, string Body)> Page(string path, string accept = "text/html")
{
using var client = _host.Client();
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Accept.ParseAdd(accept);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
return (response, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
static void Hardened(HttpResponseMessage response)
{
Assert.Equal(Csp, Assert.Single(response.Headers.GetValues("Content-Security-Policy")));
Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy")));
Assert.Equal("nosniff", Assert.Single(response.Headers.GetValues("X-Content-Type-Options")));
}
[Fact]
public async Task A_profile_shows_public_and_unlisted_posts_only_behind_hardened_headers()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
await Publish(author, "for everyone");
await Publish(author, "unlisted but linkable", PostVisibility.Unlisted);
await Publish(author, "for followers", PostVisibility.FollowersOnly);
await Seeded(author, "for one person", PostVisibility.Direct);
await Seeded(author, "for the neighbourhood", PostVisibility.LocalGeo);
var removed = await Publish(author, "taken back");
using (var scope = _host.Services.CreateScope())
Assert.True((await scope.ServiceProvider.GetRequiredService<IStatusService>().Remove(author, removed.ID, token)).Ok);
var (response, body) = await Page($"/@{author.UserName}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Hardened(response);
Assert.Contains("for everyone", body);
Assert.Contains("unlisted but linkable", body);
Assert.DoesNotContain("for followers", body);
Assert.DoesNotContain("for one person", body);
Assert.DoesNotContain("for the neighbourhood", body);
Assert.DoesNotContain("taken back", body);
Assert.Contains("<meta name=\"robots\" content=\"noindex, noarchive, nofollow\">", body);
Assert.Contains($"<link rel=\"alternate\" type=\"application/activity+json\" href=\"{author.Uri}\">", body);
}
[Fact]
public async Task A_post_page_serves_public_and_unlisted_posts_and_answers_404_for_everything_else()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
var other = await Author("other");
var open = await Publish(author, "an open post");
var unlisted = await Publish(author, "an unlisted post", PostVisibility.Unlisted);
var followersOnly = await Publish(author, "a followers post", PostVisibility.FollowersOnly);
var direct = await Seeded(author, "a direct post", PostVisibility.Direct);
var located = await Seeded(author, "a located post", PostVisibility.LocalGeo);
var removed = await Publish(author, "a removed post");
using (var scope = _host.Services.CreateScope())
Assert.True((await scope.ServiceProvider.GetRequiredService<IStatusService>().Remove(author, removed.ID, token)).Ok);
var (shown, body) = await Page($"/@{author.UserName}/{open.ID}");
Assert.Equal(HttpStatusCode.OK, shown.StatusCode);
Hardened(shown);
Assert.Contains("an open post", body);
Assert.Contains($"href=\"{author.PostUri(open.ID)}\"", body);
Assert.Equal(HttpStatusCode.OK, (await Page($"/@{author.UserName}/{unlisted.ID}")).Response.StatusCode);
foreach (var path in new[]
{
$"/@{author.UserName}/{followersOnly.ID}",
$"/@{author.UserName}/{direct.ID}",
$"/@{author.UserName}/{located.ID}",
$"/@{author.UserName}/{removed.ID}",
$"/@{other.UserName}/{open.ID}",
$"/@{author.UserName}/not-a-post-id",
$"/@{author.UserName}/ffffffffffffffffffffffff",
$"/@nobody{Guid.NewGuid():N}"[..20] + $"/{open.ID}"
})
{
var (response, missing) = await Page(path);
Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"{path} answered {(int)response.StatusCode}");
Assert.DoesNotContain("a followers post", missing);
Assert.DoesNotContain("a direct post", missing);
}
}
[Fact]
public async Task A_circle_has_no_page_a_community_has_one_and_the_instance_actor_has_none()
{
var token = TestContext.Current.CancellationToken;
var member = await Author("member");
GroupEntity Group(GroupKind kind) => new()
{
UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), Name = $"The {kind}", Kind = kind, PostingPolicy = PostingPolicy.Followers,
Members = new() { new GroupMember { AvatarId = member.Id, Role = GroupRole.Owner } }
};
var circle = Group(GroupKind.Circle);
var community = Group(GroupKind.Community);
await DB.Default.SaveAsync(new[] { circle, community }, token);
var inCommunity = await Publish(member, "said in the community", groupId: community.ID);
var inCircle = await Seeded(member, "said in the circle", PostVisibility.Circle);
await DB.Default.Update<Post>().MatchID(inCircle.ID).Modify(p => p.GroupId, circle.ID).ExecuteAsync(token);
var (circlePage, circleBody) = await Page($"/@{circle.UserName}");
var (communityPage, communityBody) = await Page($"/@{community.UserName}");
Assert.Equal(HttpStatusCode.NotFound, circlePage.StatusCode);
Assert.DoesNotContain("The Circle", circleBody);
Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}/{inCircle.ID}")).Response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await Page($"/@{circle.UserName}", "application/activity+json")).Response.StatusCode);
Assert.Equal(HttpStatusCode.OK, communityPage.StatusCode);
Hardened(communityPage);
Assert.Contains("The Community", communityBody);
Assert.Contains("said in the community", communityBody);
Assert.Contains($"href=\"{member.PostHtmlUrl(inCommunity.ID)}\"", communityBody);
Assert.Equal(HttpStatusCode.NotFound, (await Page("/@privapub")).Response.StatusCode);
}
[Fact]
public async Task An_activitypub_request_for_a_page_is_sent_to_the_actor_or_the_object()
{
var author = await Author();
var open = await Publish(author, "fetch me as json");
var followersOnly = await Publish(author, "not for strangers", PostVisibility.FollowersOnly);
var (profile, _) = await Page($"/@{author.UserName}", "application/activity+json");
var (post, _) = await Page($"/@{author.UserName}/{open.ID}", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");
var (hidden, _) = await Page($"/@{author.UserName}/{followersOnly.ID}", "application/activity+json");
Assert.Equal(HttpStatusCode.Redirect, profile.StatusCode);
Assert.Equal(author.Uri, profile.Headers.Location!.ToString());
Assert.Equal(HttpStatusCode.Redirect, post.StatusCode);
Assert.Equal(author.PostUri(open.ID), post.Headers.Location!.ToString());
Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode);
Assert.False(profile.Headers.Contains("Content-Security-Policy"));
}
[Fact]
public async Task Markup_in_a_post_a_title_or_a_bio_is_escaped_never_run()
{
var token = TestContext.Current.CancellationToken;
var author = await Author();
await DB.Default.Update<Avatar>().MatchID(author.Id).Modify(a => a.Biography, "<script>alert(bio)</script> hi").ExecuteAsync(token);
var rendered = await Publish(author, "<script>alert(post)</script> **bold** <img src=x onerror=alert(1)>");
var legacy = await Seeded(author, "<script>alert(legacy)</script>", PostVisibility.Public);
await DB.Default.Update<Post>().MatchID(legacy.ID).Modify(p => p.Title, "<script>alert(title)</script>").ExecuteAsync(token);
var (profile, profileBody) = await Page($"/@{author.UserName}");
var (post, postBody) = await Page($"/@{author.UserName}/{rendered.ID}");
var (old, oldBody) = await Page($"/@{author.UserName}/{legacy.ID}");
Assert.All(new[] { profile, post, old }, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode));
foreach (var body in new[] { profileBody, postBody, oldBody })
{
Assert.DoesNotContain("<script", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", body, StringComparison.OrdinalIgnoreCase);
}
Assert.Contains("&lt;script&gt;alert(bio)&lt;/script&gt;", profileBody);
Assert.Contains("&lt;script&gt;alert(post)&lt;/script&gt;", postBody);
Assert.Contains("<strong>bold</strong>", postBody);
Assert.Contains("&lt;script&gt;alert(legacy)&lt;/script&gt;", oldBody);
Assert.Contains("&lt;script&gt;alert(title)&lt;/script&gt;", oldBody);
}
}
}