//go:build integration package inttest import ( "context" "database/sql" "testing" "github.com/google/uuid" "scrabble/backend/internal/account" ) // lastLoginIP reads an account's stamped last-login IP ("" when unset). func lastLoginIP(t *testing.T, accountID uuid.UUID) string { t.Helper() var ip sql.NullString err := testDB.QueryRowContext(context.Background(), "SELECT last_login_ip FROM accounts WHERE account_id = $1", accountID).Scan(&ip) if err != nil { t.Fatalf("read last_login_ip %s: %v", accountID, err) } return ip.String } // retainedRow is one row of the retention journal, read directly for assertions. type retainedRow struct { kind, externalID, reason string } // retainedRows reads the retention journal for an account, oldest detach first. func retainedRows(t *testing.T, accountID uuid.UUID) []retainedRow { t.Helper() rows, err := testDB.QueryContext(context.Background(), "SELECT kind, external_id, reason FROM retained_identities WHERE account_id = $1 ORDER BY detached_at", accountID) if err != nil { t.Fatalf("query retained_identities %s: %v", accountID, err) } defer rows.Close() var out []retainedRow for rows.Next() { var r retainedRow if err := rows.Scan(&r.kind, &r.externalID, &r.reason); err != nil { t.Fatalf("scan retained row: %v", err) } out = append(out, r) } return out } // TestUnlinkJournalsRetainedIdentity: detaching a provider records it in the retention // journal (reason=unlink) before the live identity is removed. func TestUnlinkJournalsRetainedIdentity(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) tgExt := "tg-" + uuid.NewString() acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt) if err != nil { t.Fatalf("provision: %v", err) } email := "keep-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, email, true); err != nil { t.Fatalf("attach email: %v", err) } if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil { t.Fatalf("unlink telegram: %v", err) } got := retainedRows(t, acc.ID) if len(got) != 1 || got[0].kind != account.KindTelegram || got[0].externalID != tgExt || got[0].reason != "unlink" { t.Fatalf("retained rows = %+v, want one unlink telegram %q", got, tgExt) } } // TestChangeEmailJournalsOldAddress: an email change records the outgoing address in the // retention journal (reason=change). func TestChangeEmailJournalsOldAddress(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) } oldAddr := "old-" + uuid.NewString() + "@example.com" if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, oldAddr, true); err != nil { t.Fatalf("attach old email: %v", err) } newAddr := "new-" + uuid.NewString() + "@example.com" if err := svc.RequestChangeCode(ctx, acc.ID, newAddr); err != nil { t.Fatalf("request change: %v", err) } if _, err := svc.ConfirmChange(ctx, acc.ID, newAddr, sixDigit.FindString(mailer.lastBody)); err != nil { t.Fatalf("confirm change: %v", err) } got := retainedRows(t, acc.ID) if len(got) != 1 || got[0].kind != account.KindEmail || got[0].externalID != oldAddr || got[0].reason != "change" { t.Fatalf("retained rows = %+v, want one change email %q", got, oldAddr) } } // TestStampLastLoginThrottles: the first cold-load stamp writes the IP; a second within // the hour is a no-op (throttled), so it costs at most one write per account per hour. func TestStampLastLoginThrottles(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString()) if err != nil { t.Fatalf("provision: %v", err) } if err := store.StampLastLogin(ctx, acc.ID, "1.2.3.4"); err != nil { t.Fatalf("first stamp: %v", err) } if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { t.Fatalf("first stamp ip = %q, want 1.2.3.4", got) } if err := store.StampLastLogin(ctx, acc.ID, "9.9.9.9"); err != nil { t.Fatalf("second stamp: %v", err) } if got := lastLoginIP(t, acc.ID); got != "1.2.3.4" { t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got) } }