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
225 lines
12 KiB
C#
225 lines
12 KiB
C#
using MongoDB.Bson;
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Data.Migrations;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Infrastructure
|
|
{
|
|
[Xunit.Collection(nameof(Exclusive))]
|
|
[Trait("Category", "Integration")]
|
|
public class MigrationTests
|
|
{
|
|
[Fact]
|
|
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
|
|
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
|
|
await DB.Default.SaveAsync(new[] { remote, local }, token);
|
|
|
|
await new _002_sanitize_stored_content().UpgradeAsync();
|
|
|
|
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
|
|
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
|
|
Assert.Equal("<p>hi</p>", migratedRemote.Text);
|
|
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
|
|
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
|
|
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
|
|
Assert.Equal("<p><strong>bold</strong> <b>raw</b></p>", migratedLocal.ContentHtml);
|
|
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Direct_posts_move_into_posts_with_their_conversation()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var dm = new DmPost { GroupId = "conversation1", Text = "psst", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}", GroupUserId = "a1" };
|
|
await DB.Default.SaveAsync(dm, token);
|
|
|
|
await new _005_direct_posts_join_posts().UpgradeAsync();
|
|
|
|
var post = await DB.Default.Find<Post>().OneAsync(dm.ID, token);
|
|
Assert.Equal(PostVisibility.Direct, post.Visibility);
|
|
Assert.Equal("conversation1", post.ConversationId);
|
|
Assert.Equal("a1", post.AuthorAccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fetched_records_lose_the_signature_they_never_had_and_every_record_gets_its_extensions()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var fetched = new ObjectRecord
|
|
{
|
|
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Fetched, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
|
|
Algorithm = "rsa-sha256", SignedHeaders = new() { "host" }, ActivityContext = "\"https://www.w3.org/ns/activitystreams\"",
|
|
Raw = "{\"type\":\"Note\",\"quoteUrl\":\"https://q.example/1\"}"
|
|
};
|
|
var delivered = new ObjectRecord
|
|
{
|
|
ObjectURI = $"https://r.example/{Guid.NewGuid():N}", Path = ObjectPath.Delivered, KeyId = "https://x.example/u#k", SignatureScheme = "draft-cavage",
|
|
Raw = "not json"
|
|
};
|
|
await DB.Default.SaveAsync(new[] { fetched, delivered }, token);
|
|
|
|
await new _008_fetched_records_lose_the_trigger_signature().UpgradeAsync();
|
|
await new _009_object_records_keep_their_extensions().UpgradeAsync();
|
|
|
|
var afterFetched = await DB.Default.Find<ObjectRecord>().OneAsync(fetched.ID, token);
|
|
var afterDelivered = await DB.Default.Find<ObjectRecord>().OneAsync(delivered.ID, token);
|
|
Assert.Null(afterFetched.KeyId);
|
|
Assert.Null(afterFetched.SignatureScheme);
|
|
Assert.Empty(afterFetched.SignedHeaders);
|
|
Assert.Null(afterFetched.ActivityContext);
|
|
Assert.Equal("https://x.example/u#k", afterDelivered.KeyId);
|
|
Assert.Contains("legacy-quote", afterFetched.Extensions);
|
|
Assert.Empty(afterDelivered.Extensions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Conversations_get_their_participants_key_groups_become_circles_and_their_posts_stay_home()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var members = new List<GroupMember> { new() { AvatarId = "a1" }, new() { AvatarId = "https://r.example/u/b", IsForeign = true } };
|
|
var conversation = new DmGroup { Members = members };
|
|
var group = new GroupEntity { UserName = $"old{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community };
|
|
await DB.Default.SaveAsync(conversation, token);
|
|
await DB.Default.SaveAsync(group, token);
|
|
var localInGroup = new Post { GroupId = group.ID, Text = "inside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
|
|
var remoteInGroup = new Post { GroupId = group.ID, IsFederatedCopy = true, Text = "visiting", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
|
|
var ungrouped = new Post { Text = "outside", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
|
|
await DB.Default.SaveAsync(new[] { localInGroup, remoteInGroup, ungrouped }, token);
|
|
//the migration turns every group into a circle and keeps every local group post home: put the others back afterwards
|
|
var communities = (await DB.Default.Find<GroupEntity>().Match(g => g.Kind != GroupKind.Circle && g.ID != group.ID).ExecuteAsync(token)).Select(g => g.ID).ToList();
|
|
var federatedGroupPosts = (await DB.Default.Find<Post>().Match(p => p.GroupId != null && !p.IsFederatedCopy && !p.IsLocalOnly && p.ID != localInGroup.ID)
|
|
.ExecuteAsync(token)).Select(p => p.ID).ToList();
|
|
try
|
|
{
|
|
await new _003_conversation_keys_and_circles().UpgradeAsync();
|
|
|
|
Assert.Equal(DmGroup.KeyOf(members), (await DB.Default.Find<DmGroup>().OneAsync(conversation.ID, token)).ParticipantsKey);
|
|
Assert.Equal(GroupKind.Circle, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).Kind);
|
|
Assert.True((await DB.Default.Find<Post>().OneAsync(localInGroup.ID, token)).IsLocalOnly);
|
|
Assert.False((await DB.Default.Find<Post>().OneAsync(remoteInGroup.ID, token)).IsLocalOnly);
|
|
Assert.False((await DB.Default.Find<Post>().OneAsync(ungrouped.ID, token)).IsLocalOnly);
|
|
}
|
|
finally
|
|
{
|
|
if (communities.Count > 0)
|
|
await DB.Default.Update<GroupEntity>().Match(g => communities.Contains(g.ID)).Modify(g => g.Kind, GroupKind.Community).ExecuteAsync(token);
|
|
if (federatedGroupPosts.Count > 0)
|
|
await DB.Default.Update<Post>().Match(p => federatedGroupPosts.Contains(p.ID)).Modify(p => p.IsLocalOnly, false).ExecuteAsync(token);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Pending_deliveries_move_to_the_job_queue_once_and_the_rest_are_left_alone()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var host = $"r{Guid.NewGuid():N}.example";
|
|
var activityId = $"https://privapub.test/a/{Guid.NewGuid():N}";
|
|
var body = new JsonObject { ["id"] = activityId, ["type"] = "Create" }.ToJsonString();
|
|
var nextAttempt = DateTime.UtcNow.AddMinutes(7);
|
|
Delivery Legacy(string inbox, DateTime? deliveredAt = default, DateTime? abandonedAt = default) => new()
|
|
{
|
|
SignerId = "000000000000000000000001", SignerKind = LocalActorKind.Person, InboxURL = inbox, Body = body, Attempts = 3,
|
|
NextAttemptAt = nextAttempt, DeliveredAt = deliveredAt, AbandonedAt = abandonedAt
|
|
};
|
|
var pending = Legacy($"https://{host.ToUpperInvariant()}/inbox");
|
|
var twice = Legacy($"https://{host}/shared");
|
|
var already = Legacy($"https://{host}/shared");
|
|
var broken = Legacy("not an address");
|
|
var delivered = Legacy($"https://{host}/done", deliveredAt: DateTime.UtcNow.AddDays(-1));
|
|
var abandoned = Legacy($"https://{host}/given-up", abandonedAt: DateTime.UtcNow.AddDays(-1));
|
|
await DB.Default.SaveAsync(new[] { pending, twice, already, broken, delivered, abandoned }, token);
|
|
|
|
await new _004_pending_deliveries_become_jobs().UpgradeAsync();
|
|
|
|
var jobs = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.DedupeKey.StartsWith(activityId + "|")).ExecuteAsync(token);
|
|
Assert.Equal(2, jobs.Count);
|
|
var job = Assert.Single(jobs, j => j.DedupeKey == $"{activityId}|{pending.InboxURL}");
|
|
Assert.Equal(host, job.Host);
|
|
Assert.Equal(3, job.Attempts);
|
|
Assert.InRange(job.RunAt, nextAttempt.AddSeconds(-1), nextAttempt.AddSeconds(1));
|
|
var payload = JsonSerializer.Deserialize<DeliveryPayload>(job.Payload);
|
|
Assert.Equal((pending.SignerId, LocalActorKind.Person, pending.InboxURL, body), (payload.SignerId, payload.SignerKind, payload.Inbox, payload.Body));
|
|
Assert.Contains(jobs, j => j.DedupeKey == $"{activityId}|{twice.InboxURL}");
|
|
foreach (var moved in new[] { pending, twice, already, broken })
|
|
{
|
|
var after = await DB.Default.Find<Delivery>().OneAsync(moved.ID, token);
|
|
Assert.NotNull(after.AbandonedAt);
|
|
Assert.Equal("moved to the job queue", after.LastError);
|
|
}
|
|
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(delivered.ID, token)).AbandonedAt);
|
|
Assert.Null((await DB.Default.Find<Delivery>().OneAsync(abandoned.ID, token)).LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Year_one_edit_dates_become_no_edit_and_year_one_revisions_take_the_creation_date()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var created = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
var edited = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc);
|
|
var broken = new Post
|
|
{
|
|
IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = DateTime.MinValue, UpdateDate = DateTime.MinValue,
|
|
Revisions = new() { new PostRevision { ContentHtml = "<p>first</p>", EditedAt = DateTime.MinValue }, new PostRevision { ContentHtml = "<p>second</p>", EditedAt = edited } }
|
|
};
|
|
var fine = new Post { IsFederatedCopy = true, ObjectURI = $"https://r.example/{Guid.NewGuid():N}", CreationDate = created, EditedAt = edited, UpdateDate = edited };
|
|
await DB.Default.SaveAsync(new[] { broken, fine }, token);
|
|
|
|
await new _006_unedited_remote_posts_lose_year_one().UpgradeAsync();
|
|
|
|
var repaired = await DB.Default.Find<Post>().OneAsync(broken.ID, token);
|
|
Assert.Null(repaired.EditedAt);
|
|
Assert.Equal(created, repaired.UpdateDate);
|
|
Assert.Equal(new[] { created, edited }, repaired.Revisions.Select(r => r.EditedAt));
|
|
var untouched = await DB.Default.Find<Post>().OneAsync(fine.ID, token);
|
|
Assert.Equal((edited, edited), (untouched.EditedAt, untouched.UpdateDate));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Personas_and_groups_without_a_published_day_get_one_within_two_weeks_before_creation()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
var token = TestContext.Current.CancellationToken;
|
|
var created = new DateTime(2026, 6, 15, 18, 30, 0, DateTimeKind.Utc);
|
|
var avatar = new Avatar { UserName = $"aged{Guid.NewGuid():N}"[..20], CreatedAt = created };
|
|
var group = new GroupEntity { UserName = $"aged{Guid.NewGuid():N}"[..20], CreationDate = created };
|
|
var dated = new Avatar { UserName = $"dated{Guid.NewGuid():N}"[..20], CreatedAt = created, PublishedOn = new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc) };
|
|
await DB.Default.SaveAsync(new[] { avatar, dated }, token);
|
|
await DB.Default.SaveAsync(group, token);
|
|
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(b => b.Unset(a => a.PublishedOn)).ExecuteAsync(token);
|
|
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(b => b.Unset(g => g.PublishedOn)).ExecuteAsync(token);
|
|
Assert.False(await DB.Default.Find<Avatar>().Match(new BsonDocument("_id", ObjectId.Parse(avatar.ID)).Add(nameof(Avatar.PublishedOn), new BsonDocument("$exists", true))).ExecuteAnyAsync(token));
|
|
|
|
await new _007_personas_publish_a_spread_day().UpgradeAsync();
|
|
|
|
foreach (var published in new[] { (await DB.Default.Find<Avatar>().OneAsync(avatar.ID, token)).PublishedOn, (await DB.Default.Find<GroupEntity>().OneAsync(group.ID, token)).PublishedOn })
|
|
{
|
|
Assert.Equal(TimeSpan.Zero, published.TimeOfDay);
|
|
Assert.InRange(published, created.Date.AddDays(-13), created.Date);
|
|
}
|
|
Assert.Equal(new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), (await DB.Default.Find<Avatar>().OneAsync(dated.ID, token)).PublishedOn);
|
|
}
|
|
}
|
|
}
|