277 lines
14 KiB
C#
277 lines
14 KiB
C#
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<Post> Delivered(RemoteActor author, JsonObject note)
|
||
|
|
{
|
||
|
|
await _harness.Deliver(author, "/human-centipede", Create(author, note));
|
||
|
|
return await DB.Default.Find<Post>().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, "<p>soon gone</p>"));
|
||
|
|
var friends = await Delivered(friend, PublicNote(friend, "<p>still here</p>"));
|
||
|
|
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<ForeignAvatar>().Match(f => f.ActorURI == ghost.Id).ExecuteSingleAsync(token);
|
||
|
|
Assert.True(await DB.Default.Find<TimelineEntry>().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<Post>().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 == "<p>soon gone</p>");
|
||
|
|
Assert.Contains(kept, p => p.ReblogOfPostId == friends.ID);
|
||
|
|
Assert.False((await DB.Default.Find<Post>().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<Post>().Match(p => p.ActorURI == ghost.Id).Match(VisibilityPolicy.IsPublic).ExecuteAnyAsync(token));
|
||
|
|
|
||
|
|
Assert.Equal(AvatarAccountState.Deleted, (await DB.Default.Find<ForeignAvatar>().OneAsync(ghostAccount.ID, token)).AccountState);
|
||
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.ActorURI == ghost.Id).ExecuteAnyAsync(token));
|
||
|
|
Assert.False(await DB.Default.Find<Following>().Match(f => f.TargetActorURI == ghost.Id).ExecuteAnyAsync(token));
|
||
|
|
Assert.False(await DB.Default.Find<TimelineEntry>().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<PrivaPub.ClientModels.Post.ViewPost>)(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, "<p>from beyond</p>");
|
||
|
|
_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<List<string>> 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<bool> 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, $"<p>hello #{tag}</p>", 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, "<p>a friend's post</p>");
|
||
|
|
await DeliverSigned(_host, friend, Create(friend, friendsNote));
|
||
|
|
var reply = PublicNote(friend, "<p>a reply</p>");
|
||
|
|
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"] = "<p>psst</p>", ["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<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
|
||
|
|
var friends = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(friendsNote)).ExecuteSingleAsync(token);
|
||
|
|
var replyPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(reply)).ExecuteSingleAsync(token);
|
||
|
|
var ghostBoost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == ghostBoostId).ExecuteSingleAsync(token);
|
||
|
|
var dmPost = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(dm)).ExecuteSingleAsync(token);
|
||
|
|
var ghostAccount = await DB.Default.Find<ForeignAvatar>().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 = "<p>found by its address</p>"
|
||
|
|
};
|
||
|
|
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<Seen> 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<string>() == 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<Post>().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}"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|