//go:build test && dev package payments // Tests pinning the SCA-only behavior on the till and gift-card saved-card // charge gates (till.go / giftcards.go) after the 2FA fallback removal: the // gate NEVER verifies or consumes a 2FA code anymore — a token-less saved-card // charge is refused 402 verification_required up front (PSR 2017 reg 100), so // the customer's pending code is left untouched (not consumed at the gate, no // re-issue on failure). The consume-at-gate / re-issue semantics that these // tests used to pin are GONE: the homegrown 2FA fallback was removed entirely. // // These tests flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv and therefore // must stay sequential (no t.Parallel) — see the note at the top of // twofa_test.go. They use SQUARE_ENVIRONMENT=staging (NOT production) for // enforcement: twoFactorEnforced() is fail-closed, so any non-mock/dev value // enforces the gate, while square.NewDevClient() — which the injected fault // client constructs at call time — returns the in-memory mock for every env // except production/sandbox. The shared helperEnvEnforce2FA sets production, // which would panic NewDevClient in a dev build. import ( "context" "database/sql" "encoding/json" "net/http" "testing" "crussell/internal/square" "crussell/internal/twofa" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/require" ) // TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming // pins the till saved-card gate: a token-less charge is refused 402 // verification_required BEFORE the 2FA code is consulted, so the customer's // pending code is never consumed and no re-issue runs (the fallback was // removed — the gate has no code path at all). func TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") 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") seedTwoFAPendingCode(t, tx, userID, "556677") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() req := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: "2fa-till-fresh-decline", } w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less till saved-card charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) // The gate never verified the code — the seeded hash must survive untouched // (no consumption, no re-issue). var hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("556677"), hash.String, "the gate must not consume the code — it was never consulted") } // TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused // pins the same SCA-only refusal on a PENDING-REUSE till retry: the gate still // refuses 402 verification_required (the code is irrelevant), leaving the // seeded code untouched. func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") 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") seedTwoFAPendingCode(t, tx, userID, "667788") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) // Seed a PENDING till_sale with the same key — a prior attempt whose // Square charge failed after the DB transaction committed (card funded). key := "2fa-till-pending-reuse" var giftCardID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id `, adminID).Scan(&giftCardID)) _, err = tx.Exec(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $2, $3, $4, $5, NOW(), NOW()) `, giftCardID, userID, cardID, key, adminID) require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() req := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: key, } w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less till saved-card charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String()) var hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("667788"), hash.String, "the gate must not consume the code — it was never consulted") } // TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming // pins the SCA-only refusal on the gift-card purchase saved-card gate: a // token-less charge is refused 402 verification_required and the seeded code is // untouched (no consumption, no re-issue). func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") seedTwoFAPendingCode(t, tx, userID, "112233") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() req := BuyGiftCardRequest{ Amount: 2000, RecipientType: "self", CardID: &cardID, IdempotencyKey: "2fa-buy-gc-fresh-decline", } w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less gift-card saved-card charge must be refused 402 verification_required (SCA-only), body: %s", 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 hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("112233"), hash.String, "the gate must not consume the code — it was never consulted") } // TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused // pins the SCA-only refusal on a PENDING-REUSE gift-card purchase retry: the // gate refuses 402 verification_required regardless of the pending state, // leaving the seeded code untouched. func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") seedTwoFAPendingCode(t, tx, userID, "334455") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") require.NoError(t, err) // Seed a PENDING payment record with the same key — a prior attempt whose // Square charge failed (mirrors TestBuyGiftCard_RetryPending_ReattemptsCharge). key := "2fa-buy-gc-pending-reuse" _, err = tx.Exec(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by) VALUES ('full', 'online_square', 'pending', 20.00, $1, NOW(), NOW(), $2) `, key, userID) require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() 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, "a token-less gift-card saved-card charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String()) var hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("334455"), hash.String, "the gate must not consume the code — it was never consulted") } // TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused // pins the SCA-only refusal on the booking NEW-card + save_card SAVE gate: a // token-less save is refused 402 verification_required and the seeded code is // untouched (no consumption at the save gate, no re-issue — the fallback is // gone). func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "999001") origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() cardToken := "cnon:2fa-booking-newcard-savecard-decline" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-booking-newcard-savecard-decline", } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less new-card + save_card booking charge must be refused 402 verification_required (SCA-only), body: %s", 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 hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("999001"), hash.String, "the save gate must not consume the code — it was never consulted") } // TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused pins // the same SCA-only refusal on the TIP new-card + save_card SAVE gate. func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) { t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedTwoFAPendingCode(t, tx, userID, "999002") _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) origClient := SquareClient SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() cardToken := "cnon:2fa-tip-newcard-savecard-decline" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "2fa-tip-newcard-savecard-decline", } w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less new-card + save_card tip charge must be refused 402 verification_required (SCA-only), body: %s", w.Body.String()) var hash sql.NullString require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash)) require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge") require.Equal(t, twofa.Hash("999002"), hash.String, "the save gate must not consume the code — it was never consulted") }