//go:build test && dev package payments // Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env // matrix, requireTwoFactorForCardAccess() gating, and the end-to-end // enforcement of the saved-card payment paths in CreateBookingPayment / // CreateTillSale. Tests that flip 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 run before this package's // parallel batch, so the enforced env never leaks into parallel tests. import ( "bytes" "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "crussell/db" "crussell/internal/twofa" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) func helperEnvEnforce2FA(t *testing.T) { t.Helper() t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "production") } // seedTwoFAPendingCode stores a pending 2FA code hash + expiry for a user, the // state the user package's deliverTwoFACode writes. The hash uses the shared // twofa.Hash — the same single source of truth the gate verifies with. func seedTwoFAPendingCode(t *testing.T, q db.Querier, userID, code string) { t.Helper() _, err := q.Exec(context.Background(), ` UPDATE users SET two_factor_enabled = true, two_factor_pending_code_hash = $2, two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes' WHERE id = $1 `, userID, twofa.Hash(code)) require.NoError(t, err) } func TestTwoFactorEnforced(t *testing.T) { tests := []struct { name string require2FA string squareEnv string wantEnforced bool }{ // Fail-closed default: empty/unknown SQUARE_ENVIRONMENT is treated as // production-enforced, so a mistyped env var can never silently disarm // the gate. {"empty_env_fail_closed_enforced", "", "", true}, {"unknown_env_fail_closed_enforced", "", "staging", true}, {"require2fa_false_disables_prod", "false", "production", false}, {"require2fa_false_disables_sandbox", "false", "sandbox", false}, {"require2fa_false_disables_unknown_env", "false", "staging", false}, // REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of // false/0/off/no (any casing) disables, nothing else does. {"require2fa_capitalized_false_disables", "False", "production", false}, {"require2fa_uppercase_false_disables", "FALSE", "production", false}, {"require2fa_zero_disables", "0", "production", false}, {"require2fa_off_disables", "off", "production", false}, {"require2fa_uppercase_off_disables", "OFF", "production", false}, {"require2fa_no_disables", "no", "production", false}, {"require2fa_true_stays_enforced", "true", "production", true}, {"require2fa_one_stays_enforced", "1", "production", true}, {"require2fa_yes_stays_enforced", "yes", "production", true}, {"require2fa_on_stays_enforced", "on", "production", true}, {"require2fa_unknown_stays_enforced", "enable", "production", true}, {"require2fa_off_but_dev_never_enforced", "off", "mock", false}, {"production_enforced", "", "production", true}, {"sandbox_enforced", "", "sandbox", true}, {"require2fa_true_prod_enforced", "true", "production", true}, {"mock_never_enforced", "", "mock", false}, {"dev_never_enforced", "", "dev", false}, {"development_never_enforced", "", "development", false}, {"test_never_enforced", "", "test", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("REQUIRE_2FA", tt.require2FA) t.Setenv("SQUARE_ENVIRONMENT", tt.squareEnv) require.Equal(t, tt.wantEnforced, twoFactorEnforced()) require.Equal(t, tt.wantEnforced, NewPaymentService().TwoFactorEnforced(), "exported wrapper must match twoFactorEnforced") }) } } // TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path // allows every request without touching the DB (no user rows are consulted). // Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED // (fail-closed). func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) { t.Setenv("REQUIRE_2FA", "") t.Setenv("SQUARE_ENVIRONMENT", "mock") req := httptest.NewRequest(http.MethodPost, "/", nil) w := httptest.NewRecorder() require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "")) require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced") } func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) t.Run("user_not_enabled_writes_403_json", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456") require.False(t, ok) require.Equal(t, http.StatusForbidden, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON") require.NotEmpty(t, body["error"]) }) t.Run("user_enabled_but_no_code_writes_403_json", func(t *testing.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) req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() // B10: the enabled setup flag alone must NOT unlock the gate. ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "") require.False(t, ok) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("user_enabled_with_valid_code_allows", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedTwoFAPendingCode(t, tx, userID, "424242") req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242")) require.Equal(t, http.StatusOK, w.Code) }) t.Run("user_enabled_with_wrong_code_writes_400_json", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedTwoFAPendingCode(t, tx, userID, "424242") req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000") require.False(t, ok) require.Equal(t, http.StatusBadRequest, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "Invalid verification code", body["error"]) }) t.Run("unknown_user_writes_403_json", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456")) require.Equal(t, http.StatusForbidden, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON") require.NotEmpty(t, body["error"]) }) } // TestRequireTwoFactorForCardAccess_CodeIsSingleUse pins the finding-1 fix: a // code verified through the gate is CONSUMED (the pending code is NULLed), so // the same code cannot authorize a second saved-card charge within its // 10-minute lifetime. The second attempt with the same code is denied with the // documented "expired — request a new one" 400. func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedTwoFAPendingCode(t, tx, userID, "424242") req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "first use of the code must pass the gate") require.Equal(t, http.StatusOK, w.Code) // The verified code must now be consumed (NULLed) in the DB. 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, "a verified gate code must be consumed (NULLed)") // A second charge attempt with the same code must be denied as expired. w = httptest.NewRecorder() require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "a consumed code must not pass the gate twice") require.Equal(t, http.StatusBadRequest, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "Verification code expired — request a new one", body["error"]) } // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the // end-to-end gate on the save-card path: enforced + user without 2FA → 403 with // no payment row and no saved card (Square never called). func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-save-card-blocked", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Contains(t, body["error"], "Two-factor") var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount)) require.Zero(t, payCount, "blocked 2FA request must not create a payment row") var cardCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount)) require.Zero(t, cardCount, "blocked 2FA request must not persist a card") } // TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the // gate on charging an existing saved card. func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "2fa-saved-card-blocked", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Contains(t, body["error"], "Two-factor") var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount)) require.Zero(t, payCount, "blocked saved-card charge must not create a payment row") } func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "112233") cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-save-card-ok", VerificationCode: "112233", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount)) require.Equal(t, 1, payCount) } func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "334455") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "2fa-saved-card-ok", VerificationCode: "334455", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } // TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY // to saved-card paths: a new-card (nonce) charge is allowed without 2FA even // when enforced. func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "2fa-new-card-not-gated", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } // TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's // saved-card charge path: an admin charging a customer's saved card while the // card's owner has no 2FA is blocked with 403 and no till_sale is created. func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: "2fa-till-saved-blocked", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Contains(t, body["error"], "Two-factor") var saleCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount)) require.Zero(t, saleCount, "blocked till saved-card sale must not create a till_sale row") } func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.T) { helperEnvEnforce2FA(t) _, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") seedTwoFAPendingCode(t, tx, userID, "556677") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: "2fa-till-saved-ok", VerificationCode: "556677", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) }