52e6378f40
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.
93 lines
3.0 KiB
Go
93 lines
3.0 KiB
Go
//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)
|
|
}
|
|
}
|