251c7af3f6
The user-detail console gains a Deletion & retention panel: last login (time + IP), the tombstone (deleted-at + retained real name), and the retention journal (the legal dossier of detached credentials). A Delete-user action runs the same deletion orchestration as the in-app flow (mirrors the email-erase pattern). Store readers RetainedIdentities + DeletionInfo back the view; integration test covers them.
221 lines
7.3 KiB
Go
221 lines
7.3 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/accountdelete"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestDeletionDossierReaders: after deletion the admin readers expose the credential
|
|
// journal and the tombstone dossier.
|
|
func TestDeletionDossierReaders(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)
|
|
}
|
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "dos-"+uuid.NewString()+"@example.com", true); err != nil {
|
|
t.Fatalf("attach email: %v", err)
|
|
}
|
|
if err := deleter.AnonymizeAndTombstone(ctx, acc.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
|
|
rets, err := store.RetainedIdentities(ctx, acc.ID)
|
|
if err != nil || len(rets) != 2 {
|
|
t.Fatalf("RetainedIdentities = (%+v, %v), want 2 rows", rets, err)
|
|
}
|
|
for _, r := range rets {
|
|
if r.Reason != "delete" {
|
|
t.Errorf("retained reason = %q, want delete", r.Reason)
|
|
}
|
|
}
|
|
info, err := store.DeletionInfo(ctx, acc.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeletionInfo: %v", err)
|
|
}
|
|
if info.DeletedAt == nil {
|
|
t.Error("DeletionInfo.DeletedAt should be set")
|
|
}
|
|
if info.DeletedDisplayName != "Иван" {
|
|
t.Errorf("DeletionInfo.DeletedDisplayName = %q, want Иван", info.DeletedDisplayName)
|
|
}
|
|
}
|
|
|
|
// TestDropAllRobotGames drops the deletee's solo vs-AI game but keeps a game with a human
|
|
// opponent.
|
|
func TestDropAllRobotGames(t *testing.T) {
|
|
ctx := context.Background()
|
|
gsvc := newGameService()
|
|
robots := newRobotService(t, gsvc)
|
|
if err := robots.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool: %v", err)
|
|
}
|
|
mm := newMatchmaker(t, robots, time.Minute, 0)
|
|
deleter := accountdelete.NewDeleter(testDB)
|
|
|
|
user := provisionAccount(t)
|
|
other := provisionAccount(t)
|
|
|
|
aiRes, err := mm.StartVsAI(ctx, user, engine.VariantEnglish, true)
|
|
if err != nil {
|
|
t.Fatalf("start vs AI: %v", err)
|
|
}
|
|
humanGame, err := gsvc.Create(ctx, game.CreateParams{
|
|
Variant: engine.VariantEnglish, Seats: []uuid.UUID{user, other}, TurnTimeout: time.Hour, Seed: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create human game: %v", err)
|
|
}
|
|
|
|
n, err := deleter.DropAllRobotGames(ctx, user)
|
|
if err != nil {
|
|
t.Fatalf("drop: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("dropped %d games, want 1 (the vs-AI game)", n)
|
|
}
|
|
if _, err := gsvc.GameByID(ctx, aiRes.Game.ID); err == nil {
|
|
t.Error("the vs-AI game should be dropped")
|
|
}
|
|
if _, err := gsvc.GameByID(ctx, humanGame.ID); err != nil {
|
|
t.Errorf("the human game should be kept, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteStepUpEmail: an email account's delete code verifies (wrong code rejected, no
|
|
// deeplink in the mail); a platform-only account has no email and cannot request a code.
|
|
func TestDeleteStepUpEmail(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := account.NewStore(testDB)
|
|
mailer := &capturingMailer{}
|
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
|
|
|
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "del-"+uuid.NewString()+"@example.com", true); err != nil {
|
|
t.Fatalf("attach email: %v", err)
|
|
}
|
|
if has, err := svc.HasEmail(ctx, acc.ID); err != nil || !has {
|
|
t.Fatalf("HasEmail = (%v, %v), want true", has, err)
|
|
}
|
|
if err := svc.RequestDeleteCode(ctx, acc.ID); err != nil {
|
|
t.Fatalf("request delete code: %v", err)
|
|
}
|
|
if strings.Contains(mailer.lastBody, "/confirm/") {
|
|
t.Error("a delete email must not carry a one-tap deeplink")
|
|
}
|
|
code := sixDigit.FindString(mailer.lastBody)
|
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, "000000"); err == nil {
|
|
t.Error("a wrong delete code must be rejected")
|
|
}
|
|
if err := svc.VerifyDeleteCode(ctx, acc.ID, code); err != nil {
|
|
t.Fatalf("verify correct delete code: %v", err)
|
|
}
|
|
|
|
noEmail, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("provision no-email: %v", err)
|
|
}
|
|
if has, _ := svc.HasEmail(ctx, noEmail.ID); has {
|
|
t.Error("HasEmail must be false for a platform-only account")
|
|
}
|
|
if err := svc.RequestDeleteCode(ctx, noEmail.ID); !errors.Is(err, account.ErrNoEmail) {
|
|
t.Errorf("request delete for no-email account = %v, want ErrNoEmail", err)
|
|
}
|
|
}
|