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
233 lines
12 KiB
C#
233 lines
12 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Tests.Support.FederatedSeeds;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InboxGapTests : IAsyncLifetime
|
|
{
|
|
Harness _harness;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_harness = await Harness.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_harness != default)
|
|
await _harness.DisposeAsync();
|
|
}
|
|
|
|
static JsonObject Follow(RemoteActor follower, string target) =>
|
|
new() { ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target };
|
|
|
|
Task<ForeignAvatar> Stored(RemoteActor actor) =>
|
|
DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == actor.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
|
|
|
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
|
|
|
|
[Fact]
|
|
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
|
|
var before = await Stored(bob);
|
|
using var rotated = RSA.Create(2048);
|
|
var document = bob.Document();
|
|
document["name"] = "Bob Renamed";
|
|
document["summary"] = "<p>a new bio<script>alert(1)</script></p>";
|
|
document["publicKey"]!["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem();
|
|
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
|
|
var embedded = (JsonObject)document.DeepClone();
|
|
embedded["name"] = "What the activity claims";
|
|
embedded["updated"] = "2001-01-01T00:00:00Z";
|
|
|
|
var result = await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", embedded));
|
|
|
|
var after = await Stored(bob);
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.Null(before.Name);
|
|
Assert.Equal(before.ID, after.ID);
|
|
Assert.Equal("Bob Renamed", after.Name);
|
|
Assert.Equal("<p>a new bio</p>", after.Biography);
|
|
Assert.Equal(bob.KeyId, after.PublicKeyId);
|
|
Assert.NotEqual(before.PublicKey, after.PublicKey);
|
|
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
|
|
Assert.Equal(("accepted", "actor-refresh"), (Processed("Update").Outcome, Processed("Update").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_key_moved_to_a_new_id_replaces_the_old_one()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
|
|
using var rotated = RSA.Create(2048);
|
|
var document = bob.Document();
|
|
document["publicKey"] = new JsonObject { ["id"] = bob.Id + "#key-2", ["owner"] = bob.Id, ["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem() };
|
|
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", JsonValue.Create(bob.Id)!));
|
|
|
|
var after = await Stored(bob);
|
|
Assert.Equal(bob.Id + "#key-2", after.PublicKeyId);
|
|
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Undoing_a_follow_removes_the_follower_by_the_activity_id_or_by_its_object()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var bobFollows = Follow(bob, alice.Uri);
|
|
await _harness.Deliver(bob, "/human-centipede", bobFollows);
|
|
await _harness.Deliver(carol, "/human-centipede", Follow(carol, alice.Uri));
|
|
Assert.Equal(2, await DB.Default.CountAsync<Follower>(f => f.LocalActorId == alice.Id, token));
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Undo", JsonValue.Create(IdOf(bobFollows))!));
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
|
Assert.Equal(("accepted", "undone"), (Processed("Undo").Outcome, Processed("Undo").Reason));
|
|
|
|
var forgotten = Follow(carol, alice.Uri);
|
|
forgotten["id"] = NewId(carol, "follows");
|
|
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
|
|
Assert.Equal(("dropped", "unknown-object"), (Processed("Undo").Outcome, Processed("Undo").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_rejected_quote_request_marks_the_quote_rejected_and_only_the_quoted_author_can_reject_it()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var ann = new RemoteActor(_harness.Peer, "ann");
|
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
|
var note = PublicNote(ann, "<p>ask me first</p>", alice.Uri);
|
|
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" });
|
|
note["interactionPolicy"] = new JsonObject { ["canQuote"] = new JsonObject { ["manualApproval"] = new JsonArray(Addressing.Public) } };
|
|
await _harness.Deliver(ann, "/human-centipede", Create(ann, note));
|
|
var original = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
|
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", QuotedStatusId = original.ID }, token);
|
|
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
|
|
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
|
|
|
|
await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!));
|
|
Assert.Equal(QuoteState.Pending, (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).QuoteState);
|
|
|
|
await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request));
|
|
|
|
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
|
|
Assert.Equal(QuoteState.Rejected, quoting.QuoteState);
|
|
Assert.Null(quoting.QuoteAuthorizationURI);
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_followed_community_announcing_a_vote_or_its_undo_is_recorded_and_changes_nothing_yet()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
|
var stranger = new RemoteActor(_harness.Peer, "dogs", type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
var voter = new RemoteActor(_harness.Peer, "voter");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
var page = PublicNote(poster, "<p>a post</p>", community.Id);
|
|
page["type"] = "Page";
|
|
page["name"] = "a title";
|
|
page["audience"] = community.Id;
|
|
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
|
|
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI, ["audience"] = community.Id };
|
|
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
|
|
var liked = Processed("Announce");
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
|
|
var unliked = Processed("Announce");
|
|
await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like));
|
|
var unfollowed = Processed("Announce");
|
|
|
|
//votes relayed by a community are a documented gap (docs/INTEROP.md, Lemmy: "Announces of activities other than Create")
|
|
Assert.Equal(("dropped", "unsupported", "Like"), (liked.Outcome, liked.Reason, liked.Object));
|
|
Assert.Equal(("dropped", "unsupported", "Undo"), (unliked.Outcome, unliked.Reason, unliked.Object));
|
|
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
|
|
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
|
Assert.Equal(0, after.FavouritesCount);
|
|
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, open) = await _harness.Persona("alice");
|
|
await DB.Default.Update<Avatar>().MatchID(open.Id).Modify(a => a.Settings.IsLocked, true).ExecuteAsync(token);
|
|
var alice = await _harness.Local.FindById(LocalActorKind.Person, open.Id, token);
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var bobFollows = Follow(bob, alice.Uri);
|
|
var carolFollows = Follow(carol, alice.Uri);
|
|
Assert.True(alice.ManuallyApprovesFollowers);
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", bobFollows);
|
|
await _harness.Deliver(carol, "/human-centipede", carolFollows);
|
|
|
|
var pending = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, pending.Count);
|
|
Assert.All(pending, f => Assert.False(f.IsAccepted));
|
|
Assert.Equal(("accepted", "pending"), (Processed("Follow").Outcome, Processed("Follow").Reason));
|
|
var requests = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, requests.Count);
|
|
Assert.All(requests, n => Assert.Equal(NotificationType.FollowRequest, n.Type));
|
|
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
|
|
Assert.Empty(await _harness.Delivery.FollowerInboxes(alice, token));
|
|
|
|
var bobAccount = await Stored(bob);
|
|
var carolAccount = await Stored(carol);
|
|
Assert.True(await _harness.Follows.Decide(alice, bobAccount.ID, accept: true, token));
|
|
Assert.True(await _harness.Follows.Decide(alice, carolAccount.ID, accept: false, token));
|
|
Assert.False(await _harness.Follows.Decide(alice, carolAccount.ID, accept: true, token));
|
|
|
|
var accept = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"));
|
|
Assert.Equal("Accept", accept["type"]!.GetValue<string>());
|
|
Assert.Equal(alice.Uri, accept["actor"]!.GetValue<string>());
|
|
Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!));
|
|
Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue<string>());
|
|
var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
|
|
Assert.Equal("Reject", reject["type"]!.GetValue<string>());
|
|
Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!));
|
|
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted);
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
|
|
Assert.Contains(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Follow).ExecuteAsync(token),
|
|
n => n.FromAccountId == bobAccount.ID);
|
|
Assert.Equal(new[] { bob.Id + "/inbox" }, await _harness.Delivery.FollowerInboxes(alice, token));
|
|
}
|
|
}
|
|
}
|