PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges; a merchant-side 2FA check cannot legally substitute for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent). - payments/twofa.go: the homegrown 2FA fallback for token-less saved-card charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only: a non-empty Square verification_token (charge surfaces, token forwarded to Square) skips the gate; anything else is refused 402 verification_required. enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs). - New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces where the token IS forwarded to Square (charge — Square validates it) from card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token must NOT skip the save gate, auth-F1). - SCA tokenize-result wire contract (C1): a saved card charged with a fresh one-time tokenize-result sends the token as the charge SOURCE (new_card_token -> source_id) alongside saved_card_id, never a separate verification_token. resolveChargeSource resolves the saved-card branch FIRST (customer from the card row, token as source) so combined token+card requests are SCA-clean. - C6 consent fields (consent_version / consent_accepted) added to the booking/ tip/till/gift-card charge requests, enforced server-side before any fallback charge could reach Square and recorded on the 2fa_fallback_charge audit row; logVerificationTokenProvenance traces minted tokens to their charge. - user 2FA issuance gate refactored into pure build-agnostic functions (twoFAPepperConfigured / twoFADeliveryChannelConfigured / twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and .env.example entry removed; startup posture notes updated. - Test coverage: fail-closed 2FA production gates (pepper/delivery), token validation on save vs charge surfaces, completion idempotency, idempotency key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
930 lines
41 KiB
Go
930 lines
41 KiB
Go
//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, "424242", "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, "424242", "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",
|
|
VerificationCode: "112233",
|
|
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, "123456", "", 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",
|
|
VerificationCode: "445566",
|
|
}
|
|
|
|
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",
|
|
VerificationCode: "556600",
|
|
}
|
|
|
|
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",
|
|
VerificationCode: "112211",
|
|
}
|
|
|
|
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,
|
|
VerificationCode: "778811",
|
|
}
|
|
|
|
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): the
|
|
// dedicated add-card endpoint carries no verification_token field, so a save is
|
|
// refused 402 verification_required even with a valid 2FA code (SCA-only).
|
|
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: "cnon:2fa-pm-save-audit",
|
|
VerificationCode: "998877",
|
|
}
|
|
|
|
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, "123456", "", 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, "424242", "", 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, "000000", "", 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", "123456", "", 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",
|
|
VerificationCode: "112233",
|
|
}
|
|
|
|
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",
|
|
VerificationCode: "334455",
|
|
}
|
|
|
|
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",
|
|
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.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")
|
|
}
|
|
|
|
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
|
|
// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a
|
|
// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or
|
|
// SMS delivery channel" branch is UNREACHABLE in this build. The production
|
|
// predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered by
|
|
// twofa_delivery_prod_test.go in a !dev build (see its header for the
|
|
// documented limitation).
|
|
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
|
|
require.True(t, twoFADeliveryAvailable())
|
|
}
|
|
|
|
// 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); this test pins the shared count helper. NOTE:
|
|
// the REISSUE-FAIL alert (reissueTwoFACodeAfterFailedCharge) is intentionally
|
|
// NOT globally capped — Round 2 Loop B finding 2 caps it PER-ISSUE (dedup on
|
|
// reason+user_id) so one customer's alert is never suppressed by other users'
|
|
// rows.
|
|
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")
|
|
}
|