From 6a63fba1888fb0d7aab2bdee7a368269e11943c8 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 4 Jul 2026 00:45:48 -0400 Subject: [PATCH] feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the column until Wave 3, so this is behaviourally a no-op and mergeable early. - Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host account is write-locked on deploy (inverted vs password_set's conditional backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it. - SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a verified timestamp unless UserCreate.Unverified is explicitly requested (only the future cloud self-serve branch will set that). A missed call site fails SAFE (verified), not write-locked. - models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled). - Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers scan) so the admin user list keeps working. - Expose derived email_verified bool in sessionUserPayload for a later wave. Gates: make check + make test-pg both green (dual-dialect verified). Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST --- internal/models/user.go | 30 ++- internal/models/user_test.go | 26 +++ internal/server/handlers_auth.go | 15 +- .../store/migrations/070_email_verified.sql | 25 +++ .../store/pgmigrations/048_email_verified.sql | 24 ++ internal/store/users.go | 40 +++- internal/store/users_email_verified_test.go | 207 ++++++++++++++++++ 7 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 internal/models/user_test.go create mode 100644 internal/store/migrations/070_email_verified.sql create mode 100644 internal/store/pgmigrations/048_email_verified.sql create mode 100644 internal/store/users_email_verified_test.go diff --git a/internal/models/user.go b/internal/models/user.go index c53d5e08..7f96e9e7 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -19,13 +19,14 @@ type User struct { RecoveryCodes string `json:"-"` // Never serialized Plan string `json:"plan"` // "free", "pro", or "self-hosted" PlanExpiresAt string `json:"plan_expires_at,omitempty"` - StripeCustomerID string `json:"-"` // Never serialized - PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits - OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] - PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash) - DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled - LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write) - LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543. + StripeCustomerID string `json:"-"` // Never serialized + PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits + OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] + PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash) + DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled + EmailVerifiedAt string `json:"email_verified_at,omitempty"` // Non-empty = email verified (mirror DisabledAt). Empty/NULL = unverified. PLAN-1933 / TASK-1935. + LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write) + LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543. CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -35,6 +36,13 @@ func (u *User) IsDisabled() bool { return u.DisabledAt != "" } +// IsEmailVerified returns true if the user's email address has been verified. +// Mirrors IsDisabled: a non-empty EmailVerifiedAt timestamp means verified, +// empty (NULL in the DB) means unverified. PLAN-1933 / TASK-1935. +func (u *User) IsEmailVerified() bool { + return u.EmailVerifiedAt != "" +} + // GetOAuthProviders parses the JSON oauth_providers field into a string slice. func (u *User) GetOAuthProviders() []string { if u.OAuthProviders == "" { @@ -72,6 +80,14 @@ type UserCreate struct { Name string `json:"name"` Password string `json:"password"` // Plaintext, will be hashed Role string `json:"role,omitempty"` // Defaults to "member" + + // Unverified requests that the created user start with an UNVERIFIED email + // (email_verified_at = NULL). The zero value (false) yields a VERIFIED user, + // so a call site that forgets to set it fails SAFE — verified, not + // write-locked (DR-3). The ONLY path that sets this true is the future + // cloud self-serve signup branch (PLAN-1933 Wave 3), which does not exist + // yet. Not settable via the request body (json:"-") — the handler decides. + Unverified bool `json:"-"` } // UserUpdate is the input for updating user profile fields. diff --git a/internal/models/user_test.go b/internal/models/user_test.go new file mode 100644 index 00000000..5e18bb32 --- /dev/null +++ b/internal/models/user_test.go @@ -0,0 +1,26 @@ +package models + +import "testing" + +// TestIsEmailVerified locks in the mirror-of-IsDisabled contract: a non-empty +// EmailVerifiedAt means verified, empty (the zero value / NULL round-trip) +// means unverified. PLAN-1933 / TASK-1935. +func TestIsEmailVerified(t *testing.T) { + cases := []struct { + name string + emailVerifiedAt string + want bool + }{ + {"empty is unverified", "", false}, + {"timestamp is verified", "2026-07-04T12:00:00Z", true}, + {"any non-empty string is verified", "x", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u := &User{EmailVerifiedAt: c.emailVerifiedAt} + if got := u.IsEmailVerified(); got != c.want { + t.Errorf("IsEmailVerified() with EmailVerifiedAt=%q = %v, want %v", c.emailVerifiedAt, got, c.want) + } + }) + } +} diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 9890b1f7..22862203 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -63,13 +63,14 @@ func sessionUserPayload(user *models.User) map[string]interface{} { return nil } return map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "username": user.Username, - "name": user.Name, - "role": user.Role, - "totp_enabled": user.TOTPEnabled, - "plan": user.Plan, + "id": user.ID, + "email": user.Email, + "username": user.Username, + "name": user.Name, + "role": user.Role, + "totp_enabled": user.TOTPEnabled, + "plan": user.Plan, + "email_verified": user.IsEmailVerified(), } } diff --git a/internal/store/migrations/070_email_verified.sql b/internal/store/migrations/070_email_verified.sql new file mode 100644 index 00000000..17dbb82b --- /dev/null +++ b/internal/store/migrations/070_email_verified.sql @@ -0,0 +1,25 @@ +-- Add email_verified_at column for email verification (PLAN-1933 Wave 1 / TASK-1935). +-- NULL = unverified, non-NULL = the time the email was verified. Mirrors +-- disabled_at's nullable-timestamp shape. Pure infra — nothing reads this column +-- until Wave 3, so this migration is a no-op behaviourally. +-- +-- NOTE: no `IF NOT EXISTS` — SQLite's ALTER TABLE ADD COLUMN rejects it. +-- NOTE: no expression DEFAULT either — SQLite forbids a parenthesised/CURRENT_* +-- default on ADD COLUMN, so the column defaults to NULL. The SAFE default +-- (verified) is enforced in the application layer instead: store.CreateUser / +-- CreateOAuthUser write a verified timestamp unless a creation path explicitly +-- requests unverified. The ONLY path that will ever leave this NULL is the +-- future cloud self-serve signup branch (Wave 3), which does not exist yet. +ALTER TABLE users ADD COLUMN email_verified_at TEXT; + +-- Backfill: UNCONDITIONALLY mark EVERY existing row verified. Existing / OAuth / +-- self-host accounts predate email verification and must NOT be write-locked on +-- deploy. This is INVERTED vs password_set's conditional (oauth-aware) backfill: +-- there is no "was this user ever verified?" signal to key on, and the correct +-- answer for every pre-existing account is "verified". Emit RFC3339 with a 'Z' +-- suffix so Go's time.Parse(time.RFC3339, …) / store.parseTime reads it back +-- (the default datetime(...) 'YYYY-MM-DD HH:MM:SS' output is space-separated and +-- un-parseable by RFC3339) — same convention as disabled_at / created_at. +UPDATE users +SET email_verified_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') +WHERE email_verified_at IS NULL; diff --git a/internal/store/pgmigrations/048_email_verified.sql b/internal/store/pgmigrations/048_email_verified.sql new file mode 100644 index 00000000..d3adda24 --- /dev/null +++ b/internal/store/pgmigrations/048_email_verified.sql @@ -0,0 +1,24 @@ +-- Add email_verified_at column for email verification (PLAN-1933 Wave 1 / TASK-1935). +-- NULL = unverified, non-NULL = the time the email was verified. Mirrors +-- disabled_at's nullable-timestamp shape. TEXT (not TIMESTAMP) to match the +-- codebase's RFC3339-string convention for user timestamps. Pure infra — +-- nothing reads this column until Wave 3. +-- +-- The SAFE default (verified) is enforced in the application layer +-- (store.CreateUser / CreateOAuthUser), NOT via a DB DEFAULT, so both dialects +-- stay symmetric and the fail-safe lives in one testable place. +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TEXT; + +-- Backfill: UNCONDITIONALLY mark EVERY existing row verified. Existing / OAuth / +-- self-host accounts predate email verification and must NOT be write-locked on +-- deploy. This is INVERTED vs password_set's conditional (oauth-aware) backfill. +-- Emit RFC3339 UTC with a 'Z' suffix so Go's time.Parse(time.RFC3339, …) reads +-- it back. now() AT TIME ZONE 'UTC' yields a naive UTC timestamp that to_char +-- formats as-is, and the hardcoded 'Z' labels it UTC regardless of server +-- locale (the plain ::text cast would be space-separated and un-parseable). +UPDATE users +SET email_verified_at = to_char( + now() AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS"Z"' +) +WHERE email_verified_at IS NULL; diff --git a/internal/store/users.go b/internal/store/users.go index c5ba23b2..c98d9cea 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -27,7 +27,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`) var bcryptCost = 12 // user SELECT columns — used by all user queries. -const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, last_active_at, last_write_at, created_at, updated_at` +const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, email_verified_at, last_active_at, last_write_at, created_at, updated_at` // scanUser scans a user row into a User struct. // Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after @@ -36,17 +36,20 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error) var u models.User var createdAt, updatedAt string - var disabledAt, lastActiveAt, lastWriteAt sql.NullString + var disabledAt, emailVerifiedAt, lastActiveAt, lastWriteAt sql.NullString err := row.Scan( &u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes, &u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders, &u.PasswordSet, - &disabledAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt, + &disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt, ) if disabledAt.Valid { u.DisabledAt = disabledAt.String } + if emailVerifiedAt.Valid { + u.EmailVerifiedAt = emailVerifiedAt.String + } if lastActiveAt.Valid { u.LastActiveAt = lastActiveAt.String } @@ -93,10 +96,20 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) { id := newID() ts := now() + // Email-verification default is SAFE = verified (DR-3). Every creation path + // yields a verified user unless it explicitly requests unverified via + // UserCreate.Unverified. Today only the future cloud self-serve signup + // branch (PLAN-1933 Wave 3) sets that; every current call site inherits + // verified. A nil interface binds as a NULL column (= unverified). + var emailVerifiedAt interface{} + if !input.Unverified { + emailVerifiedAt = ts + } + _, err = s.db.Exec(s.q(` - INSERT INTO users (id, email, username, name, password_hash, role, password_set, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, true, ts, ts) + INSERT INTO users (id, email, username, name, password_hash, role, password_set, email_verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, true, emailVerifiedAt, ts, ts) if err != nil { return nil, fmt.Errorf("insert user: %w", err) } @@ -465,7 +478,7 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul for rows.Next() { var entry AdminUserListEntry var createdAt, updatedAt string - var disabledAt, lastActiveAt, lastWriteAt sql.NullString + var disabledAt, emailVerifiedAt, lastActiveAt, lastWriteAt sql.NullString var workspaceCount int var storageBytes int64 if err := rows.Scan( @@ -473,7 +486,7 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul &entry.TOTPSecret, &entry.TOTPEnabled, &entry.RecoveryCodes, &entry.Plan, &entry.PlanExpiresAt, &entry.StripeCustomerID, &entry.PlanOverrides, &entry.OAuthProviders, &entry.PasswordSet, - &disabledAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt, + &disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt, &workspaceCount, &storageBytes, ); err != nil { return nil, fmt.Errorf("search users scan: %w", err) @@ -481,6 +494,9 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul if disabledAt.Valid { entry.DisabledAt = disabledAt.String } + if emailVerifiedAt.Valid { + entry.EmailVerifiedAt = emailVerifiedAt.String + } if lastActiveAt.Valid { entry.LastActiveAt = lastActiveAt.String } @@ -648,10 +664,12 @@ func (s *Store) CreateOAuthUser(email, name, avatarURL string) (*models.User, er return nil, fmt.Errorf("generate username: %w", err) } + // OAuth users are always email-verified (the provider asserted the address); + // this matches DR-3's "OAuth = verified" and the SAFE default. _, err = s.db.Exec(s.q(` - INSERT INTO users (id, email, username, name, password_hash, role, avatar_url, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), id, strings.ToLower(strings.TrimSpace(email)), username, strings.TrimSpace(name), string(hash), "member", avatarURL, ts, ts) + INSERT INTO users (id, email, username, name, password_hash, role, avatar_url, email_verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), id, strings.ToLower(strings.TrimSpace(email)), username, strings.TrimSpace(name), string(hash), "member", avatarURL, ts, ts, ts) if err != nil { return nil, fmt.Errorf("insert oauth user: %w", err) } diff --git a/internal/store/users_email_verified_test.go b/internal/store/users_email_verified_test.go new file mode 100644 index 00000000..387bb7a3 --- /dev/null +++ b/internal/store/users_email_verified_test.go @@ -0,0 +1,207 @@ +package store + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// emailVerifiedBackfillSQL reads the ACTUAL backfill statement out of the +// embedded migration file (070 for SQLite, 048 for Postgres) so the backfill +// test exercises the real dialect-specific DML rather than a hand-copied +// duplicate. The ALTER TABLE line is dropped — the column already exists +// (migration ran at store open) and SQLite's ADD COLUMN would error on +// re-apply — leaving just the `UPDATE users SET email_verified_at ...` chunk. +func emailVerifiedBackfillSQL(t *testing.T) string { + t.Helper() + var ( + data []byte + err error + ) + if os.Getenv("PAD_TEST_POSTGRES_URL") != "" { + data, err = pgMigrationsFS.ReadFile("pgmigrations/048_email_verified.sql") + } else { + data, err = migrationsFS.ReadFile("migrations/070_email_verified.sql") + } + if err != nil { + t.Fatalf("read email_verified migration: %v", err) + } + // The backfill UPDATE is the FINAL statement in the file. Take everything + // from the last "UPDATE users" through end-of-file so the test runs the real + // DML without re-executing the ALTER (which SQLite rejects on re-apply). + // Indexing from the LAST occurrence avoids tripping on the word appearing in + // a preceding comment — and, unlike a naive split on ';', is immune to + // semicolons inside comment prose. + full := string(data) + idx := strings.LastIndex(strings.ToUpper(full), "UPDATE USERS") + if idx < 0 { + t.Fatalf("no UPDATE users statement found in email_verified migration:\n%s", full) + } + return full[idx:] +} + +// insertLegacyUnverifiedUser inserts a user row with email_verified_at = NULL, +// simulating an account that existed BEFORE migration 070 added + backfilled +// the column. CreateUser can't produce this state directly (its SAFE default +// is verified), so we go straight to SQL. +func insertLegacyUnverifiedUser(t *testing.T, s *Store, email string) string { + t.Helper() + id := newID() + ts := now() + if _, err := s.db.Exec(s.q(` + INSERT INTO users (id, email, username, name, password_hash, role, email_verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) + `), id, email, "u_"+id, "Legacy "+id, "x", "member", ts, ts); err != nil { + t.Fatalf("insert legacy unverified user: %v", err) + } + return id +} + +// TestEmailVerifiedBackfill covers DR-3's UNCONDITIONAL backfill: every row +// that predates the column must come out VERIFIED so no existing / OAuth / +// self-host account is write-locked on deploy. Runs the real migration DML +// against pre-existing NULL rows (both dialects via make test-pg). +func TestEmailVerifiedBackfill(t *testing.T) { + s := testStore(t) + + legacy := []string{ + insertLegacyUnverifiedUser(t, s, "legacy1@example.com"), + insertLegacyUnverifiedUser(t, s, "legacy2@example.com"), + } + + // Precondition: the legacy rows start unverified (NULL round-trips as ""). + for _, id := range legacy { + u, err := s.GetUser(id) + if err != nil { + t.Fatalf("GetUser(%s): %v", id, err) + } + if u.IsEmailVerified() { + t.Fatalf("precondition failed: legacy row %s should start unverified, got %q", id, u.EmailVerifiedAt) + } + } + + // Run the migration's backfill statement. + if _, err := s.db.Exec(emailVerifiedBackfillSQL(t)); err != nil { + t.Fatalf("run backfill DML: %v", err) + } + + // Postcondition: every pre-existing row is now verified with a timestamp + // that Go's time.Parse(time.RFC3339, …) can read back (the parse round-trip + // is the whole point of the strftime/to_char 'Z'-suffixed formats). + for _, id := range legacy { + u, err := s.GetUser(id) + if err != nil { + t.Fatalf("GetUser(%s) after backfill: %v", id, err) + } + if !u.IsEmailVerified() { + t.Errorf("backfill did not verify legacy row %s (EmailVerifiedAt=%q)", id, u.EmailVerifiedAt) + } + if _, err := time.Parse(time.RFC3339, u.EmailVerifiedAt); err != nil { + t.Errorf("backfilled timestamp for %s is not RFC3339: %q (%v)", id, u.EmailVerifiedAt, err) + } + } +} + +// TestCreateUserEmailVerifiedDefault covers DR-3's SAFE default: a plain +// CreateUser yields a VERIFIED user, and only an explicit Unverified request +// (the future cloud self-serve branch) produces an unverified one. +func TestCreateUserEmailVerifiedDefault(t *testing.T) { + s := testStore(t) + + // Default: verified. + u := createTestUser(t, s, "default@example.com", "Default", "password123") + if !u.IsEmailVerified() { + t.Errorf("CreateUser default should be VERIFIED (safe default), got EmailVerifiedAt=%q", u.EmailVerifiedAt) + } + if _, err := time.Parse(time.RFC3339, u.EmailVerifiedAt); err != nil { + t.Errorf("default-verified timestamp not RFC3339: %q (%v)", u.EmailVerifiedAt, err) + } + + // Explicit Unverified=true: unverified, and it must persist as NULL (not + // just live on the returned struct). + unv, err := s.CreateUser(models.UserCreate{ + Email: "unverified@example.com", Name: "Unv", Password: "password123", Unverified: true, + }) + if err != nil { + t.Fatalf("CreateUser(Unverified): %v", err) + } + if unv.IsEmailVerified() { + t.Errorf("CreateUser with Unverified=true should be UNVERIFIED, got EmailVerifiedAt=%q", unv.EmailVerifiedAt) + } + if got, _ := s.GetUser(unv.ID); got == nil || got.IsEmailVerified() { + t.Errorf("unverified user should stay unverified after re-fetch, got %+v", got) + } +} + +// TestCreateOAuthUserVerified covers DR-3's "OAuth = verified" rule. +func TestCreateOAuthUserVerified(t *testing.T) { + s := testStore(t) + u, err := s.CreateOAuthUser("oauth@example.com", "OAuth User", "https://example.com/a.png") + if err != nil { + t.Fatalf("CreateOAuthUser: %v", err) + } + if !u.IsEmailVerified() { + t.Errorf("OAuth users must be verified, got EmailVerifiedAt=%q", u.EmailVerifiedAt) + } +} + +// TestEmailVerifiedBothScanPaths guards the two-scan-site foot-gun: the field +// must round-trip through scanUser (GetUser/ListUsers) AND the inline scan in +// SearchUsers. Missing the second breaks the admin user list at runtime with a +// column/target mismatch, which a compile check would NOT catch. +func TestEmailVerifiedBothScanPaths(t *testing.T) { + s := testStore(t) + + verified := createTestUser(t, s, "scanverified@example.com", "V", "password123") + unverifiedID := insertLegacyUnverifiedUser(t, s, "scanunverified@example.com") + + // --- scanUser path (GetUser) --- + if got, err := s.GetUser(verified.ID); err != nil || got == nil || !got.IsEmailVerified() { + t.Fatalf("scanUser (GetUser) verified: err=%v got=%+v", err, got) + } + if got, err := s.GetUser(unverifiedID); err != nil || got == nil || got.IsEmailVerified() { + t.Fatalf("scanUser (GetUser) unverified: err=%v got=%+v", err, got) + } + + // --- SearchUsers inline scan path --- + res, err := s.SearchUsers(AdminUserSearchParams{}) + if err != nil { + t.Fatalf("SearchUsers: %v", err) + } + byEmail := map[string]AdminUserListEntry{} + for _, u := range res.Users { + byEmail[u.Email] = u + } + if e, ok := byEmail["scanverified@example.com"]; !ok || !e.IsEmailVerified() { + t.Errorf("SearchUsers inline scan: verified user missing/unverified (ok=%v EmailVerifiedAt=%q)", ok, e.EmailVerifiedAt) + } + if e, ok := byEmail["scanunverified@example.com"]; !ok || e.IsEmailVerified() { + t.Errorf("SearchUsers inline scan: unverified user missing/verified (ok=%v EmailVerifiedAt=%q)", ok, e.EmailVerifiedAt) + } +} + +// TestListUsersEmailVerified is a lightweight extra assertion that the +// ListUsers scanUser path also surfaces the field (defensive; scanUser is +// shared, but ListUsers is the admin-facing bulk reader). +func TestListUsersEmailVerified(t *testing.T) { + s := testStore(t) + for i := 0; i < 3; i++ { + createTestUser(t, s, fmt.Sprintf("list%d@example.com", i), "L", "password123") + } + users, err := s.ListUsers() + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 3 { + t.Fatalf("want 3 users, got %d", len(users)) + } + for _, u := range users { + if !u.IsEmailVerified() { + t.Errorf("ListUsers: user %s should be verified, got EmailVerifiedAt=%q", u.Email, u.EmailVerifiedAt) + } + } +}