//go:build test package user // Tests for the loose-fake 2FA endpoints (GET /api/user/2fa/status, // POST /api/user/2fa/setup|verify|disable). Every test that flips // REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv must stay sequential (no // t.Parallel): os.Getenv is process-global and t.Setenv panics under // t.Parallel. Sequential tests in this package run before the parallel batch, // so the enforced env never leaks into parallel tests. import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "log" "net/http" "net/http/httptest" "os" "regexp" "testing" "crussell/clock" "crussell/db" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" ) func twofaEnvEnforced(t *testing.T) { t.Helper() t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "production") } func twofaEnvUnenforced(t *testing.T) { t.Helper() // Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED // (fail-closed), so an unenforced test must opt in via an explicit dev value. t.Setenv("REQUIRE_2FA", "") t.Setenv("SQUARE_ENVIRONMENT", "mock") } // performUser2FARequest invokes a handler with the authenticated-user context // injected directly (the profile_test.go pattern). An empty userID simulates an // unauthenticated request (no mw.UserIDKey in context). func performUser2FARequest(t *testing.T, handler http.HandlerFunc, ctx context.Context, method, path string, body any, userID string) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != nil { b, err := json.Marshal(body) require.NoError(t, err) req = httptest.NewRequest(method, path, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if userID != "" { req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID)) } else { req = req.WithContext(ctx) } w := httptest.NewRecorder() handler(w, req) return w } // seedPendingTwoFA writes a known verification code's SHA-256 hash plus a fresh // expiry into the user's pending columns, so enforced-mode verify tests don't // depend on reading the logged code. func seedPendingTwoFA(t *testing.T, ctx context.Context, q db.Querier, userID, code string) { t.Helper() _, err := q.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, hashTwoFACode(code), clock.Now().Add(twoFAPendingExpiry)) require.NoError(t, err) } func TestTwoFAStatus_NotEnabled(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.False(t, resp.Enabled, "fresh user must report 2FA disabled") require.False(t, resp.Required, "unenforced env must report required=false") require.Nil(t, resp.Method) } func TestTwoFAStatus_Required(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.False(t, resp.Enabled) require.True(t, resp.Required, "enforced env must report required=true") } func TestTwoFASetup_InvalidMethod(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "carrier-pigeon"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) } func TestTwoFASetup_Valid_StoresHash(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp struct { Message string `json:"message"` Code string `json:"code"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, "Code sent", resp.Message) require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code") // The DB must hold the SHA-256 digest of exactly the returned code. var pendingHash, method sql.NullString var expires sql.NullTime require.NoError(t, tx.QueryRow(ctx, ` SELECT two_factor_pending_code_hash, two_factor_method, two_factor_pending_code_expires FROM users WHERE id = $1`, userID).Scan(&pendingHash, &method, &expires)) require.True(t, pendingHash.Valid, "setup must write a pending code hash") require.Equal(t, "email", method.String) require.True(t, expires.Valid && expires.Time.After(clock.Now()), "pending code must have a future expiry") sum := sha256.Sum256([]byte(resp.Code)) require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code") } func TestTwoFASetup_AlreadyEnabled_Conflict(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID) require.Equal(t, http.StatusConflict, w.Code) } func TestTwoFAVerify_WrongCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "wrong code must not enable 2FA") } func TestTwoFAVerify_CorrectCode(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]bool require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.True(t, resp["enabled"]) var enabled bool var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &pendingHash)) require.True(t, enabled, "correct code must enable 2FA") require.False(t, pendingHash.Valid, "pending code must be cleared after verification") } func TestTwoFAVerify_Expired(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Seed the correct code but backdate the expiry so the handler's // pendingExpires.After(clock.Now()) check fails. _, err = tx.Exec(ctx, `UPDATE users SET two_factor_method = 'email', two_factor_pending_code_hash = $2, two_factor_pending_code_expires = NOW() - INTERVAL '1 minute' WHERE id = $1`, userID, hashTwoFACode("123456")) require.NoError(t, err) w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusBadRequest, w.Code) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "expired code must not enable 2FA") } func TestTwoFAVerify_Unenforced_AnyCodeSucceeds(t *testing.T) { twofaEnvUnenforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Dev bypass: in an unenforced env even an empty code with no pending row // verifies. w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: ""}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.True(t, enabled) } func TestTwoFADisable(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", nil, userID) require.Equal(t, http.StatusOK, w.Code) var enabled bool var method sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method FROM users WHERE id = $1", userID).Scan(&enabled, &method)) require.False(t, enabled, "disable must clear two_factor_enabled") require.False(t, method.Valid, "disable must clear the method") } func TestTwoFA_Unauthenticated(t *testing.T) { tests := []struct { name string method string path string body any }{ {"status", http.MethodGet, "/api/user/2fa/status", nil}, {"setup", http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}}, {"verify", http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}}, {"disable", http.MethodPost, "/api/user/2fa/disable", nil}, } handlers := map[string]http.HandlerFunc{ "status": GetTwoFAStatusHandler, "setup": SetupTwoFAHandler, "verify": VerifyTwoFAHandler, "disable": DisableTwoFAHandler, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := performUser2FARequest(t, handlers[tt.name], context.Background(), tt.method, tt.path, tt.body, "") require.Equal(t, http.StatusUnauthorized, w.Code) }) } } func TestProfile_Get_IncludesTwoFAState(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) require.NoError(t, err) w := performUser2FARequest(t, GetProfileHandler, ctx, http.MethodGet, "/api/user/profile", nil, userID) require.Equal(t, http.StatusOK, w.Code) var profile UserProfile require.NoError(t, json.Unmarshal(w.Body.Bytes(), &profile)) require.True(t, profile.TwoFactorEnabled) require.True(t, profile.TwoFactorRequired, "profile must expose the enforced flag") require.NotNil(t, profile.TwoFactorMethod) require.Equal(t, "email", *profile.TwoFactorMethod) } func TestTwoFA_FailClosedDefaultEnforced(t *testing.T) { // Empty SQUARE_ENVIRONMENT (a misconfigured prod deploy) must default to // ENFORCED, never silently disable the gate. t.Setenv("REQUIRE_2FA", "") t.Setenv("SQUARE_ENVIRONMENT", "") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, GetTwoFAStatusHandler, ctx, http.MethodGet, "/api/user/2fa/status", nil, userID) require.Equal(t, http.StatusOK, w.Code) var resp TwoFAStatusResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.True(t, resp.Required, "empty SQUARE_ENVIRONMENT must be treated as enforced (fail-closed)") } func TestTwoFASetup_Enforced_NoCodeInResponse(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) _, hasCode := resp["code"] require.False(t, hasCode, "enforced setup must NOT return the code in the response") } func TestTwoFASetup_CodeLoggedOnlyWhenUnenforced(t *testing.T) { // Capture the standard logger so we can assert on what setup logs. var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) // Enforced: the [2FA] log line must carry the delivery note but never the // plaintext code — a production misconfig must not leak codes to stdout. twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) out := buf.String() require.Contains(t, out, "NOT SENT, fake delivery") require.NotContains(t, out, "verification code for user", "enforced setup must NOT log the plaintext code") // Unenforced (dev): the plaintext code IS logged for the loose-fake flow. buf.Reset() twofaEnvUnenforced(t) ctx2, tx2 := testutils.SetupTestTx(t) userID2, err := fixtures.CreateTestUser(tx2) require.NoError(t, err) w = performUser2FARequest(t, SetupTwoFAHandler, ctx2, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "sms"}, userID2) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "unenforced setup must log the plaintext code") } func TestTwoFAVerify_LockoutAfterFiveFailedAttempts(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") // Attempts 1-4: plain 400. for i := 0; i < 4; i++ { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } // Attempt 5: lockout — 429 and the pending code is invalidated. w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.") var pendingHash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash)) require.False(t, pendingHash.Valid, "lockout must invalidate the pending code") var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled, "locked-out user must not be enabled") // Attempt 6: still 429 (even with the correct code) until a new code is // requested via setup. w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, "post-lockout attempts must keep returning 429") } func TestTwoFAVerify_NewCodeViaSetupResetsLockout(t *testing.T) { twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") // Reach lockout: 4 plain 400s, then the 5th failure locks out. for i := 0; i < 4; i++ { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1) } w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID) require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String()) // Requesting a new code via setup resets the attempt counter, so // verification is possible again. w = performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) // The setup-generated code is unknown (enforced), so seed a fresh known // code and confirm the reset allows verification. seedPendingTwoFA(t, ctx, tx, userID, "654321") w = performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "654321"}, userID) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } func TestTwoFAVerify_WrongCodesAnyLengthRejected(t *testing.T) { // Exercises the constant-time compare path: wrong codes of any length and // shape fail identically (400) without enabling, while the correct code // still succeeds — no length-based early exit leaks match information. twofaEnvEnforced(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID, "123456") for _, code := range []string{"12345", "1234567", "abcdef", ""} { w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: code}, userID) require.Equal(t, http.StatusBadRequest, w.Code, "wrong code %q must be rejected", code) } var enabled bool require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled)) require.False(t, enabled) // Four wrong attempts were consumed above; one more would lock out. Use a // fresh user to prove the correct code still verifies. userID2, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedPendingTwoFA(t, ctx, tx, userID2, "123456") w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID2) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) }