Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
119 lines
5.4 KiB
C#
119 lines
5.4 KiB
C#
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<HttpResponseMessage> 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<string>();
|
|
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<string>() == "Delete");
|
|
Assert.Equal(persona.ActorUri(), toFollower["object"]!.GetValue<string>());
|
|
Assert.Equal(persona.ActorUri(), toFollower["actor"]!.GetValue<string>());
|
|
var groupName = group["userName"]!.GetValue<string>();
|
|
Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token),
|
|
d => d["type"]!.GetValue<string>() == "Delete" && d["object"]!.GetValue<string>() == $"{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<string>());
|
|
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<PostEntity>().MatchID(post.ID).ExecuteFirstAsync(token);
|
|
Assert.NotNull(stored.DeletedAt);
|
|
Assert.Null(stored.ContentHtml);
|
|
Assert.NotNull((await DB.Default.Find<GroupEntity>().MatchID(groupId).ExecuteFirstAsync(token)).DeletionAt);
|
|
var removed = await DB.Default.Find<RootUser>().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<RootUser>().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<Avatar>().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<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(token)).DeletionAt);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", token)).StatusCode);
|
|
}
|
|
}
|
|
}
|