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
@@ -1,10 +1,21 @@
|
||||
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))]
|
||||
@@ -79,5 +90,135 @@ namespace PrivaPub.Tests.Infrastructure
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user