//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) } } // listHasID reports whether the user list contains accountID. func listHasID(items []account.UserListItem, id uuid.UUID) bool { for _, it := range items { if it.ID == id { return true } } return false } // TestUserListDeletedFilter: a tombstoned account is hidden from the default People list and // shown only under the Deleted scope. func TestUserListDeletedFilter(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) deleter := accountdelete.NewDeleter(testDB) live, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Live", "") if err != nil { t.Fatalf("provision live: %v", err) } goneTg := "tg-" + uuid.NewString() gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "") if err != nil { t.Fatalf("provision gone: %v", err) } goneEmail := "gone-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil { t.Fatalf("attach gone email: %v", err) } if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil { t.Fatalf("delete: %v", err) } people, err := store.ListUsers(ctx, account.UserFilter{}, 5000, 0) if err != nil { t.Fatalf("list people: %v", err) } if listHasID(people, gone.ID) { t.Error("a deleted account must not appear in the default People list") } if !listHasID(people, live.ID) { t.Error("a live account must appear in the default People list") } deleted, err := store.ListUsers(ctx, account.UserFilter{Deleted: true}, 5000, 0) if err != nil { t.Fatalf("list deleted: %v", err) } if !listHasID(deleted, gone.ID) { t.Error("a deleted account must appear in the Deleted list") } if listHasID(deleted, live.ID) { t.Error("a live account must not appear in the Deleted list") } // A search spans both lists and reaches the retention journal: a deleted account is // still found by the email and external id it held (both moved to retained_identities // on deletion, out of the live identities table). byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0) if err != nil { t.Fatalf("search by email: %v", err) } if !listHasID(byEmail, gone.ID) { t.Error("a deleted account must be found by the email it held (retention journal)") } byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0) if err != nil { t.Fatalf("search by external id: %v", err) } if !listHasID(byExt, gone.ID) { t.Error("a deleted account must be found by the external id it held (retention journal)") } } // TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to // a durable account (defence-in-depth — no confirmed-email path leaves is_guest set). func TestConfirmCodeClearsGuest(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) mailer := &capturingMailer{} svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") guest, err := store.ProvisionGuest(ctx, "", "") if err != nil { t.Fatalf("provision guest: %v", err) } email := "cc-" + uuid.NewString() + "@example.com" if err := svc.RequestCode(ctx, guest.ID, email); err != nil { t.Fatalf("request code: %v", err) } if _, err := svc.ConfirmCode(ctx, guest.ID, email, sixDigit.FindString(mailer.lastBody)); err != nil { t.Fatalf("confirm code: %v", err) } after, err := store.GetByID(ctx, guest.ID) if err != nil { t.Fatalf("load: %v", err) } if after.IsGuest { t.Error("confirming an email must clear the guest flag") } } // 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) } }