mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)
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
This commit is contained in:
+23
-7
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+29
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user