//go:build test && dev package payments // Tests for the PSD2 SCA gate (twofa.go): the twoFactorEnforced() env matrix, // requireTwoFactorForCardAccess() gating, and the end-to-end enforcement of the // saved-card payment paths in CreateBookingPayment / CreateTillSale. Saved-card // charges are SCA-ONLY: the homegrown 2FA fallback was REMOVED entirely (PSR // 2017 reg 100 — SCA mandatory and non-waivable for customer-initiated // stored-credential charges), so a token-less saved-card charge is always // refused 402 verification_required and no 2FA code can authorise it. 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" "encoding/json" "net/http" "net/http/httptest" "testing" "crussell/db" "crussell/internal/adminnotify" "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") // There is no TWO_FACTOR_FALLBACK switch anymore (the 2FA fallback was // removed — SCA-only); enforcement now only means token-less saved-card // charges are refused 402 verification_required. } // 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 re-issue path uses. // Used by tests that prove even a VALID pending 2FA code cannot authorise a // token-less saved-card charge (SCA-only). 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_VerificationTokenSkips pins the SCA-primary // leg of the decision model: when the request carries a Square verification_token // (the issuer already completed SCA), the gate is skipped entirely — even a // user with NO 2FA setup passes, no code is demanded, and no fallback is used. func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "vrf_sca_token_123", false) require.True(t, allowed, "SCA performed — the gate must be skipped") require.False(t, fallbackUsed, "SCA is primary — no fallback is ever used") require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate") } // TestRequireTwoFactorForCardAccess_TokenForwardedDistinction pins auth-F1 at // the gate level: the SAME non-empty verification_token must be treated // differently by surface. On a token-FORWARDED (charge) surface it is // Square-validated SCA and skips the gate (tokenForwardedToSquare=true). On a // card-SAVE surface it is client-asserted and never reaches Square, so it must // NOT skip the gate (tokenForwardedToSquare=false) — a forged token cannot // authorise a save, exactly like a token-less request, and no 2FA code can // authorise it either (SCA-only). func TestRequireTwoFactorForCardAccess_TokenForwardedDistinction(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) t.Run("forged_token_no_code_refused_402", 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) w := httptest.NewRecorder() allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "forged-token", true, false) require.False(t, allowed, "a forged non-empty token must not skip the gate on the save path") require.False(t, fallbackUsed) require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) }) t.Run("forged_token_with_valid_code_still_refused_402", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedTwoFAPendingCode(t, tx, userID, "424242") w := httptest.NewRecorder() allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "forged-token", true, false) require.False(t, allowed, "a valid 2FA code cannot authorise a save (SCA-only — the 2FA fallback was removed)") require.False(t, fallbackUsed, "the 2FA fallback was removed — fallbackUsed is always false") require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) }) t.Run("forged_token_user_not_enabled_refused_402", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := httptest.NewRecorder() allowed, _ := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "forged-token", true, false) require.False(t, allowed, "a user without 2FA setup cannot save a card even with a token") require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) }) t.Run("token_forwarded_variant_still_skips", func(t *testing.T) { userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) w := httptest.NewRecorder() allowed, fallbackUsed := requireTwoFactorForCardAccessWithTokenValidation(w, req, NewPaymentService(), userID, "vrf_sca_token_123", false, true) require.True(t, allowed, "a token-forwarded charge path still skips on a non-empty token (SCA)") require.False(t, fallbackUsed) require.Equal(t, http.StatusOK, w.Code) }) } // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_Blocked pins // auth-F1 end-to-end on the booking SAVE gate: enforced + save_card=true + a // forged non-empty verification_token is denied 402 verification_required with // no payment row and no saved card. A forged token can never skip the SAVE gate // (Square never validates it there), and no 2FA code can fall back (SCA-only). func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_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" forged := "forged-verification-token" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-save-card-forge-token", VerificationToken: &forged, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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 save-card 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 save-card request must not persist a card") } // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_With2FA_Blocked // pins that a forged token on the booking SAVE gate cannot authorise a save by // ANY fallback: even with a valid 2FA code the request is refused 402 // verification_required (SCA-only — the homegrown 2FA fallback was removed). func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_ForgeToken_With2FA_Blocked(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" forged := "forged-verification-token" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-save-card-forge-token-ok", VerificationToken: &forged, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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 save-card request must not create a payment row") } // TestTwoFactorEnforced_CreateBookingPayment_SCATokenizeResult_SaveGate_Skips // pins auth-F1 scenario (c): the legitimate SCA-primary tokenize-result flow // (new_card_token + saved_card_id) still skips BOTH 2FA gates on save+charge — // the SAVE gate is skipped because the combined path never persists a card // (resolveChargeSource uses the token as the one-time source) and the // tokenize-result token is validated by Square as the source_id. A user with // NO 2FA setup and NO code succeeds: the tokenize-result token is the SCA proof. func TestTwoFactorEnforced_CreateBookingPayment_SCATokenizeResult_SaveGate_Skips(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-save-2fa", "VISA", "4242") require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) installRecordingClient(t) // The fixture above already created one saved-card row for the user; the // combined SCA tokenize-result path must not add another. var cardsBefore int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardsBefore)) token := "cnon:sca-tokenize-save-2fa" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &token, UserSavedCardID: &cardID, SaveCard: true, IdempotencyKey: "sca-tokenize-save-2fa-" + bookingID, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result save+charge must skip both 2FA gates, body: %s", w.Body.String()) // The combined path never persists a card — the SCA tokenize-result is the // one-time charge source, not a new card-on-file. var cardsAfter int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardsAfter)) require.Equal(t, cardsBefore, cardsAfter, "the SCA tokenize-result save+charge must not persist a new card") } // TestRequireTwoFactorForCardAccess_Tokenless_402Structured pins the SCA-only // posture: a token-less saved-card charge is ALWAYS refused 402 with the // structured verification_required body — even when the customer holds a valid // 2FA code, because the homegrown 2FA fallback was removed entirely (PSR 2017 // reg 100: SCA is mandatory and non-waivable for customer-initiated // stored-credential charges). There is no TWO_FACTOR_FALLBACK switch to flip. func TestRequireTwoFactorForCardAccess_Tokenless_402Structured(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) seedTwoFAPendingCode(t, tx, userID, "123456") req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false) require.False(t, allowed, "SCA-only: no 2FA fallback for a token-less charge") require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed") require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) } // TestTwoFactorEnforced_BookingSavedCard_Tokenless_402 pins the SCA-only // posture end-to-end on the booking saved-card charge path: a token-less // saved-card charge (even with a valid 2FA code) is refused 402 // verification_required, no payment row is created, and no 2fa_fallback_charge // audit row is written (the fallback audit path is unreachable — the gate never // authorises a charge by 2FA). func TestTwoFactorEnforced_BookingSavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "445566") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_audit", "VISA", "4242") require.NoError(t, err) req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "2fa-fallback-audit", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount)) require.Zero(t, payCount, "a refused token-less charge must not create a payment row") var auditCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'").Scan(&auditCount)) require.Zero(t, auditCount, "the 2FA fallback was removed — no fallback audit row may be written") } // TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA- // authorized charge (verification token present) writes NO 2fa_fallback_charge // audit row: SCA is primary and the fallback no longer exists at all. func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(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_sca_audit", "VISA", "4242") require.NoError(t, err) vrf := "vrf_sca_booking_audit" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "2fa-sca-audit", VerificationToken: &vrf, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var auditCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge' `).Scan(&auditCount)) require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row") } // TestTwoFactorEnforced_TipSavedCard_Tokenless_402 pins the SCA-only posture on // the TIP SAVE gate (CreateTipPayment with save_card=true): a token-less save // is refused 402 verification_required even with a valid 2FA code. func TestTwoFactorEnforced_TipSavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "556600") _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) cardToken := "cnon:2fa-tip-save-audit" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-tip-save-audit", } w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) } // TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402 pins the SCA-only // posture on the TIP CHARGE gate (CreateTipPayment charging an existing saved // card): a token-less charge is refused 402 verification_required even with a // valid 2FA code. func TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "112211") _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242") require.NoError(t, err) req := CreateTipPaymentRequest{ Amount: 500, CardID: &cardID, IdempotencyKey: "2fa-tip-charge-audit", } w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) } // TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402 pins the SCA-only // posture on the gift-card purchase saved-card gate (giftcards.go): a token-less // charge is refused 402 verification_required even with a valid 2FA code. func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "778811") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242") require.NoError(t, err) key := "2fa-buy-gc-fallback-audit" req := BuyGiftCardRequest{ Amount: 2000, RecipientType: "self", CardID: &cardID, IdempotencyKey: key, } w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", key).Scan(&payCount)) require.Zero(t, payCount, "a refused token-less gift-card purchase must not create a payment row") } // TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402 pins the SCA-only // posture on the add-card save gate (handlers.go CreatePaymentMethod): a save // from a NON-token-like source (a raw PAN — the gate's only remaining refusal // shape) is refused 402 verification_required even with a valid 2FA code // (SCA-only). A genuine token-like source is SCA-proven and skips the gate. func TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "998877") req := CreatePaymentMethodRequest{ CardToken: "4111111111111111", } w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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, "a refused token-less save must not persist a card") } // 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() allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false) require.True(t, allowed, "no response must be written when not enforced") require.False(t, fallbackUsed, "not enforced — no fallback is ever used") require.Equal(t, http.StatusOK, w.Code) } // TestRequireTwoFactorForCardAccess_Enforced pins the SCA-only refusal in an // enforced environment: a token-less saved-card charge is refused 402 // verification_required regardless of the user's 2FA state or a submitted code — // the homegrown 2FA fallback was removed entirely and cannot authorise anything. func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) { helperEnvEnforce2FA(t) ctx, tx := testutils.SetupTestTx(t) t.Run("user_not_enabled_writes_402_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, "", false) require.False(t, ok) require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON") require.Equal(t, "verification_required", body["code"]) }) t.Run("user_enabled_but_no_code_writes_402_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() ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false) require.False(t, ok) require.Equal(t, http.StatusPaymentRequired, w.Code) }) t.Run("user_enabled_with_valid_code_still_refused_402", 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() allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false) require.False(t, allowed, "a valid 2FA code cannot authorise a token-less charge (SCA-only)") require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed") require.Equal(t, http.StatusPaymentRequired, w.Code) }) t.Run("user_enabled_with_wrong_code_writes_402_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, "", false) require.False(t, ok, "the gate does not verify codes anymore — any token-less charge is refused 402") require.Equal(t, http.StatusPaymentRequired, w.Code) }) t.Run("unknown_user_writes_402_json", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) w := httptest.NewRecorder() ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "", false) require.False(t, ok) require.Equal(t, http.StatusPaymentRequired, w.Code) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON") require.Equal(t, "verification_required", body["code"]) }) } // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the // end-to-end gate on the save-card path: enforced + no SCA → 402 // verification_required 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.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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 without SCA. 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.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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") } // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Blocked pins that // a valid 2FA code cannot unlock the booking SAVE gate (SCA-only — the 2FA // fallback was removed). func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Blocked(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", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount)) require.Zero(t, payCount, "a valid 2FA code must not create a payment row (SCA-only)") } // TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Blocked pins // that a valid 2FA code cannot unlock a saved-card charge (SCA-only — the 2FA // fallback was removed). func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Blocked(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", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) } // TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY // to saved-card paths: a new-card (nonce) charge is allowed without 2FA even // when enforced (SCA is performed by Square's buyer-verification flow). 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 without SCA // is blocked with 402 verification_required 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.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) 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") } // TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Blocked pins that a // valid 2FA code cannot unlock the till saved-card gate (SCA-only — the 2FA // fallback was removed). func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Blocked(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", } 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.StatusPaymentRequired, w.Code, w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) } // TestSCASuccess_BookingSavedCard_NeverRequiresConsent pins the SCA-primary // carve-out: a charge carrying a Square verification_token (SCA performed) is // never gated on the (removed) fallback consent — with no consent fields and no // 2FA setup it succeeds and writes no fallback audit row. func TestSCASuccess_BookingSavedCard_NeverRequiresConsent(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:consent_sca", "VISA", "4242") require.NoError(t, err) vrf := "vrf_consent_sca_success" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", CardID: &cardID, IdempotencyKey: "consent-sca", VerificationToken: &vrf, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "SCA-success must never demand consent, body: %s", w.Body.String()) var auditCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'").Scan(&auditCount)) require.Zero(t, auditCount, "an SCA-success charge must not write a fallback audit row") } // TestNotificationsCapExceeded pins Round 2 Loop B finding 1: the GLOBAL cap on // unacknowledged 'critical_payment_log' admin notifications // (adminnotify.MaxUnacknowledgedCriticalLogs, the shared cap living in // crussell/internal/adminnotify) suppresses new inserts once the unacknowledged // queue reaches the cap, so a hostile flood of attacker-registered accounts // cannot bury the single-operator notification centre. Acknowledging rows // re-arms inserts. The same cap is now applied atomically (a conditional // INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap) at every // 'critical_payment_log' / 'refresh_token_reuse' insert site (webhooks, // account erasure, jwt reuse, the payment sweep); this test pins the shared // count helper. func TestNotificationsCapExceeded(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) // Empty queue → below the cap, inserts allowed. require.False(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log")) // Fill the unacknowledged queue to the cap. _, err = tx.Exec(ctx, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`) require.NoError(t, err) for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ { _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id, created_at) VALUES ('critical_payment_log', $1, NOW()) `, userID) require.NoError(t, err) } require.True(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log"), "at the cap the insert must be suppressed") // Acknowledging one row drops below the cap → inserts re-arm. _, err = tx.Exec(ctx, ` UPDATE admin_notifications SET acknowledged_at = NOW() WHERE ctid = ( SELECT ctid FROM admin_notifications WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL LIMIT 1 ) `) require.NoError(t, err) require.False(t, adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log"), "acknowledging one row must re-arm inserts") }