//go:build test && dev package payments // Tests for the CreateTerminalPayment SCA decision model: the VerificationToken // field (handlers.go:114, validation 440-444, extraction 843-846, gate skip // 1024, forwarding 1113), the SCA-only token-less refusal (402 // verification_required) on the terminal and till saved-card surfaces, and the // customer_initiated classification (Square's SCA/liability-shift signal) on // every saved-card charge site. // // Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv stay sequential // (no t.Parallel) — see the note at the top of twofa_test.go. They use // SQUARE_ENVIRONMENT=staging (enforced, but square.NewDevClient() returns the // in-memory mock — helperEnvEnforce2FA's production would panic a dev-build // NewDevClient, see twofa_gate_consume_test.go). import ( "encoding/json" "net/http" "testing" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/require" ) // helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env // that still resolves to the in-memory mock client. Saved-card charges are // SCA-only (the 2FA fallback was removed entirely), so an enforced env refuses // token-less saved-card charges 402 verification_required. func helperEnvEnforce2FAStaging(t *testing.T) { t.Helper() t.Setenv("REQUIRE_2FA", "true") t.Setenv("SQUARE_ENVIRONMENT", "staging") } // installRecordingClient wraps the dev mock in the shared recording client so // tests can assert the exact CreatePaymentReq the handler sent to Square. func installRecordingClient(t *testing.T) *recordingPaymentClient { t.Helper() origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec t.Cleanup(func() { SquareClient = origClient }) return rec } // TestTerminalSavedCard_VerificationToken_Passthrough pins (d): a saved-card // charge through CreateTerminalPayment forwards the SCA verification token // verbatim on the CreatePaymentReq. func TestTerminalSavedCard_VerificationToken_Passthrough(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca", "VISA", "4242") require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") rec := installRecordingClient(t) vrf := "vrf_terminal_sca_1" req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "terminal-sca-passthrough-" + bookingID, VerificationToken: &vrf, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() got := rec.lastReq.VerificationToken rec.mu.Unlock() require.Equal(t, vrf, got, "the SCA verification token must be forwarded to Square on the CreatePaymentReq") } // TestTerminalSavedCard_VerificationToken_TooLong_Rejected pins (d): an // oversized verification_token is rejected with 400 by ValidateVerificationToken // before any charge branch runs. func TestTerminalSavedCard_VerificationToken_TooLong_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") big := make([]byte, 600) for i := range big { big[i] = 'a' } token := string(big) req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), IdempotencyKey: "terminal-sca-too-long-" + bookingID, VerificationToken: &token, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "an oversized verification_token must be rejected with 400, body: %s", w.Body.String()) } // TestTerminalSavedCard_VerificationToken_Skips2FA pins (d): when the charge // carries a Square verification_token (SCA performed), the 2FA gate is SKIPPED // even for a user with NO 2FA setup — a charge that would 403 on the fallback // path succeeds via the token. func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_skip", "VISA", "4242") require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") installRecordingClient(t) vrf := "vrf_terminal_sca_skip_1" req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "terminal-sca-skip-" + bookingID, VerificationToken: &vrf, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "SCA performed — the token must skip the 2FA gate, 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-authorized terminal charge must not write a 2FA-fallback audit row") } // TestTerminalSavedCard_Tokenless_402 pins the SCA-only posture on the terminal // saved-card surface: a token-less terminal saved-card charge is refused 402 // verification_required even when the card owner holds a valid 2FA code (the // homegrown 2FA fallback was removed entirely — PSR 2017 reg 100), and no // 2fa_fallback_charge audit row is written. func TestTerminalSavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) seedTwoFAPendingCode(t, tx, userID, "334411") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_fallback", "VISA", "4242") require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") installRecordingClient(t) req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "terminal-tokenless-" + bookingID, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less terminal saved-card charge must be refused 402 (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 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") } // TestTillSavedCard_Tokenless_402 pins the SCA-only posture on the till saved- // card surface: an admin till saved-card sale is refused 402 // verification_required even with a valid 2FA code (the 2FA fallback was // removed entirely), and no fallback audit row is written. func TestTillSavedCard_Tokenless_402(t *testing.T) { helperEnvEnforce2FAStaging(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") seedTwoFAPendingCode(t, tx, userID, "665544") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_till_audit", "VISA", "4242") require.NoError(t, err) installRecordingClient(t) key := "2fa-till-fallback-audit" 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 sale must be refused 402 (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 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") } // TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated // flag on the CreatePaymentReq — false for the ADMIN surfaces (terminal // saved-card handlers.go:1121, till saved-card till.go:1179: merchant- // initiated) and true for the CUSTOMER surfaces (booking handlers.go:2467, // tip handlers.go:4708, gift-card giftcards.go:1676). func TestCustomerInitiated_ChargeClassification(t *testing.T) { t.Run("admin_terminal_saved_card_merchant_initiated", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_terminal", "VISA", "4242") require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") rec := installRecordingClient(t) req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "ci-terminal-" + bookingID, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() cd := rec.lastReq.CustomerDetails rec.mu.Unlock() require.NotNil(t, cd, "an admin saved-card terminal charge must carry customer_details") require.False(t, cd.CustomerInitiated, "an admin-initiated terminal saved-card charge is merchant-initiated") }) t.Run("admin_till_saved_card_merchant_initiated", func(t *testing.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:mock_ci_till", "VISA", "4242") require.NoError(t, err) rec := installRecordingClient(t) req := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &userID, IdempotencyKey: "ci-till", } w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) rec.mu.Lock() cd := rec.lastReq.CustomerDetails rec.mu.Unlock() require.NotNil(t, cd, "an admin till saved-card charge must carry customer_details") require.False(t, cd.CustomerInitiated, "an admin-initiated till saved-card charge is merchant-initiated") }) t.Run("customer_booking_cardholder_initiated", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) rec := installRecordingClient(t) cardToken := "cnon:ci-booking" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "ci-booking-" + bookingID, } w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() cd := rec.lastReq.CustomerDetails rec.mu.Unlock() require.NotNil(t, cd) require.True(t, cd.CustomerInitiated, "a customer booking charge is cardholder-initiated") }) t.Run("customer_tip_cardholder_initiated", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed") require.NoError(t, err) rec := installRecordingClient(t) cardToken := "cnon:ci-tip" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "ci-tip-" + bookingID, } w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() cd := rec.lastReq.CustomerDetails rec.mu.Unlock() require.NotNil(t, cd) require.True(t, cd.CustomerInitiated, "a customer tip charge is cardholder-initiated") }) t.Run("customer_gift_card_cardholder_initiated", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_gc", "VISA", "4242") require.NoError(t, err) rec := installRecordingClient(t) req := BuyGiftCardRequest{ Amount: 2000, RecipientType: "self", CardID: &cardID, IdempotencyKey: "ci-gc", } w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String()) rec.mu.Lock() cd := rec.lastReq.CustomerDetails rec.mu.Unlock() require.NotNil(t, cd) require.True(t, cd.CustomerInitiated, "a customer gift-card purchase is cardholder-initiated") }) }