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
This commit is contained in:
1 parent
2cfea7b60c
commit
2645dea26f
22 files changed
+1529
-34
No files matched your search
@@ -0,0 +1,222 @@
|
||||
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("<script>alert(bio)</script>", profileBody);
|
||||
Assert.Contains("<script>alert(post)</script>", postBody);
|
||||
Assert.Contains("<strong>bold</strong>", postBody);
|
||||
Assert.Contains("<script>alert(legacy)</script>", oldBody);
|
||||
Assert.Contains("<script>alert(title)</script>", oldBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user