//go:build test package twofa // Tests for the shared 2FA verification core (the package the payments // card-access gate — B6/B10 — imports for real-challenge verification). // The pepper provider is never registered here (handlers/user's build-tagged // files register it), so Hash falls back to the legacy plain SHA-256 digest — // which is exactly what the seeded pending-code hashes use. import ( "context" "database/sql" "testing" "time" "crussell/clock" "crussell/db" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" ) func seedPending(t *testing.T, ctx context.Context, tx db.Querier, userID, code string) { t.Helper() _, err := tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = $3 WHERE id = $1`, userID, Hash(code), clock.Now().Add(10*time.Minute)) require.NoError(t, err) } func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // consume=false (interactive setup/disable path): a success keeps the // pending code valid, so a wrong follow-up code reports ErrIncorrect. userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID, "123456") require.NoError(t, VerifyForUser(ctx, userID, "123456", DeferredConsume), "correct code must verify") require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", DeferredConsume), ErrIncorrect) // consume=true (payments saved-card gate path): a success DESTROYS the // pending code, so re-verifying the same code reports ErrMissingOrExpired // — a verified code is single-use and cannot authorize a second charge. userID2, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID2, "123456") require.NoError(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), "correct code must verify") require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), ErrMissingOrExpired, "a consumed code must be single-use") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID2).Scan(&pendingHash)) require.False(t, pendingHash.Valid, "a consumed code must be NULLed in the DB") } func TestVerifyForUser_LockoutAndMissing(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID, "123456") // Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap. require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", ConsumeOnVerify), ErrIncorrect) for i := 0; i < 4; i++ { _ = VerifyForUser(ctx, userID, "999999", ConsumeOnVerify) } require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", ConsumeOnVerify), ErrLockedOut) // A fresh user with no pending code → ErrMissingOrExpired. userID2, err := fixtures.CreateTestUser(tx) require.NoError(t, err) require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", ConsumeOnVerify), ErrMissingOrExpired) } // TestVerifyForUser_ConsumeOnVerifyConcurrency pins the finding-1 contract: a // code verified with ConsumeOnVerify authorizes exactly ONE operation. Even // though the per-user mutex serializes the critical section (so no test can // actually race it), the observable guarantee is that the first verify burns the // code and any subsequent verify of the same code fails with // ErrMissingOrExpired — two concurrent charge gates can never both pass. func TestVerifyForUser_ConsumeOnVerifyConcurrency(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID, "424242") // Two "concurrent" charge-gate verifies of the same code, serialized by // StateFor's per-user mutex exactly as the payments gate would experience // them. Only the first may succeed. require.NoError(t, VerifyForUser(ctx, userID, "424242", ConsumeOnVerify), "first charge gate must verify") require.ErrorIs(t, VerifyForUser(ctx, userID, "424242", ConsumeOnVerify), ErrMissingOrExpired, "second charge gate with the same code must fail — one code, one charge") } // TestVerifyForUser_SuccessClearsLoginLockout pins LOW 6b: a successful 2FA // verify lifts any password-guessing login lockout (users.failed_attempts / // locked_until) because a correct code proves control of the second factor. func TestVerifyForUser_SuccessClearsLoginLockout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID, "123456") _, err = tx.Exec(ctx, `UPDATE users SET failed_attempts = 9, locked_until = NOW() + INTERVAL '30 minutes' WHERE id = $1`, userID) require.NoError(t, err) require.NoError(t, VerifyForUser(ctx, userID, "123456", ConsumeOnVerify)) var failedAttempts int var lockedUntil *time.Time require.NoError(t, tx.QueryRow(ctx, `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)) require.Zero(t, failedAttempts, "successful 2FA verify must reset the login lockout counter") require.Nil(t, lockedUntil, "successful 2FA verify must clear locked_until") } // TestConsumePendingCode pins the MEDIUM-2 contract: ConsumePendingCode NULLs // the stored pending-code digest and expiry (idempotently), and is the ONLY // place a verified-but-unconsumed code dies on the saved-card charge path. func TestConsumePendingCode(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPending(t, ctx, tx, userID, "123456") // A code verified WITHOUT consuming stays valid (the saved-card charge gate // path, MEDIUM-2) — re-verification must keep working until consumption. require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "verify-without-consume must pass") require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "an unconsumed code must still verify on a same-key retry") require.NoError(t, ConsumePendingCode(ctx, tx, userID), "explicit consumption at charge success must succeed") require.ErrorIs(t, VerifyForUser(ctx, userID, "123456", false), ErrMissingOrExpired, "a consumed code must no longer verify") // Consumption is idempotent — a second call (e.g. a retried completed // charge) is a no-op, never an error. require.NoError(t, ConsumePendingCode(ctx, tx, userID), "consuming an already-consumed code must be a no-op") // An unknown user is a no-op too. require.NoError(t, ConsumePendingCode(ctx, tx, "000000000000")) } // TestVerifyForUser_AttemptStateMapPersists exercises the shared per-user // attempt map directly (the state the payments gate shares with the interactive // endpoints): the map is bounded and a locked-out record is never evicted. func TestVerifyForUser_AttemptStateMapPersists(t *testing.T) { t.Cleanup(func() { MapMu.Lock() Map = make(map[string]*AttemptState) MaxTrackedAttempts = 10_000 MapMu.Unlock() }) MapMu.Lock() Map = make(map[string]*AttemptState) MaxTrackedAttempts = 2 MapMu.Unlock() // Fill the map with locked-out records; a new key must NOT evict one. now := clock.Now() for _, id := range []string{"victim_a", "victim_b"} { st := &AttemptState{} st.SetLastActive(now) st.Count.Store(MaxAttempts) Map[id] = st } _ = StateFor("new_user") // transient, untracked (map full of lockouts) MapMu.Lock() defer MapMu.Unlock() require.Len(t, Map, 2, "locked-out records must survive the cap pressure") }