feat(accountdelete): anonymize-and-tombstone deletion core
New accountdelete package: AnonymizeAndTombstone journals every credential to the retention log (reason=delete) then removes them (freeing email/vk/tg for reuse), snapshots the real display name into deleted_display_name and scrubs the live one to 'Удалённый игрок', sets deleted_at, anonymizes the account's game-seat snapshots, and drops its friendships/blocks/invitations/friend-codes/drafts/pending-codes — all in one transaction. Chat, feedback and complaints are kept (the tombstone keeps their no-cascade FKs valid). Session revocation + game forfeit are orchestrated a layer up. Integration test covers journalling, tombstone/scrub and credential reuse.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountdelete"
|
||||
)
|
||||
|
||||
// deletedFields reads a tombstoned account's retained real name and its deleted_at.
|
||||
func deletedFields(t *testing.T, accountID uuid.UUID) (name string, deletedAt sql.NullTime) {
|
||||
t.Helper()
|
||||
var dn sql.NullString
|
||||
err := testDB.QueryRowContext(context.Background(),
|
||||
"SELECT deleted_display_name, deleted_at FROM accounts WHERE account_id = $1", accountID).
|
||||
Scan(&dn, &deletedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("read deleted fields %s: %v", accountID, err)
|
||||
}
|
||||
return dn.String, deletedAt
|
||||
}
|
||||
|
||||
// TestAnonymizeAndTombstone: deletion journals + frees the credentials, tombstones the
|
||||
// account, scrubs the live name while retaining the real one, and frees the creds for a
|
||||
// new account to reuse.
|
||||
func TestAnonymizeAndTombstone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
deleter := accountdelete.NewDeleter(testDB)
|
||||
|
||||
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "handle", "Иван", "+03:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
email := "del-" + uuid.NewString() + "@example.com"
|
||||
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil {
|
||||
t.Fatalf("attach email: %v", err)
|
||||
}
|
||||
before, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load before: %v", err)
|
||||
}
|
||||
|
||||
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// The live identities are gone.
|
||||
if ids, err := store.Identities(ctx, acc.ID); err != nil || len(ids) != 0 {
|
||||
t.Fatalf("identities after delete = %+v (err %v), want none", ids, err)
|
||||
}
|
||||
// Both credentials are journalled with reason=delete.
|
||||
got := retainedRows(t, acc.ID)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("retained rows = %+v, want 2", got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.reason != "delete" {
|
||||
t.Errorf("retained reason = %q, want delete", r.reason)
|
||||
}
|
||||
}
|
||||
// The live name is scrubbed; the real one is retained; deleted_at is set.
|
||||
after, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load after: %v", err)
|
||||
}
|
||||
if after.DisplayName != accountdelete.AnonymizedName {
|
||||
t.Errorf("live display name = %q, want %q", after.DisplayName, accountdelete.AnonymizedName)
|
||||
}
|
||||
name, deletedAt := deletedFields(t, acc.ID)
|
||||
if name != before.DisplayName {
|
||||
t.Errorf("retained name = %q, want %q", name, before.DisplayName)
|
||||
}
|
||||
if !deletedAt.Valid || time.Since(deletedAt.Time) > time.Minute {
|
||||
t.Errorf("deleted_at = %+v, want a recent timestamp", deletedAt)
|
||||
}
|
||||
// The credentials are free: a new account can claim the same email.
|
||||
other, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Other", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision other: %v", err)
|
||||
}
|
||||
if err := store.AttachIdentity(ctx, other.ID, account.KindEmail, email, true); err != nil {
|
||||
t.Fatalf("email should be free after deletion, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user