//go:build integration package inttest import ( "context" "database/sql" "encoding/json" "errors" "net/http" "strings" "testing" "time" "github.com/google/uuid" "go.uber.org/zap/zaptest" "scrabble/backend/internal/account" "scrabble/backend/internal/accountdelete" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/server" "scrabble/backend/internal/social" ) // 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) } } // TestDeletedAccountRefusesSocialActions: once an opponent's account is deleted, neither a // friend request nor a block may target it — the client hides both controls on a deleted // seat, and the server refuses them for an older client. The seat's deleted mark that drives // that hiding is resolved by Store.DeletedIDs. func TestDeletedAccountRefusesSocialActions(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) svc := newSocialService() deleter := accountdelete.NewDeleter(testDB) _, seats := newGameWithSeats(t, 2) // a shared game: the befriend-an-opponent gate viewer, gone := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, viewer, gone); err != nil { t.Fatalf("friend request before deletion = %v, want nil", err) } if err := deleter.AnonymizeAndTombstone(ctx, gone); err != nil { t.Fatalf("delete: %v", err) } // Deletion dropped the pending request with the rest of the account's social rows, so // this is a fresh request against the tombstone — exactly what an older client sends // after the 🤝 reappears on the finished game's scoreboard. if err := svc.SendFriendRequest(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) { t.Errorf("friend request to a deleted account = %v, want ErrAccountDeleted", err) } if err := svc.Block(ctx, viewer, gone); !errors.Is(err, social.ErrAccountDeleted) { t.Errorf("block of a deleted account = %v, want ErrAccountDeleted", err) } // The live opponent stays actionable — the guard is per-account, not a blanket switch. live := provisionAccount(t) if err := svc.Block(ctx, viewer, live); err != nil { t.Errorf("block of a live account = %v, want nil", err) } deleted, err := store.DeletedIDs(ctx, []uuid.UUID{viewer, gone}) if err != nil { t.Fatalf("deleted ids: %v", err) } if !deleted[gone] { t.Error("the deleted account must be marked deleted") } if deleted[viewer] { t.Error("a live account must not be marked deleted") } } // TestGameStateMarksDeletedSeat: the per-viewer game state marks the seat of a deleted // opponent, which is what hides the in-game social controls (add-friend, block, chat, nudge) // on the client; the viewer's own live seat stays unmarked. func TestGameStateMarksDeletedSeat(t *testing.T) { ctx := context.Background() games := newGameService() seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)} g, err := games.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create game: %v", err) } srv := server.New(":0", server.Deps{ Logger: zaptest.NewLogger(t), DB: testDB, Accounts: account.NewStore(testDB), Games: games, }) viewer, gone := seats[0], seats[1] if marks := stateSeatDeleted(t, srv, g.ID, viewer); marks[0] || marks[1] { t.Fatalf("before deletion no seat may be marked deleted, got %v", marks) } if err := accountdelete.NewDeleter(testDB).AnonymizeAndTombstone(ctx, gone); err != nil { t.Fatalf("delete: %v", err) } marks := stateSeatDeleted(t, srv, g.ID, viewer) if marks[0] { t.Error("the viewer's own live seat must not be marked deleted") } if !marks[1] { t.Error("the deleted opponent's seat must be marked deleted") } } // stateSeatDeleted fetches viewer's game state and returns the seats' deleted marks, indexed // by seat. func stateSeatDeleted(t *testing.T, srv *server.Server, gameID, viewer uuid.UUID) map[int]bool { t.Helper() rec := userGet(t, srv, "/api/v1/user/games/"+gameID.String()+"/state", viewer) if rec.Code != http.StatusOK { t.Fatalf("game state = %d (%s), want 200", rec.Code, rec.Body.String()) } var body struct { Game struct { Seats []struct { Seat int `json:"seat"` Deleted bool `json:"deleted"` } `json:"seats"` } `json:"game"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode game state: %v", err) } out := map[int]bool{} for _, s := range body.Game.Seats { out[s.Seat] = s.Deleted } return out } // 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) } }