Files
Crussell/backend/handlers/payments/terminal_sca_test.go
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

394 lines
15 KiB
Go

//go:build test && dev
package payments
// Tests for the CreateTerminalPayment SCA/2FA decision model: the
// VerificationToken field (handlers.go:114, validation 440-444, extraction
// 843-846, gate skip 1024, forwarding 1113), the admin-actor 2FA-fallback
// audit rows 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.
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_2FAFallback_Audit_AdminActor pins (c)+(d): a token-less
// terminal saved-card charge falls back to the card owner's 2FA code and writes
// a strict 2fa_fallback_charge audit row for the ADMIN actor (admin_id = the
// charging admin, target_user_id = the card owner).
func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(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")
rec := installRecordingClient(t)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "terminal-2fa-fallback-" + bookingID,
VerificationCode: "334411",
}
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.Empty(t, got, "a 2FA-fallback charge must not carry a verification token")
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// TestTerminalSavedCard_2FAFallbackDisabled_402 pins (d): when the deployment
// opts out of the 2FA fallback (TWO_FACTOR_FALLBACK=false), a token-less saved-
// card terminal charge is denied 402 with the structured verification_required
// body the frontend keys on to run the SCA challenge.
func TestTerminalSavedCard_2FAFallbackDisabled_402(t *testing.T) {
helperEnvEnforce2FAStaging(t)
t.Setenv("TWO_FACTOR_FALLBACK", "false")
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
seedTwoFAPendingCode(t, tx, userID, "998800")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_no_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-sca-no-fallback-" + bookingID,
VerificationCode: "998800",
}
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less charge with the fallback disabled must be denied 402, 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"])
}
// TestTillSavedCard_2FAFallback_Audit_AdminActor pins (c): an admin till
// saved-card sale authorized by the 2FA fallback writes a strict
// 2fa_fallback_charge audit row for the ADMIN actor with the till sale id as
// the reference.
func TestTillSavedCard_2FAFallback_Audit_AdminActor(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,
VerificationCode: "665544",
}
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
var tillSaleID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, key).Scan(&tillSaleID))
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
}
// 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")
})
}