using MongoDB.Entities; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Federation.Objects; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.FederatedSeeds; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Tests.Federation { public class AuthorGoneRuleTests { [Fact] public async Task A_post_whose_author_is_gone_is_neither_shown_nor_public_nor_seen_by_anyone() { var gone = new Post { Visibility = PostVisibility.Public, AuthorGone = true, GroupUserId = "alice" }; Assert.False(VisibilityPolicy.Shown(gone)); Assert.False(VisibilityPolicy.IsPublic.Compile()(gone)); Assert.False(await VisibilityPolicy.CanSee(gone, default, TestContext.Current.CancellationToken)); Assert.False(await VisibilityPolicy.CanSee(gone, "alice", TestContext.Current.CancellationToken)); Assert.True(VisibilityPolicy.Shown(new Post())); Assert.False(VisibilityPolicy.Shown(new Post { DeletedAt = DateTime.UtcNow })); Assert.False(VisibilityPolicy.Shown(default)); } } [Trait("Category", "Integration")] public sealed class AuthorGoneTests : 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(); } async Task Delivered(RemoteActor author, JsonObject note) { await _harness.Deliver(author, "/human-centipede", Create(author, note)); return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteFirstAsync(TestContext.Current.CancellationToken); } [Fact] public async Task Deleting_itself_keeps_every_post_of_the_account_but_hides_them_and_drops_its_follows_and_rows() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var ghost = new RemoteActor(_harness.Peer, "ghost"); var friend = new RemoteActor(_harness.Peer, "friend"); await Follows(alice.Id, ghost); await Follows(alice.Id, friend); await _harness.FollowedBy(alice, ghost); var post = await Delivered(ghost, PublicNote(ghost, "

soon gone

")); var friends = await Delivered(friend, PublicNote(friend, "

still here

")); await _harness.Deliver(ghost, "/human-centipede", new JsonObject { ["id"] = NewId(ghost, "announces"), ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = friends.ObjectURI, ["to"] = new JsonArray(Addressing.Public) }); var ghostAccount = await DB.Default.Find().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token); Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token)); Assert.Equal(202, (await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!))).StatusCode); var kept = await DB.Default.Find().Match(p => p.ActorURI == ghost.Id).ExecuteAsync(token); Assert.Equal(2, kept.Count); Assert.All(kept, p => Assert.True(p.AuthorGone)); Assert.Contains(kept, p => p.ID == post.ID && p.ContentHtml == "

soon gone

"); Assert.Contains(kept, p => p.ReblogOfPostId == friends.ID); Assert.False((await DB.Default.Find().OneAsync(friends.ID, token)).AuthorGone); Assert.All(kept, p => Assert.False(VisibilityPolicy.Shown(p))); foreach (var hidden in kept) Assert.False(await VisibilityPolicy.CanSee(hidden, alice.Id, token)); Assert.False(await DB.Default.Find().Match(p => p.ActorURI == ghost.Id).Match(VisibilityPolicy.IsPublic).ExecuteAnyAsync(token)); Assert.Equal(AvatarAccountState.Deleted, (await DB.Default.Find().OneAsync(ghostAccount.ID, token)).AccountState); Assert.False(await DB.Default.Find().Match(f => f.ActorURI == ghost.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(f => f.TargetActorURI == ghost.Id).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(e => e.AuthorAccountId == ghostAccount.ID).ExecuteAnyAsync(token)); var mapper = new MastodonMapper(_harness.Db, _harness.Local); Assert.Empty(await mapper.Statuses(kept, alice.Id, token)); var home = (List)(await _harness.Timelines.Home(root, alice.Id, default, 40, token)).Data; Assert.DoesNotContain(home, v => v.AuthorActorURI == ghost.Id); Assert.Contains(home, v => v.Id == friends.ID); } [Fact] public async Task A_post_by_an_account_already_gone_is_hidden_from_the_moment_it_is_stored() { var token = TestContext.Current.CancellationToken; var (_, alice) = await _harness.Persona("alice"); var ghost = new RemoteActor(_harness.Peer, "ghost"); await _harness.FollowedBy(alice, ghost); await _harness.Deliver(ghost, "/human-centipede", Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!)); var late = PublicNote(ghost, "

from beyond

"); _harness.Peer.Serve(new Uri(IdOf(late)).AbsolutePath, late.ToJsonString()); var stored = await _harness.RemotePosts.StoreContext(IdOf(late), 0, token); Assert.NotNull(stored); Assert.True(stored.AuthorGone); Assert.False(await VisibilityPolicy.CanSee(stored, alice.Id, token)); } } [Trait("Category", "Integration")] public sealed class AuthorGoneOverHttpTests : IAsyncLifetime { PrivaPubHost _host; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } static async Task<(HttpStatusCode Status, JsonNode Body)> Get(HttpClient client, string path) { var response = await client.GetAsync(path, TestContext.Current.CancellationToken); var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); return (response.StatusCode, string.IsNullOrEmpty(text) || response.Content.Headers.ContentType?.MediaType?.Contains("json") != true ? default : JsonNode.Parse(text)); } static async Task> Ids(HttpClient client, string path) { var (status, body) = await Get(client, path); Assert.Equal(HttpStatusCode.OK, status); return body!.AsArray().Select(s => IdOf(s!)).ToList(); } static async Task Found(HttpClient client, string path) => (await Get(client, path)).Status == HttpStatusCode.OK; sealed record Seen(bool Status, bool Context, bool FavouritedBy, bool RebloggedBy, bool History, bool Many, bool Boost, bool InAncestors, bool BoostersListed, bool OnProfile, bool OnTag, bool OnPublic, bool OnHome, bool Notified, bool InConversation, bool Searched, bool Favourited, bool Bookmarked, bool Provenance, bool InCommunityOutbox); [Fact] public async Task Every_lookup_treats_a_post_whose_author_deleted_itself_as_not_found() { var token = TestContext.Current.CancellationToken; var persona = await _host.Persona(await _host.SignUp(), "reader"); var bearer = await _host.MastodonToken(persona); using var client = _host.As(bearer); var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair(); var community = new GroupEntity { UserName = $"commons{Guid.NewGuid():N}"[..20], Kind = GroupKind.Community, PostingPolicy = PostingPolicy.Anyone, PrivateKey = privateKey, PublicKey = publicKey, Members = new() { new GroupMember { AvatarId = persona.Id, Role = GroupRole.Owner } } }; await DB.Default.SaveAsync(community, token); var personaUri = $"{PrivaPubHost.Base}/peasants/{persona.UserName}"; var communityUri = $"{PrivaPubHost.Base}/peasants/{community.UserName}"; var ghost = new RemoteActor(_peer, "ghost"); var friend = new RemoteActor(_peer, "friend"); await Follows(persona.Id, ghost); await Follows(persona.Id, friend); var tag = $"gone{Guid.NewGuid():N}"[..16]; var note = PublicNote(ghost, $"

hello #{tag}

", personaUri, communityUri); note["tag"] = new JsonArray( new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" }, new JsonObject { ["type"] = "Hashtag", ["name"] = "#" + tag, ["href"] = $"{Origin(ghost)}/tags/{tag}" }); Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Create(ghost, note))); var friendsNote = PublicNote(friend, "

a friend's post

"); await DeliverSigned(_host, friend, Create(friend, friendsNote)); var reply = PublicNote(friend, "

a reply

"); reply["inReplyTo"] = IdOf(note); await DeliverSigned(_host, friend, Create(friend, reply)); await DeliverSigned(_host, friend, new JsonObject { ["id"] = NewId(friend, "announces"), ["type"] = "Announce", ["actor"] = friend.Id, ["object"] = IdOf(note), ["to"] = new JsonArray(Addressing.Public) }); var ghostBoostId = NewId(ghost, "announces"); await DeliverSigned(_host, ghost, new JsonObject { ["id"] = ghostBoostId, ["type"] = "Announce", ["actor"] = ghost.Id, ["object"] = IdOf(friendsNote), ["to"] = new JsonArray(Addressing.Public) }); var dm = new JsonObject { ["id"] = NewId(ghost, "notes"), ["type"] = "Note", ["attributedTo"] = ghost.Id, ["content"] = "

psst

", ["to"] = new JsonArray(personaUri), ["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = personaUri, ["name"] = "@reader" }) }; await DeliverSigned(_host, ghost, Create(ghost, dm)); var post = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); var friends = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(friendsNote)).ExecuteSingleAsync(token); var replyPost = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(reply)).ExecuteSingleAsync(token); var ghostBoost = await DB.Default.Find().Match(p => p.ObjectURI == ghostBoostId).ExecuteSingleAsync(token); var dmPost = await DB.Default.Find().Match(p => p.ObjectURI == IdOf(dm)).ExecuteSingleAsync(token); var ghostAccount = await DB.Default.Find().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token); Assert.Equal(community.ID, post.GroupId); //search looks up https addresses only, which the peer does not have var searchable = new Post { ObjectURI = $"https://ghost{Guid.NewGuid():N}.example/notes/1", ActorURI = ghost.Id, AuthorAccountId = ghostAccount.ID, IsFederatedCopy = true, Visibility = PostVisibility.Public, ContentHtml = "

found by its address

" }; await DB.Default.SaveAsync(searchable, token); Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/favourite", default, token)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.PostAsync($"/api/v1/statuses/{post.ID}/bookmark", default, token)).StatusCode); async Task Look() { var home = (await Get(client, "/api/v1/timelines/home?limit=40")).Body!.AsArray(); var notifications = (await Get(client, "/api/v1/notifications?limit=30")).Body!.AsArray(); var conversations = (await Get(client, "/api/v1/conversations")).Body!.AsArray(); var search = (await Get(client, $"/api/v2/search?type=statuses&q={Uri.EscapeDataString(searchable.ObjectURI)}")).Body!; using var anonymous = _host.Client(); using var outboxRequest = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{community.UserName}/anus?page=true"); outboxRequest.Headers.Accept.ParseAdd("application/activity+json"); var outbox = JsonNode.Parse(await (await anonymous.SendAsync(outboxRequest, token)).Content.ReadAsStringAsync(token))!; return new Seen( await Found(client, $"/api/v1/statuses/{post.ID}"), await Found(client, $"/api/v1/statuses/{post.ID}/context"), await Found(client, $"/api/v1/statuses/{post.ID}/favourited_by"), await Found(client, $"/api/v1/statuses/{post.ID}/reblogged_by"), await Found(client, $"/api/v1/statuses/{post.ID}/history"), (await Ids(client, $"/api/v1/statuses?id[]={post.ID}")).Contains(post.ID), await Found(client, $"/api/v1/statuses/{ghostBoost.ID}"), (await Get(client, $"/api/v1/statuses/{replyPost.ID}/context")).Body!["ancestors"]!.AsArray().Any(a => IdOf(a!) == post.ID), (await Ids(client, $"/api/v1/statuses/{friends.ID}/reblogged_by")).Contains(ghostAccount.ID), (await Ids(client, $"/api/v1/accounts/{ghostAccount.ID}/statuses")).Count > 0, (await Ids(client, $"/api/v1/timelines/tag/{tag}")).Contains(post.ID), (await Ids(client, $"/api/v1/timelines/public?remote=true&limit=1&max_id={IdAbove(post.ID)}")).Contains(post.ID), home.Any(s => IdOf(s!) == post.ID || IdOf(s!) == ghostBoost.ID || s!["reblog"] is JsonObject inner && IdOf(inner) == post.ID), notifications.Any(n => n!["status"] is JsonObject status && IdOf(status) == post.ID), conversations.Any(c => c!["last_status"] is JsonObject last && IdOf(last) == dmPost.ID), search["statuses"]!.AsArray().Count > 0, (await Ids(client, "/api/v1/favourites")).Contains(post.ID), (await Ids(client, "/api/v1/bookmarks")).Contains(post.ID), await Found(client, $"/api/privapub/v1/statuses/{post.ID}/provenance"), outbox["orderedItems"]!.AsArray().Any(a => a!["object"]?.GetValue() == post.ObjectURI)); } var before = await Look(); Assert.Equal(HttpStatusCode.Accepted, await DeliverSigned(_host, ghost, Activity(ghost, "Delete", JsonValue.Create(ghost.Id)!))); var after = await Look(); var everything = new Seen(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true); var nothing = new Seen(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false); Assert.Equal(everything, before); Assert.Equal(nothing, after); Assert.True(await DB.Default.Find().Match(p => p.ID == post.ID && p.AuthorGone && p.DeletedAt == null).ExecuteAnyAsync(token)); Assert.True(await Found(client, $"/api/v1/statuses/{friends.ID}")); Assert.True(await Found(client, $"/api/v1/statuses/{replyPost.ID}")); } } }