f8aeec8008
A guest was provisioned with only its detected time zone; its PreferredLanguage fell to the 'en' column default. The client already sends its locale in the guest login (GuestLoginRequest.locale), but the gateway dropped it and ProvisionGuest took no language — so a Russian user's brand-new native guest got English language-dependent server content (the ad banner, bot messages) until the client's later language reconcile. Read the locale in the gateway guest handler, thread it through GuestAuth -> ProvisionGuest, and seed PreferredLanguage from it (validated; an unsupported/absent value keeps the 'en' default). Wire field already present — no schema change.
323 lines
11 KiB
Go
323 lines
11 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|