using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Net.Http.Json; using GroupEntity = PrivaPub.Models.Group.Group; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Tests.Http { // Deleting a root deletes its personas and their groups everywhere (owner decision 2026-10-04). [Trait("Category", "Integration")] public sealed class RootRemovalTests : IAsyncLifetime { PrivaPubHost _host; HttpClient _client; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { _client?.Dispose(); if (_peer != default) await _peer.DisposeAsync(); } async Task AdminRemoves(params string[] rootIds) { var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); using var request = new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = rootIds }) }; return await client.SendAsync(request, TestContext.Current.CancellationToken); } [Fact] public async Task A_removed_roots_personas_and_groups_are_deleted_everywhere_and_say_so() { var token = TestContext.Current.CancellationToken; var root = await _host.SignUp("leaving"); var persona = await _host.Persona(root, "leaving"); var follower = new RemoteActor(_peer, "fan"); var member = new RemoteActor(_peer, "member"); await DB.Default.SaveAsync(new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token); var group = await _host.Group(persona, community: true); var groupId = group["id"]!.GetValue(); await DB.Default.SaveAsync(new Follower { LocalActorId = groupId, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token); var post = await _host.Publish(persona, "goodbye soon"); var apiToken = await _host.MastodonToken(persona); var since = DateTime.UtcNow.AddSeconds(-1); Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(root.Id)).StatusCode); var toFollower = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Delete"); Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue()); Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue()); var groupName = group["userName"]!.GetValue(); Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Delete" && d["object"]!.GetValue() == $"{PrivaPubHost.Base}/peasants/{groupName}"); var gone = await _client.Fetch($"/peasants/{persona.UserName}"); Assert.Equal(HttpStatusCode.Gone, gone.Status); Assert.Equal("Person", gone.Json["formerType"]!.GetValue()); Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch($"/peasants/{groupName}")).Status); Assert.Equal(HttpStatusCode.Gone, (await _client.GetAsync($"/.well-known/webfinger?resource=acct:{persona.UserName}@{PrivaPubHost.Host}", token)).StatusCode); var stored = await DB.Default.Find().MatchID(post.ID).ExecuteFirstAsync(token); Assert.NotNull(stored.DeletedAt); Assert.Null(stored.ContentHtml); Assert.NotNull((await DB.Default.Find().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt); var removed = await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(token); Assert.Equal($"deleted-{root.Id}", removed.UserName); Assert.Null(removed.HashedPassword); using var app = _host.Client(); app.DefaultRequestHeaders.Authorization = new("Bearer", apiToken); Assert.Equal(HttpStatusCode.Unauthorized, (await app.GetAsync("/api/v1/accounts/verify_credentials", token)).StatusCode); } [Fact] public async Task A_second_removal_does_not_collide_with_the_first() { var first = await _host.SignUp("first"); var second = await _host.SignUp("second"); Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(first.Id)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await AdminRemoves(second.Id)).StatusCode); Assert.NotNull((await DB.Default.Find().MatchID(second.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).DeletedAt); } [Fact] public async Task A_root_deletes_itself_only_with_its_password() { var token = TestContext.Current.CancellationToken; var root = await _host.SignUp("self"); var persona = await _host.Persona(root, "self"); using var client = _host.As(root.Jwt); var refused = await client.PostJson("/clientapi/user/delete", new { password = "Not-The-Password-1" }); Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); Assert.Null((await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt); var accepted = await client.PostJson("/clientapi/user/delete", new { password = root.Password }); Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); Assert.NotNull((await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt); Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode); } } }