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.
394 lines
15 KiB
Go
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")
|
|
})
|
|
}
|