feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
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.
This commit is contained in:
+19
-19
@@ -55,19 +55,25 @@ SQUARE_ENVIRONMENT=mock
|
|||||||
# deliberately route a dev build to the real production API. Never set in a
|
# deliberately route a dev build to the real production API. Never set in a
|
||||||
# deployed production build.
|
# deployed production build.
|
||||||
SQUARE_ALLOW_REAL_API=
|
SQUARE_ALLOW_REAL_API=
|
||||||
# 2FA — backup merchant-level authorization gate on saved-card online payments.
|
# 2FA — homegrown two-factor authentication for ADMIN/ACCOUNT VERIFICATION ONLY
|
||||||
# NOT PSD2 SCA: Square buyer verification (3-D Secure / SCA) is the PRIMARY
|
# (setup, disable, delete-account re-auth). It is NEVER a card-charge
|
||||||
# authorisation for saved-card charges and is wired for both new-card and
|
# authorisation. Saved-card online payments are authorised EXCLUSIVELY by
|
||||||
# saved-card charges. The 2FA gate fires only when SCA is unavailable (e.g. a
|
# Square PSD2 SCA (3-D Secure / buyer verification), wired for both new-card
|
||||||
# customer's bank does not support the in-app approval flow) and every such
|
# and saved-card charges. PSR 2017 reg 100 makes SCA mandatory and non-waivable
|
||||||
# fallback charge is audited (admin_audit_log 2fa_fallback_charge). Enforcement
|
# for customer-initiated stored-credential charges, so a saved-card charge
|
||||||
# is FAIL-CLOSED:
|
# carrying no Square verification token is REFUSED 402 verification_required
|
||||||
|
# (the payment does not go through; the customer can try again later, or at
|
||||||
|
# the till they can be invited to pay online later instead) — the homegrown
|
||||||
|
# 2FA fallback was removed entirely and no TWO_FACTOR_FALLBACK switch exists.
|
||||||
|
# Enforcement is FAIL-CLOSED:
|
||||||
# ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
|
# ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
|
||||||
# OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
|
# OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
|
||||||
# Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced
|
# Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced
|
||||||
# (a mistyped env var can never silently disarm the gate; the backend logs a
|
# (a mistyped env var can never silently disarm the gate; the backend logs a
|
||||||
# startup warning in that case). Set REQUIRE_2FA=false only in controlled
|
# startup warning in that case). Set REQUIRE_2FA=false only in controlled
|
||||||
# environments.
|
# environments. The dev mock simulates SCA
|
||||||
|
# (SimulateSavedCardVerificationRequired + cnon:sca-... tokenize-results), so
|
||||||
|
# development has full parity with the SCA-only production posture.
|
||||||
# Code delivery: the intended channel is email/SMS (the method chosen at
|
# Code delivery: the intended channel is email/SMS (the method chosen at
|
||||||
# setup) — NOT wired yet. Until it lands, the verification code is delivered
|
# setup) — NOT wired yet. Until it lands, the verification code is delivered
|
||||||
# via the server log (a [2FA]-prefixed line) when the operator opts into
|
# via the server log (a [2FA]-prefixed line) when the operator opts into
|
||||||
@@ -75,16 +81,6 @@ SQUARE_ALLOW_REAL_API=
|
|||||||
# environments an operator must relay the logged code to the user out-of-band;
|
# environments an operator must relay the logged code to the user out-of-band;
|
||||||
# the API never returns the code while enforcement is ON.
|
# the API never returns the code while enforcement is ON.
|
||||||
REQUIRE_2FA=true
|
REQUIRE_2FA=true
|
||||||
# TWO_FACTOR_FALLBACK: whether the homegrown 2FA code gate may be used as a
|
|
||||||
# BACKUP authorisation for saved-card charges when Square SCA (3-D Secure /
|
|
||||||
# buyer verification) is unavailable — e.g. a customer's bank does not support
|
|
||||||
# in-app approval. Defaults true. Square 3DS2 SCA is the PRIMARY authorisation;
|
|
||||||
# when a saved-card charge carries a Square verification_token the 2FA gate is
|
|
||||||
# skipped entirely. Set TWO_FACTOR_FALLBACK=false for a security-first posture
|
|
||||||
# in which a saved-card charge without SCA cannot proceed via 2FA (the frontend
|
|
||||||
# surfaces the SCA challenge; if the bank can't complete it, the charge fails).
|
|
||||||
# 2FA fallback success always writes an admin_audit_log 2fa_fallback_charge row.
|
|
||||||
TWO_FACTOR_FALLBACK=true
|
|
||||||
# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED
|
# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED
|
||||||
# in production builds: code issuance FAILS CLOSED when it is unset (an
|
# in production builds: code issuance FAILS CLOSED when it is unset (an
|
||||||
# unsalted SHA-256 digest in the 1M code space would be offline-brute-forceable
|
# unsalted SHA-256 digest in the 1M code space would be offline-brute-forceable
|
||||||
@@ -97,7 +93,7 @@ TWO_FACTOR_PEPPER=
|
|||||||
# FAILS CLOSED without a delivery channel: there is no email/SMS transport yet,
|
# FAILS CLOSED without a delivery channel: there is no email/SMS transport yet,
|
||||||
# so the ONLY production channel is the operator's explicit opt-in to the
|
# so the ONLY production channel is the operator's explicit opt-in to the
|
||||||
# insecure server-log delivery ([2FA] prefix — anyone with backend log access
|
# insecure server-log delivery ([2FA] prefix — anyone with backend log access
|
||||||
# can defeat the gate on saved-card charges). MUST be set to true to deliver
|
# can defeat the account 2FA gate). MUST be set to true to deliver
|
||||||
# 2FA codes via the server log in production until email/SMS lands. Dev/test
|
# 2FA codes via the server log in production until email/SMS lands. Dev/test
|
||||||
# builds always deliver via the log and never consult this flag.
|
# builds always deliver via the log and never consult this flag.
|
||||||
TWO_FACTOR_ALLOW_LOG_DELIVERY=false
|
TWO_FACTOR_ALLOW_LOG_DELIVERY=false
|
||||||
@@ -137,6 +133,10 @@ TEST_DB_DSN=
|
|||||||
GO_TESTING=
|
GO_TESTING=
|
||||||
|
|
||||||
# CardDAV (SabreDAV) — profile photo sync
|
# CardDAV (SabreDAV) — profile photo sync
|
||||||
|
# DAV_BASE_URL is retained for reference only: the Go backend no longer reads it
|
||||||
|
# — updateCardDAV writes through the dav.Service directly (Postgres-backed
|
||||||
|
# CardDAV store), it never calls this URL. Only the sabredav PHP container needs
|
||||||
|
# the server-side credential below.
|
||||||
DAV_BASE_URL=http://localhost:8080
|
DAV_BASE_URL=http://localhost:8080
|
||||||
# DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to
|
# DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to
|
||||||
# start when unset or set to a known weak/default value ('admin' etc.) — this
|
# start when unset or set to a known weak/default value ('admin' etc.) — this
|
||||||
|
|||||||
@@ -43,6 +43,52 @@ import (
|
|||||||
// On any error the helper writes the HTTP response and returns ok=false — the
|
// On any error the helper writes the HTTP response and returns ok=false — the
|
||||||
// caller must return immediately.
|
// caller must return immediately.
|
||||||
func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *PaymentService, userID string, newCardToken, cardID *string, saveCard bool, notFoundMsg string) (sourceID string, savedCardID *string, squareCustomerID string, ok bool) {
|
func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *PaymentService, userID string, newCardToken, cardID *string, saveCard bool, notFoundMsg string) (sourceID string, savedCardID *string, squareCustomerID string, ok bool) {
|
||||||
|
// The saved-card branch is resolved FIRST so a request carrying BOTH a
|
||||||
|
// new-card token and a saved card id — the SCA tokenize-result wire
|
||||||
|
// contract (Square's CURRENT "Charge a Card on File" flow, where
|
||||||
|
// card.tokenize(verificationDetails, cardId) returns a token that must be
|
||||||
|
// sent as source_id, NOT a separate verification_token) — resolves the
|
||||||
|
// customer from the saved-card row and uses the fresh one-time token as
|
||||||
|
// the source. Without the token it is a plain saved-card (ccof:) charge.
|
||||||
|
// Only a token with NO saved card falls through to the new-card path below.
|
||||||
|
if cardID != nil {
|
||||||
|
card, err := svc.GetCardByID(ctx, *cardID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.Error(w, notFoundMsg, http.StatusNotFound)
|
||||||
|
return "", nil, "", false
|
||||||
|
}
|
||||||
|
log.Printf("Failed to get card: %v", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return "", nil, "", false
|
||||||
|
}
|
||||||
|
if card.SquareCustomerID == "" {
|
||||||
|
if userID == "" {
|
||||||
|
// Defensive parity with the original saved-card block: a card
|
||||||
|
// with no bookable owner cannot be provisioned. Unreachable in
|
||||||
|
// practice — GetCardByID above filters on user_id and would
|
||||||
|
// have 404'd for an empty owner.
|
||||||
|
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
||||||
|
return "", nil, "", false
|
||||||
|
}
|
||||||
|
provisioned, provErr := svc.EnsureSquareCustomerForSavedCard(ctx, *cardID, userID)
|
||||||
|
if provErr != nil {
|
||||||
|
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *cardID, userID, provErr)
|
||||||
|
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||||
|
return "", nil, "", false
|
||||||
|
}
|
||||||
|
card.SquareCustomerID = provisioned
|
||||||
|
}
|
||||||
|
if newCardToken != nil && *newCardToken != "" {
|
||||||
|
// SCA tokenize-result for a saved card: the token is the charge
|
||||||
|
// source (a fresh one-time token minted only after the issuer
|
||||||
|
// completed buyer verification for THIS card + amount); the stored
|
||||||
|
// ccof: id is NOT sent. customer_id still derives from the saved
|
||||||
|
// card row — Square requires it for the card-on-file charge.
|
||||||
|
return *newCardToken, cardID, card.SquareCustomerID, true
|
||||||
|
}
|
||||||
|
return card.SquareCardID, cardID, card.SquareCustomerID, true
|
||||||
|
}
|
||||||
if newCardToken != nil && *newCardToken != "" {
|
if newCardToken != nil && *newCardToken != "" {
|
||||||
if saveCard {
|
if saveCard {
|
||||||
sqCustomerID, custErr := svc.EnsureSquareCustomer(ctx, userID)
|
sqCustomerID, custErr := svc.EnsureSquareCustomer(ctx, userID)
|
||||||
@@ -96,36 +142,6 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen
|
|||||||
}
|
}
|
||||||
return sourceID, savedCardID, squareCustomerID, true
|
return sourceID, savedCardID, squareCustomerID, true
|
||||||
}
|
}
|
||||||
if cardID != nil {
|
|
||||||
card, err := svc.GetCardByID(ctx, *cardID, userID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
http.Error(w, notFoundMsg, http.StatusNotFound)
|
|
||||||
return "", nil, "", false
|
|
||||||
}
|
|
||||||
log.Printf("Failed to get card: %v", err)
|
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
||||||
return "", nil, "", false
|
|
||||||
}
|
|
||||||
if card.SquareCustomerID == "" {
|
|
||||||
if userID == "" {
|
|
||||||
// Defensive parity with the original saved-card block: a card
|
|
||||||
// with no bookable owner cannot be provisioned. Unreachable in
|
|
||||||
// practice — GetCardByID above filters on user_id and would
|
|
||||||
// have 404'd for an empty owner.
|
|
||||||
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
|
||||||
return "", nil, "", false
|
|
||||||
}
|
|
||||||
provisioned, provErr := svc.EnsureSquareCustomerForSavedCard(ctx, *cardID, userID)
|
|
||||||
if provErr != nil {
|
|
||||||
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *cardID, userID, provErr)
|
|
||||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
|
||||||
return "", nil, "", false
|
|
||||||
}
|
|
||||||
card.SquareCustomerID = provisioned
|
|
||||||
}
|
|
||||||
return card.SquareCardID, cardID, card.SquareCustomerID, true
|
|
||||||
}
|
|
||||||
// Neither a new-card token nor a saved card — validation upstream
|
// Neither a new-card token nor a saved card — validation upstream
|
||||||
// (ValidateCardInfo) guarantees one of them is present.
|
// (ValidateCardInfo) guarantees one of them is present.
|
||||||
return "", nil, "", false
|
return "", nil, "", false
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
// M15 webhook/sweep completion-asymmetry tests. A Square payment.updated
|
||||||
|
// webhook (handlers/webhooks/square.go handlePaymentUpdated) and the stale
|
||||||
|
// pending-payment sweep's rescue path can both try to complete the same
|
||||||
|
// payment row: the webhook flips `payments.status` with a
|
||||||
|
// `WHERE ... AND status = 'pending'` guard, and the sweep's rescue
|
||||||
|
// (rescueStaleRowCompletedTx) flips the row with its own
|
||||||
|
// `WHERE id = ... AND status = 'pending'` guard before applying the split /
|
||||||
|
// VAT / fully-paid-booking completion side effects. Both guards make the
|
||||||
|
// completion idempotent — the first writer wins, the second matches zero rows
|
||||||
|
// and applies NO side effects. These tests lock that invariant in both race
|
||||||
|
// orderings. Sequential (no t.Parallel): they swap the package-global
|
||||||
|
// SquareClient and mutate the shared pool, like the other sweep tests.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/internal/square"
|
||||||
|
"crussell/testutils"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSweepRescueThenWebhook_CompletesExactlyOnce locks the sweep-first race
|
||||||
|
// ordering: the sweep rescues a stale pending payment to 'completed' and
|
||||||
|
// applies the booking-completion side effects exactly once; a webhook-style
|
||||||
|
// idempotent status flip (the exact `WHERE ... AND status = 'pending'` UPDATE
|
||||||
|
// handlePaymentUpdated runs) that arrives AFTER the rescue matches zero rows,
|
||||||
|
// and a re-run of the sweep also does nothing — the side effects are never
|
||||||
|
// doubled.
|
||||||
|
func TestSweepRescueThenWebhook_CompletesExactlyOnce(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||||
|
// Give the booking a payable total so the rescue's fully-paid check can
|
||||||
|
// complete it.
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
|
||||||
|
t.Fatalf("failed to set booking total: %v", err)
|
||||||
|
}
|
||||||
|
// VAT-registered — the rescue applies VAT to the split records.
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
||||||
|
t.Fatalf("failed to enable VAT in business_settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create pending payment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", payID); err != nil {
|
||||||
|
t.Fatalf("failed to age the payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
mock := square.NewDevClient().(*square.MockClient)
|
||||||
|
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||||
|
Amount: 200000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "seed-asymmetry-completed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed completed Square payment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, payID); err != nil {
|
||||||
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||||
|
}
|
||||||
|
SquareClient = mock
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
pgxTx := db.TxFromContext(ctx)
|
||||||
|
if pgxTx == nil {
|
||||||
|
t.Fatal("no transaction in context")
|
||||||
|
}
|
||||||
|
if err := pgxTx.Commit(ctx); err != nil {
|
||||||
|
t.Fatalf("failed to commit test tx: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. The sweep rescues the stale pending row (Square reports COMPLETED).
|
||||||
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||||
|
t.Fatalf("sweep failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payStatus string
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", payID).Scan(&payStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query payment: %v", err)
|
||||||
|
}
|
||||||
|
if payStatus != "completed" {
|
||||||
|
t.Fatalf("expected the sweep to rescue the payment to 'completed', got %q", payStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rescue must have completed the booking (fully paid) exactly once.
|
||||||
|
var bookingStatus string
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query booking: %v", err)
|
||||||
|
}
|
||||||
|
if bookingStatus != "completed" {
|
||||||
|
t.Errorf("expected the sweep rescue to complete the fully-paid booking, got %q", bookingStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly the deposit + balance split records exist — no duplicates.
|
||||||
|
var recordCount int
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||||
|
t.Fatalf("failed to count payment records: %v", err)
|
||||||
|
}
|
||||||
|
if recordCount != 2 {
|
||||||
|
t.Errorf("expected exactly 2 split payment records after the rescue, got %d", recordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAT applied on both split records.
|
||||||
|
var vatRows int
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND vat_amount IS NOT NULL", bookingID).Scan(&vatRows); err != nil {
|
||||||
|
t.Fatalf("failed to count VAT'd records: %v", err)
|
||||||
|
}
|
||||||
|
if vatRows != 2 {
|
||||||
|
t.Errorf("expected VAT applied to both split records exactly once, got %d records with VAT", vatRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The webhook's completion path arrives AFTER the rescue: its pending-
|
||||||
|
// only UPDATE matches zero rows (idempotent status flip).
|
||||||
|
tag, err := db.Conn.Exec(pool, `
|
||||||
|
UPDATE payments SET status = 'completed', updated_at = NOW()
|
||||||
|
WHERE square_payment_id = $1 AND status = 'pending'
|
||||||
|
`, pay.SquarePayID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webhook-style update failed: %v", err)
|
||||||
|
}
|
||||||
|
if int(tag.RowsAffected()) != 0 {
|
||||||
|
t.Errorf("expected the post-rescue webhook completion to match 0 pending rows, got %d", tag.RowsAffected())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. A re-run of the sweep finds nothing pending — no second rescue, no
|
||||||
|
// second completion, no duplicate records.
|
||||||
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||||
|
t.Fatalf("second sweep failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||||
|
t.Fatalf("failed to re-count payment records: %v", err)
|
||||||
|
}
|
||||||
|
if recordCount != 2 {
|
||||||
|
t.Errorf("expected the second sweep to add no payment records, got %d", recordCount)
|
||||||
|
}
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||||
|
t.Fatalf("failed to re-query booking: %v", err)
|
||||||
|
}
|
||||||
|
if bookingStatus != "completed" {
|
||||||
|
t.Errorf("expected the booking to stay completed after the second sweep, got %q", bookingStatus)
|
||||||
|
}
|
||||||
|
var stampAwarded sql.NullTime
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT loyalty_stamp_awarded_at FROM bookings WHERE id = $1", bookingID).Scan(&stampAwarded); err != nil {
|
||||||
|
t.Fatalf("failed to query loyalty stamp marker: %v", err)
|
||||||
|
}
|
||||||
|
if !stampAwarded.Valid {
|
||||||
|
t.Error("expected the loyalty stamp awarded exactly once by the single completion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookThenSweepRescue_CompletesExactlyOnce locks the webhook-first race
|
||||||
|
// ordering: the webhook's pending-only status flip completes the payment
|
||||||
|
// first, so the sweep finds no pending row left to rescue and applies NO
|
||||||
|
// side effects — the booking is not double-completed and no split/VAT records
|
||||||
|
// are minted by the sweep.
|
||||||
|
func TestWebhookThenSweepRescue_CompletesExactlyOnce(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil {
|
||||||
|
t.Fatalf("failed to set booking total: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create pending payment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_webhook_first' WHERE id = $1", payID); err != nil {
|
||||||
|
t.Fatalf("failed to age the payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
SquareClient = square.NewDevClient()
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
pgxTx := db.TxFromContext(ctx)
|
||||||
|
if pgxTx == nil {
|
||||||
|
t.Fatal("no transaction in context")
|
||||||
|
}
|
||||||
|
if err := pgxTx.Commit(ctx); err != nil {
|
||||||
|
t.Fatalf("failed to commit test tx: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. The webhook flips the pending row to completed first.
|
||||||
|
tag, err := db.Conn.Exec(pool, `
|
||||||
|
UPDATE payments SET status = 'completed', updated_at = NOW()
|
||||||
|
WHERE square_payment_id = 'sqp_webhook_first' AND status = 'pending'
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("webhook-style update failed: %v", err)
|
||||||
|
}
|
||||||
|
if int(tag.RowsAffected()) != 1 {
|
||||||
|
t.Fatalf("expected the webhook-style completion to match exactly 1 pending row, got %d", tag.RowsAffected())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The sweep rescue path arrives after: the row is no longer pending, so
|
||||||
|
// it is never fetched and NO side effects run.
|
||||||
|
if _, err := SweepStalePendingPayments(pool); err != nil {
|
||||||
|
t.Fatalf("sweep failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordCount int
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
|
||||||
|
t.Fatalf("failed to count payment records: %v", err)
|
||||||
|
}
|
||||||
|
// Exactly ONE row — the webhook only flipped status; the sweep's split
|
||||||
|
// logic must not have run (the rescue is gated on the row still pending).
|
||||||
|
if recordCount != 1 {
|
||||||
|
t.Errorf("expected the sweep to add no records after the webhook completed the row, got %d", recordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bookingStatus string
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query booking: %v", err)
|
||||||
|
}
|
||||||
|
if bookingStatus != "in_progress" {
|
||||||
|
t.Errorf("expected the booking untouched by the sweep (no double-completion), got %q", bookingStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/testutils"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCompleteActiveBookingFromPayment_RefusesCancelledBooking locks the M2
|
||||||
|
// money-safety boundary of the sweep rescue's completion side-effect: a
|
||||||
|
// cancelled / lapsed / no-show booking must NEVER be auto-completed by the
|
||||||
|
// payment path — a charge landing on such a booking is failed + auto-refunded
|
||||||
|
// by the sweep's F3 gate, and completing the booking would record money against
|
||||||
|
// a booking the cancellation flow already closed.
|
||||||
|
func TestCompleteActiveBookingFromPayment_RefusesCancelledBooking(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
|
||||||
|
t.Fatalf("failed to set booking we_cancelled: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pgxTx := db.TxFromContext(ctx)
|
||||||
|
if pgxTx == nil {
|
||||||
|
t.Fatal("no transaction in context")
|
||||||
|
}
|
||||||
|
completeActiveBookingFromPayment(ctx, pgxTx, bookingID)
|
||||||
|
|
||||||
|
var status string
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||||
|
t.Fatalf("failed to query booking status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "we_cancelled" {
|
||||||
|
t.Errorf("expected the cancelled booking NOT auto-completed, got %q", status)
|
||||||
|
}
|
||||||
|
// The setup tx is rolled back at test end, so no pool-level cleanup is needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteFullyPaidBooking_CompletesPayableBooking locks the sweep rescue's
|
||||||
|
// completion side-effect (applyStaleRescueRecords → bookingIsFullyPaid →
|
||||||
|
// completeActiveBookingFromPayment): a PAYABLE booking fully covered by
|
||||||
|
// completed real money is completed by the rescue-completion path, exactly as
|
||||||
|
// the live payment path would.
|
||||||
|
func TestCompleteFullyPaidBooking_CompletesPayableBooking(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
||||||
|
|
||||||
|
// A full £50 completed payment covers the £50 booking total.
|
||||||
|
payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create completed payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pgxTx := db.TxFromContext(ctx)
|
||||||
|
if pgxTx == nil {
|
||||||
|
t.Fatal("no transaction in context")
|
||||||
|
}
|
||||||
|
if err := pgxTx.Commit(ctx); err != nil {
|
||||||
|
t.Fatalf("failed to commit setup tx: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_discounts WHERE booking_id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, payID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
|
||||||
|
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||||
|
})
|
||||||
|
|
||||||
|
completeFullyPaidBooking(pool, bookingID)
|
||||||
|
|
||||||
|
var status string
|
||||||
|
if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||||
|
t.Fatalf("failed to query booking status: %v", err)
|
||||||
|
}
|
||||||
|
if status != "completed" {
|
||||||
|
t.Errorf("expected the fully-paid payable booking completed, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,10 @@ func isVerificationRequiredError(err error) bool {
|
|||||||
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
|
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
|
||||||
// structured-error pattern — mw.RespondJSON, code + human message). The message
|
// structured-error pattern — mw.RespondJSON, code + human message). The message
|
||||||
// tells the buyer to approve the payment in their banking app. Used both by the
|
// tells the buyer to approve the payment in their banking app. Used both by the
|
||||||
// charge-failure paths (Square returned an SCA-required code) and by the 2FA
|
// charge-failure paths (Square returned an SCA-required code) and by the SCA-only
|
||||||
// gate when the SCA-only posture has no fallback for a token-less charge.
|
// saved-card gate, which refuses any token-less charge (the homegrown 2FA
|
||||||
|
// fallback was removed — a token-less charge is always refused here, never
|
||||||
|
// authorised by a 2FA code).
|
||||||
func writeVerificationRequiredResponse(w http.ResponseWriter) {
|
func writeVerificationRequiredResponse(w http.ResponseWriter) {
|
||||||
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
|
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
|
||||||
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
|
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
|
||||||
|
|||||||
@@ -75,23 +75,54 @@ func InsertAdminAuditCharge(ctx context.Context, adminID, targetUserID, action s
|
|||||||
// charge must land an admin_audit_log row (action_type '2fa_fallback_charge',
|
// charge must land an admin_audit_log row (action_type '2fa_fallback_charge',
|
||||||
// details {sca_performed:false, fallback_reason:"verification_unavailable"}) so
|
// details {sca_performed:false, fallback_reason:"verification_unavailable"}) so
|
||||||
// the operator can distinguish SCA-authorized charges from fallback-authorized
|
// the operator can distinguish SCA-authorized charges from fallback-authorized
|
||||||
// ones. Mirrors InsertAdminAuditCharge's best-effort, own-transaction,
|
// ones. Since C6 the row also captures the customer's versioned consent to the
|
||||||
// non-fatal failure handling (a failed audit write can never abort a completed
|
// fallback (consent_version, consent_accepted, consent_versioned_at) — the
|
||||||
// charge). For customer-initiated online charges the actor (adminID) is the
|
// exact values the server validated before the charge — so an operator/GDPR
|
||||||
// customer's own userID; for admin surfaces it is the admin from request
|
// export can reconstruct which dialog version was shown, that it was accepted,
|
||||||
// context — the caller passes accordingly. cardLast4 and referenceID are filled
|
// when, and on which charge. Mirrors InsertAdminAuditCharge's best-effort,
|
||||||
// by the caller at charge success (paymentResult.CardLast4, booking/till/payment
|
// own-transaction, non-fatal failure handling (a failed audit write can never
|
||||||
// id), where they are actually known.
|
// abort a completed charge). For customer-initiated online charges the actor
|
||||||
func insertTwoFAFallbackAudit(ctx context.Context, adminID, userID, cardLast4, referenceID, notes string) {
|
// (adminID) is the customer's own userID; for admin surfaces it is the admin
|
||||||
|
// from request context — the caller passes accordingly. cardLast4 and
|
||||||
|
// referenceID are filled by the caller at charge success
|
||||||
|
// (paymentResult.CardLast4, booking/till/payment id), where they are actually
|
||||||
|
// known.
|
||||||
|
func insertTwoFAFallbackAudit(ctx context.Context, adminID, userID, cardLast4, referenceID, notes string, consentVersion string, consentAccepted bool) {
|
||||||
InsertAdminAuditCharge(ctx, adminID, userID, "2fa_fallback_charge", map[string]any{
|
InsertAdminAuditCharge(ctx, adminID, userID, "2fa_fallback_charge", map[string]any{
|
||||||
"sca_performed": false,
|
"sca_performed": false,
|
||||||
"fallback_reason": "verification_unavailable",
|
"fallback_reason": "verification_unavailable",
|
||||||
"card_last4": cardLast4,
|
"card_last4": cardLast4,
|
||||||
"reference_id": referenceID,
|
"reference_id": referenceID,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
|
"consent_version": consentVersion,
|
||||||
|
"consent_accepted": consentAccepted,
|
||||||
|
"consent_versioned_at": clock.Now().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logVerificationTokenProvenance records the charge context a legacy SCA
|
||||||
|
// verification_token arrived with (saved-card reference + booking) so an
|
||||||
|
// operator can correlate a minted token with the exact charge it authorized.
|
||||||
|
// The length-only ValidateVerificationToken is deliberately not extended:
|
||||||
|
// Square mints and validates these tokens server-side, binding them to the
|
||||||
|
// card + amount, and any stale/reused/mis-bound token is definitively rejected
|
||||||
|
// by Square with VERIFICATION_TOKEN_INVALID / CARD_DECLINED_VERIFICATION_REQUIRED
|
||||||
|
// (errors.go classifies those), so Square being the sole arbiter is acceptable
|
||||||
|
// — the charge fails closed on any mismatch. This log is the minimum provenance
|
||||||
|
// trace; the token is redacted to a prefix because it is a sensitive credential.
|
||||||
|
// A no-op for token-less charges (the SCA tokenize-result wire contract sends
|
||||||
|
// no verification_token at all).
|
||||||
|
func logVerificationTokenProvenance(flow, bookingID string, savedCardRef *string, token string) {
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ref := "(new-card)"
|
||||||
|
if savedCardRef != nil && *savedCardRef != "" {
|
||||||
|
ref = *savedCardRef
|
||||||
|
}
|
||||||
|
log.Printf("SCA verification_token present on %s charge for booking %s (saved card %s) — token %q forwarded to Square", flow, bookingID, ref, square.TokenPrefix(token))
|
||||||
|
}
|
||||||
|
|
||||||
type CreateTerminalPaymentRequest struct {
|
type CreateTerminalPaymentRequest struct {
|
||||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||||
PaymentType string `json:"payment_type" validate:"required"`
|
PaymentType string `json:"payment_type" validate:"required"`
|
||||||
@@ -122,12 +153,27 @@ type CreateTerminalPaymentRequest struct {
|
|||||||
// amount+card key for no-client-key retry safety. Cap ≤45 (Square's
|
// amount+card key for no-client-key retry safety. Cap ≤45 (Square's
|
||||||
// idempotency-key limit for /v2/payments — this key feeds CreatePayment).
|
// idempotency-key limit for /v2/payments — this key feeds CreatePayment).
|
||||||
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
||||||
|
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
|
||||||
|
// consent to the SCA-unavailable → 2FA fallback (C6). The frontend sends
|
||||||
|
// consent_version:"v1" + consent_accepted:true (scaFallbackConsentFields)
|
||||||
|
// when the ScaFallbackConsentDialog was accepted; the server enforces it
|
||||||
|
// (403 consent_required) before a fallback charge can reach Square and
|
||||||
|
// records it on the 2fa_fallback_charge audit row.
|
||||||
|
ConsentVersion *string `json:"consent_version,omitempty"`
|
||||||
|
ConsentAccepted bool `json:"consent_accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingPaymentRequest struct {
|
type CreateBookingPaymentRequest struct {
|
||||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||||
PaymentType string `json:"payment_type" validate:"required"`
|
PaymentType string `json:"payment_type" validate:"required"`
|
||||||
CardID *string `json:"card_id,omitempty"`
|
CardID *string `json:"card_id,omitempty"`
|
||||||
|
// UserSavedCardID (saved_card_id) is the SCA path's reference to the stored
|
||||||
|
// saved card (user_saved_cards.id). It coexists with NewCardToken when the
|
||||||
|
// frontend sends the SCA tokenize-result — card.tokenize(verificationDetails,
|
||||||
|
// cardId) — as new_card_token: the tokenize-result token is a fresh
|
||||||
|
// one-time source_id and the saved-card row supplies the Square customer.
|
||||||
|
// Without a token it behaves exactly like card_id (legacy/2FA-fallback).
|
||||||
|
UserSavedCardID *string `json:"saved_card_id,omitempty"`
|
||||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||||
SaveCard bool `json:"save_card"`
|
SaveCard bool `json:"save_card"`
|
||||||
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
|
||||||
@@ -136,6 +182,11 @@ type CreateBookingPaymentRequest struct {
|
|||||||
// enforced environment charges a saved card only when this matches the
|
// enforced environment charges a saved card only when this matches the
|
||||||
// customer's pending code.
|
// customer's pending code.
|
||||||
VerificationCode string `json:"verification_code,omitempty"`
|
VerificationCode string `json:"verification_code,omitempty"`
|
||||||
|
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
|
||||||
|
// consent to the SCA-unavailable → 2FA fallback (C6), enforced server-side
|
||||||
|
// (403 consent_required) and recorded on the 2fa_fallback_charge audit row.
|
||||||
|
ConsentVersion *string `json:"consent_version,omitempty"`
|
||||||
|
ConsentAccepted bool `json:"consent_accepted"`
|
||||||
// ConfirmOverflowTip acknowledges that an overpayment beyond the booking's
|
// ConfirmOverflowTip acknowledges that an overpayment beyond the booking's
|
||||||
// remaining balance will be recorded as a tip (M7). Tips cannot be paid in
|
// remaining balance will be recorded as a tip (M7). Tips cannot be paid in
|
||||||
// advance, so a pre-start overpayment is rejected with 400
|
// advance, so a pre-start overpayment is rejected with 400
|
||||||
@@ -167,6 +218,11 @@ type CreateTipPaymentRequest struct {
|
|||||||
// enforced environment charges a saved card only when this matches the
|
// enforced environment charges a saved card only when this matches the
|
||||||
// customer's pending code.
|
// customer's pending code.
|
||||||
VerificationCode string `json:"verification_code,omitempty"`
|
VerificationCode string `json:"verification_code,omitempty"`
|
||||||
|
// ConsentVersion / ConsentAccepted: the customer's explicit versioned
|
||||||
|
// consent to the SCA-unavailable → 2FA fallback (C6), enforced server-side
|
||||||
|
// (403 consent_required) and recorded on the 2fa_fallback_charge audit row.
|
||||||
|
ConsentVersion *string `json:"consent_version,omitempty"`
|
||||||
|
ConsentAccepted bool `json:"consent_accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutResponse struct {
|
type CheckoutResponse struct {
|
||||||
@@ -491,6 +547,26 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Route based on payment method
|
// Route based on payment method
|
||||||
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
|
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
|
||||||
|
// C2: serialize this till cash/giftcard charge under the SAME
|
||||||
|
// crussell:payment:<bookingID> advisory lock the online booking payment
|
||||||
|
// path (CreateBookingPayment) and the saved-card/terminal paths take.
|
||||||
|
// The bookings-row FOR UPDATE below only serializes against OTHER
|
||||||
|
// transactions that take the same row lock — the online path reads the
|
||||||
|
// remaining balance under the advisory lock with NO FOR UPDATE, so the
|
||||||
|
// two primitives do NOT serialize against each other: a concurrent
|
||||||
|
// online charge + till cash/giftcard charge could both pass their
|
||||||
|
// remaining-balance checks and both record money (the overflow carved
|
||||||
|
// into a non-refundable tip by buildSplitRecords). Holding the same
|
||||||
|
// advisory lock here makes every money-mutating path contend on one
|
||||||
|
// primitive; the FOR UPDATE stays as a harmless double-guard against
|
||||||
|
// concurrent cancellations. The bounded try-lock (R6) gives an
|
||||||
|
// in-flight online charge ~3s to finish, then fails this fast with 409.
|
||||||
|
pinConn, lockOK := acquireBookingPaymentLock(r.Context(), w, "crussell:payment:"+bookingID, "Payment in progress, try again")
|
||||||
|
if !lockOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
|
||||||
|
|
||||||
// Start transaction before the status and idempotency checks so they
|
// Start transaction before the status and idempotency checks so they
|
||||||
// are atomic with the payment insert.
|
// are atomic with the payment insert.
|
||||||
tx, err := db.Conn.Begin(r.Context())
|
tx, err := db.Conn.Begin(r.Context())
|
||||||
@@ -888,6 +964,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
terminalVerificationToken := ""
|
terminalVerificationToken := ""
|
||||||
if req.VerificationToken != nil {
|
if req.VerificationToken != nil {
|
||||||
terminalVerificationToken = *req.VerificationToken
|
terminalVerificationToken = *req.VerificationToken
|
||||||
|
logVerificationTokenProvenance("admin terminal saved-card", bookingID, req.UserSavedCardID, terminalVerificationToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the saved-card Square source for the booking's user (the
|
// Resolve the saved-card Square source for the booking's user (the
|
||||||
@@ -1070,6 +1147,12 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// C6: a fallback-authorized charge must carry the customer's
|
||||||
|
// accepted consent (403 consent_required otherwise) — the server-side
|
||||||
|
// guard that stops a 2FA-path charge reaching Square without it.
|
||||||
|
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B13: the pre-charge discount SET for the post-charge apply-time
|
// B13: the pre-charge discount SET for the post-charge apply-time
|
||||||
@@ -1312,7 +1395,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// The 2FA BACKUP authorized this token-less saved-card charge
|
// The 2FA BACKUP authorized this token-less saved-card charge
|
||||||
// (SCA was unavailable) — record the strict fallback audit row.
|
// (SCA was unavailable) — record the strict fallback audit row.
|
||||||
if twoFAFallbackUsed {
|
if twoFAFallbackUsed {
|
||||||
insertTwoFAFallbackAudit(r.Context(), adminID, bookingUserID.String, paymentResult.CardLast4, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)")
|
insertTwoFAFallbackAudit(r.Context(), adminID, bookingUserID.String, paymentResult.CardLast4, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,6 +2036,19 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// savedCardRef is the effective saved-card reference for this request:
|
||||||
|
// the legacy card_id field OR the SCA path's saved_card_id (they are the
|
||||||
|
// same user_saved_cards.id; card_id wins when both are sent). When a
|
||||||
|
// NEW-card token arrives alongside it (SCA tokenize-result wire contract),
|
||||||
|
// the token is the one-time charge source and this row supplies the
|
||||||
|
// customer. ValidateCardInfo above already rejected card_id + new_card_token
|
||||||
|
// together, so a coexistence can only be new_card_token + saved_card_id.
|
||||||
|
savedCardRef := req.CardID
|
||||||
|
if savedCardRef == nil || *savedCardRef == "" {
|
||||||
|
savedCardRef = req.UserSavedCardID
|
||||||
|
}
|
||||||
|
scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != "" && savedCardRef != nil && *savedCardRef != ""
|
||||||
|
|
||||||
// Serialize payment attempts for this booking to prevent concurrent payments
|
// Serialize payment attempts for this booking to prevent concurrent payments
|
||||||
// across browser tabs or duplicate requests. Uses a PostgreSQL session-level
|
// across browser tabs or duplicate requests. Uses a PostgreSQL session-level
|
||||||
// advisory lock so that only one goroutine processes payment for a given
|
// advisory lock so that only one goroutine processes payment for a given
|
||||||
@@ -1999,8 +2095,8 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// the dedup lookup below returns it (double-charge protection).
|
// the dedup lookup below returns it (double-charge protection).
|
||||||
if req.IdempotencyKey == "" {
|
if req.IdempotencyKey == "" {
|
||||||
cardPart := "new"
|
cardPart := "new"
|
||||||
if req.CardID != nil && *req.CardID != "" {
|
if savedCardRef != nil && *savedCardRef != "" {
|
||||||
cardPart = *req.CardID
|
cardPart = *savedCardRef
|
||||||
}
|
}
|
||||||
key, keyErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart)
|
key, keyErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart)
|
||||||
if keyErr != nil {
|
if keyErr != nil {
|
||||||
@@ -2189,21 +2285,39 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
|
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
|
||||||
// new charge may move at Square. The gate also runs before
|
// new charge may move at Square. The gate also runs before
|
||||||
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
||||||
// SCA-primary: a request carrying a Square verification_token (SCA
|
// SCA-primary (auth-F1): the SAVE surface never forwards the client's
|
||||||
// performed) skips the gate; a token-less card-save/charge falls back to
|
// verification_token to Square (the card is persisted via CreateCardOnFile,
|
||||||
// the customer's 2FA code, and twoFAFallbackUsed records that the 2FA
|
// which takes no token), so a non-empty token is client-asserted and must
|
||||||
// BACKUP authorized the operation (the caller audits the charge on success).
|
// NOT skip the gate — a token-less save falls back to the customer's 2FA
|
||||||
|
// code, and twoFAFallbackUsed records that the 2FA BACKUP authorized the
|
||||||
|
// operation (the caller audits it). Only the call-site's
|
||||||
|
// scaTokenizedSavedCard tokenize-result flow skips the save gate (the
|
||||||
|
// combined path never persists a card and Square validates the token as the
|
||||||
|
// source_id).
|
||||||
twoFAFallbackUsed := false
|
twoFAFallbackUsed := false
|
||||||
bookingVerificationToken := ""
|
bookingVerificationToken := ""
|
||||||
if req.VerificationToken != nil {
|
if req.VerificationToken != nil {
|
||||||
bookingVerificationToken = *req.VerificationToken
|
bookingVerificationToken = *req.VerificationToken
|
||||||
|
logVerificationTokenProvenance("booking", bookingID, savedCardRef, bookingVerificationToken)
|
||||||
}
|
}
|
||||||
if req.SaveCard {
|
// scaTokenizedSavedCard (an SCA tokenize-result token charging a saved
|
||||||
|
// card) skips BOTH 2FA gates exactly like a present verification_token: the
|
||||||
|
// token only exists after the issuer completed buyer verification for this
|
||||||
|
// card + amount (SCA-primary), so no homegrown fallback authorization is
|
||||||
|
// needed. The SAVE gate is skipped because the combined path never persists
|
||||||
|
// a card (resolveChargeSource uses the token as a one-time source, no
|
||||||
|
// card-on-file is created).
|
||||||
|
if req.SaveCard && !scaTokenizedSavedCard {
|
||||||
var gateOK bool
|
var gateOK bool
|
||||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true)
|
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true, false)
|
||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// C6: a fallback-authorized save/charge must carry the customer's
|
||||||
|
// accepted consent (403 consent_required otherwise).
|
||||||
|
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After the idempotency check (which handles same-key retries), verify
|
// After the idempotency check (which handles same-key retries), verify
|
||||||
@@ -2490,17 +2604,26 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// carrying a Square verification_token (SCA performed) skips the gate; a
|
// carrying a Square verification_token (SCA performed) skips the gate; a
|
||||||
// token-less charge falls back to 2FA and twoFAFallbackUsed is set for the
|
// token-less charge falls back to 2FA and twoFAFallbackUsed is set for the
|
||||||
// charge-success audit.
|
// charge-success audit.
|
||||||
if req.CardID != nil && *req.CardID != "" {
|
if savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard {
|
||||||
var gateOK bool
|
var gateOK bool
|
||||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
|
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
|
||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// C6: a fallback-authorized charge must carry the customer's accepted
|
||||||
|
// consent (403 consent_required otherwise).
|
||||||
|
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Resolve the new-card-vs-saved-card Square source (shared with
|
// Resolve the new-card-vs-saved-card Square source (shared with
|
||||||
// CreateTipPayment, BuyGiftCard, and the saved-card branch of
|
// CreateTipPayment, BuyGiftCard, and the saved-card branch of
|
||||||
// CreateTerminalPayment — see resolveChargeSource for the R6 rationale).
|
// CreateTerminalPayment — see resolveChargeSource for the R6 rationale).
|
||||||
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
|
// savedCardRef (card_id OR saved_card_id) is passed as the card reference;
|
||||||
|
// when an SCA tokenize-result token rides along in NewCardToken,
|
||||||
|
// resolveChargeSource uses the token as the source and the card row for the
|
||||||
|
// customer.
|
||||||
|
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, savedCardRef, req.SaveCard, "Card not found")
|
||||||
if !sourceOK {
|
if !sourceOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2622,7 +2745,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// A pending-reuse retry verified WITHOUT consuming, so its code is still
|
// A pending-reuse retry verified WITHOUT consuming, so its code is still
|
||||||
// live and no re-issue runs (a re-issue would invalidate the code the
|
// live and no re-issue runs (a re-issue would invalidate the code the
|
||||||
// customer already holds).
|
// customer already holds).
|
||||||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
if (savedCardRef != nil && *savedCardRef != "") || req.SaveCard {
|
||||||
reissueTwoFACodeAfterFailedCharge(r.Context(), db.Conn, userID, true, twoFAFallbackUsed && !reusePendingRecord, r)
|
reissueTwoFACodeAfterFailedCharge(r.Context(), db.Conn, userID, true, twoFAFallbackUsed && !reusePendingRecord, r)
|
||||||
}
|
}
|
||||||
// SCA-required failures must surface the structured verification_required
|
// SCA-required failures must surface the structured verification_required
|
||||||
@@ -2760,9 +2883,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// For a PENDING-REUSE retry (gate passed consume=false — the code was
|
// For a PENDING-REUSE retry (gate passed consume=false — the code was
|
||||||
// re-issued for this retry) this is where it is burned, so a retry that
|
// re-issued for this retry) this is where it is burned, so a retry that
|
||||||
// fails again keeps its code for one more attempt. Only runs for
|
// fails again keeps its code for one more attempt. Only runs for
|
||||||
// saved-card (CardID) charges — the gate only ran for those, and new-card
|
// saved-card charges — the gate only ran for those, and new-card
|
||||||
// charges have no code to consume.
|
// charges have no code to consume.
|
||||||
if req.CardID != nil && *req.CardID != "" && reusePendingRecord {
|
if savedCardRef != nil && *savedCardRef != "" && reusePendingRecord {
|
||||||
if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil {
|
if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil {
|
||||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
|
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
|
||||||
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
|
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
|
||||||
@@ -2850,7 +2973,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// completed charge). The actor is the customer's own userID
|
// completed charge). The actor is the customer's own userID
|
||||||
// (customer-initiated online charge).
|
// (customer-initiated online charge).
|
||||||
if twoFAFallbackUsed {
|
if twoFAFallbackUsed {
|
||||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)")
|
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
// B13: a campaign was exhausted between the preview and the apply-time
|
// B13: a campaign was exhausted between the preview and the apply-time
|
||||||
@@ -3343,6 +3466,21 @@ type CreatePaymentMethodRequest struct {
|
|||||||
// enforced environment persists a card only when this matches the
|
// enforced environment persists a card only when this matches the
|
||||||
// customer's pending code.
|
// customer's pending code.
|
||||||
VerificationCode string `json:"verification_code,omitempty"`
|
VerificationCode string `json:"verification_code,omitempty"`
|
||||||
|
// VerificationToken is a Square 3DS/SCA verification token. On the add-card
|
||||||
|
// SAVE surface it is CLIENT-ASSERTED and never forwarded to Square
|
||||||
|
// (CreateCardOnFile takes no verification_token), so the 2FA gate IGNORES it
|
||||||
|
// (auth-F1 — a forged value cannot authorise a save; see twofa.go). It is
|
||||||
|
// carried on the wire for parity with the charge surfaces and passed through
|
||||||
|
// to the gate, whose save variant applies the token-less SCA-only refusal
|
||||||
|
// when a genuine SCA proof is absent. The genuine proof for a SAVE is the
|
||||||
|
// STORE-intent tokenize-result submitted as card_token (see
|
||||||
|
// isSCATokenizeResultCardToken).
|
||||||
|
VerificationToken *string `json:"verification_token,omitempty"`
|
||||||
|
// ConsentVersion / ConsentAccepted mirror the charge structs (C6): the
|
||||||
|
// add-card endpoint never charges, so they are recorded on the fallback
|
||||||
|
// audit row (when the client sends them) but not enforced here.
|
||||||
|
ConsentVersion *string `json:"consent_version,omitempty"`
|
||||||
|
ConsentAccepted bool `json:"consent_accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -3371,19 +3509,33 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
service := NewPaymentService()
|
service := NewPaymentService()
|
||||||
|
|
||||||
// 2FA gating (H4): persisting a card via the account "add card" endpoint
|
// SCA-only compliance (M11): the add-card surface's SCA proof is the card
|
||||||
// requires 2FA when the feature is enforced — the same gate the booking
|
// token itself. The frontend performs the STORE-intent SCA at tokenization
|
||||||
// and tip flows apply to req.SaveCard. Persisting a stored credential is
|
// (SquareCardInput.tokenizeForStore) — the tokenize-result IS the card
|
||||||
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
|
// token, and Square validates it as a card-on-file source when
|
||||||
// endpoint must not be the un-gated side door. consume=true: saving a card
|
// CreateCardOnFile persists it. A save that carries a genuine SCA
|
||||||
// is a terminal operation with no downstream charge to attach consumption
|
// tokenize-result as card_token is therefore SCA-compliant and skips the
|
||||||
// to (MEDIUM-2). This request type carries no verification_token field, so
|
// 2FA gate at the call site — exactly like the charge surfaces'
|
||||||
// the 2FA fallback is the only authorization path; a fallback success is
|
// scaTokenizedSavedCard skip: the STORE-intent SCA performed at tokenization
|
||||||
// recorded by the strict audit below.
|
// IS the verification (PSR 2017 reg 100 compliant), so no homegrown fallback
|
||||||
|
// authorization is needed and no fallback audit is written.
|
||||||
|
//
|
||||||
|
// Every other save stays gated through the save-surface variant
|
||||||
|
// (tokenForwardedToSquare=false): a verification_token is client-asserted
|
||||||
|
// and NEVER forwarded to Square on a SAVE surface, so it must NOT skip the
|
||||||
|
// gate (auth-F1 — a forged value cannot authorise a save, and no 2FA code
|
||||||
|
// can either, SCA-only). A token-less legacy save (a raw card.tokenize()
|
||||||
|
// nonce, or a token the backend cannot recognize as a genuine SCA
|
||||||
|
// tokenize-result) is refused 402 verification_required in an enforced
|
||||||
|
// deployment.
|
||||||
|
saveVerificationToken := ""
|
||||||
|
if req.VerificationToken != nil {
|
||||||
|
saveVerificationToken = *req.VerificationToken
|
||||||
|
}
|
||||||
twoFAFallbackUsed := false
|
twoFAFallbackUsed := false
|
||||||
{
|
if !isSCATokenizeResultCardToken(req.CardToken) {
|
||||||
var gateOK bool
|
var gateOK bool
|
||||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, "", true)
|
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, saveVerificationToken, true, false)
|
||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3402,9 +3554,11 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The 2FA BACKUP authorized persisting this card (no SCA was performed on
|
// The 2FA BACKUP authorized persisting this card (no SCA was performed on
|
||||||
// the add-card endpoint); the actor is the customer's own userID.
|
// the add-card endpoint); the actor is the customer's own userID. Unreachable
|
||||||
|
// under SCA-only (the gate never sets fallbackUsed), retained for parity
|
||||||
|
// with the charge surfaces.
|
||||||
if twoFAFallbackUsed && card != nil {
|
if twoFAFallbackUsed && card != nil {
|
||||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)")
|
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(card); err != nil {
|
if err := json.NewEncoder(w).Encode(card); err != nil {
|
||||||
@@ -3412,6 +3566,20 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSCATokenizeResultCardToken reports whether a card token submitted to the
|
||||||
|
// add-card save surface is a GENUINE Square tokenizeWithVerification result —
|
||||||
|
// the STORE-intent tokenize-result the account page's tokenizeForStore
|
||||||
|
// produces (the SCA challenge runs at tokenization, so the returned token is
|
||||||
|
// the SCA-verified source the backend stores). Real Square returns opaque cnon:
|
||||||
|
// tokens, so the dev mock's contract marks a genuine tokenize-result with
|
||||||
|
// "sca-" immediately after the cnon: prefix (internal/square's
|
||||||
|
// isSCATokenizeResultSource) — the enforcement-parity stand-in the charge
|
||||||
|
// paths also rely on (money-F2). A raw card.tokenize() nonce or any other
|
||||||
|
// shape is NOT an SCA proof and never skips the gate.
|
||||||
|
func isSCATokenizeResultCardToken(cardToken string) bool {
|
||||||
|
return strings.HasPrefix(cardToken, "cnon:sca-")
|
||||||
|
}
|
||||||
|
|
||||||
// isDefinitiveCardSaveFailure reports whether a CreatePaymentMethod error is a
|
// isDefinitiveCardSaveFailure reports whether a CreatePaymentMethod error is a
|
||||||
// definitive client rejection — an expired/invalid/already-used card source or
|
// definitive client rejection — an expired/invalid/already-used card source or
|
||||||
// a declined card that can never be saved — as opposed to an ambiguous
|
// a declined card that can never be saved — as opposed to an ambiguous
|
||||||
@@ -3987,6 +4155,16 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// MEDIUM-3a coverage: a manual refund that completes synchronously at
|
||||||
|
// Square (COMPLETED on the first attempt) records the SAME
|
||||||
|
// admin_audit_log row the sweep's re-issue writes — reuse the shared
|
||||||
|
// insertManualRefundAudit helper (refunds.go) so the row shape is
|
||||||
|
// byte-identical: action 'admin_refund', admin actor, payment id, pence
|
||||||
|
// amount and reason, best-effort own-tx non-fatal. Written AFTER the
|
||||||
|
// row is marked completed so a later sweep pass (which only processes
|
||||||
|
// still-pending rows) can never re-resolve this refund and duplicate
|
||||||
|
// the audit row.
|
||||||
|
insertManualRefundAudit(r.Context(), adminID, paymentID, req.Amount, req.Reason)
|
||||||
} else {
|
} else {
|
||||||
status = "pending"
|
status = "pending"
|
||||||
}
|
}
|
||||||
@@ -4555,9 +4733,12 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// consume=true: saving a card is a terminal operation (the card row is
|
// consume=true: saving a card is a terminal operation (the card row is
|
||||||
// created right here), so the verified code is single-use immediately —
|
// created right here), so the verified code is single-use immediately —
|
||||||
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
||||||
// charge's terminal success (MEDIUM-2). A request carrying a Square
|
// charge's terminal success (MEDIUM-2). SCA-primary (auth-F1): the SAVE
|
||||||
// verification_token (SCA performed) skips the gate; a token-less save falls
|
// surface never forwards the client's verification_token to Square (the
|
||||||
// back to 2FA and twoFAFallbackUsed is set for the charge-success audit.
|
// card is persisted via CreateCardOnFile, which takes no token), so a
|
||||||
|
// non-empty token is client-asserted and must NOT skip the gate — a
|
||||||
|
// token-less save falls back to 2FA and twoFAFallbackUsed is set for the
|
||||||
|
// charge-success audit.
|
||||||
twoFAFallbackUsed := false
|
twoFAFallbackUsed := false
|
||||||
tipVerificationToken := ""
|
tipVerificationToken := ""
|
||||||
if req.VerificationToken != nil {
|
if req.VerificationToken != nil {
|
||||||
@@ -4565,10 +4746,15 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if req.SaveCard {
|
if req.SaveCard {
|
||||||
var gateOK bool
|
var gateOK bool
|
||||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, true)
|
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, req.VerificationCode, tipVerificationToken, true, false)
|
||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// C6: a fallback-authorized save/charge must carry the customer's
|
||||||
|
// accepted consent (403 consent_required otherwise).
|
||||||
|
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ValidateAmount(req.Amount); err != nil {
|
if err := ValidateAmount(req.Amount); err != nil {
|
||||||
@@ -4814,6 +5000,11 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !gateOK {
|
if !gateOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// C6: a fallback-authorized charge must carry the customer's accepted
|
||||||
|
// consent (403 consent_required otherwise).
|
||||||
|
if !enforceSCAFallbackConsent(w, req.ConsentVersion, req.ConsentAccepted, twoFAFallbackUsed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reusePendingRecord {
|
if !reusePendingRecord {
|
||||||
@@ -5000,7 +5191,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
// unavailable) — record the strict fallback audit row. The actor is the
|
// unavailable) — record the strict fallback audit row. The actor is the
|
||||||
// customer's own userID (customer-initiated online charge).
|
// customer's own userID (customer-initiated online charge).
|
||||||
if twoFAFallbackUsed {
|
if twoFAFallbackUsed {
|
||||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)")
|
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", consentVersionValue(req.ConsentVersion), req.ConsentAccepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
// Tests for the C2 till-vs-online advisory-lock serialization fix and the
|
||||||
|
// SCA tokenize-result wire contract (new_card_token + saved_card_id on
|
||||||
|
// CreateBookingPayment).
|
||||||
|
//
|
||||||
|
// These tests COMMIT their setup so the advisory locks work across independent
|
||||||
|
// pool connections (see cleanupConcurrentTestRows in concurrency_test.go for
|
||||||
|
// the leak-free deletion order). They swap the SquareClient global and run
|
||||||
|
// lock-bound waits (~3s each), so they are sequential — never t.Parallel.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/internal/square"
|
||||||
|
"crussell/testutils"
|
||||||
|
"crussell/testutils/fixtures"
|
||||||
|
"crussell/testutils/jwt"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enteredCreatePaymentClient blocks inside CreatePayment until the caller
|
||||||
|
// confirms entry (closing entered), then delays `delay` before forwarding.
|
||||||
|
// Because the online handler holds the crussell:payment:<bookingID> advisory
|
||||||
|
// lock from BEFORE the pending insert through to AFTER CreatePayment returns,
|
||||||
|
// a closed entered channel proves the lock is provably held — letting the test
|
||||||
|
// deterministically fire a concurrent till cash payment into that window.
|
||||||
|
type enteredCreatePaymentClient struct {
|
||||||
|
square.SquareClient
|
||||||
|
entered chan struct{}
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *enteredCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||||
|
select {
|
||||||
|
case <-c.entered:
|
||||||
|
default:
|
||||||
|
close(c.entered)
|
||||||
|
}
|
||||||
|
time.Sleep(c.delay)
|
||||||
|
return c.SquareClient.CreatePayment(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitSetupTx commits the per-test transaction so the test operates at pool
|
||||||
|
// level on independent connections (advisory locks only serialize across
|
||||||
|
// independent connections; a shared per-test tx would mask the race).
|
||||||
|
func commitSetupTx(t *testing.T, ctx context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
innerTx := db.TxFromContext(ctx)
|
||||||
|
require.NotNil(t, innerTx, "no transaction in context")
|
||||||
|
require.NoError(t, innerTx.Commit(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTerminalCash_BlockedByConcurrentOnlineCharge pins the C2 fix: while an
|
||||||
|
// online CreateBookingPayment charge holds the crussell:payment:<bookingID>
|
||||||
|
// advisory lock across its Square round-trip, a till CASH payment on the SAME
|
||||||
|
// booking must fail fast with 409 — never pass its remaining-balance check and
|
||||||
|
// record a second amount (which buildSplitRecords would carve into a
|
||||||
|
// non-refundable tip). Before the fix the till branch serialized only on the
|
||||||
|
// bookings-row FOR UPDATE (a DIFFERENT primitive the online path never takes),
|
||||||
|
// so both charges would land and the overflow became a silent tip.
|
||||||
|
func TestTerminalCash_BlockedByConcurrentOnlineCharge(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||||
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||||
|
commitSetupTx(t, ctx)
|
||||||
|
|
||||||
|
// The online charge holds the lock for ~4s (longer than the ~3s bounded
|
||||||
|
// try-lock), so the concurrent till cash request is guaranteed to hit a
|
||||||
|
// contended lock and give up with 409.
|
||||||
|
origClient := SquareClient
|
||||||
|
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
|
||||||
|
SquareClient = slow
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
cardToken := "cnon:c2-online-inflight"
|
||||||
|
onlineReq := CreateBookingPaymentRequest{
|
||||||
|
Amount: 5000,
|
||||||
|
PaymentType: "full",
|
||||||
|
NewCardToken: &cardToken,
|
||||||
|
IdempotencyKey: "c2-online-inflight-" + bookingID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var onlineRec *httptest.ResponseRecorder
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Block until the online charge is inside the Square call — the advisory
|
||||||
|
// lock is provably held from that point until the handler returns.
|
||||||
|
<-slow.entered
|
||||||
|
|
||||||
|
cashReq := CreateTerminalPaymentRequest{
|
||||||
|
Amount: 2000,
|
||||||
|
PaymentType: "full",
|
||||||
|
PaymentMethod: strPtr("cash"),
|
||||||
|
}
|
||||||
|
cashRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", cashReq, adminToken, pool)
|
||||||
|
require.Equal(t, http.StatusConflict, cashRec.Code,
|
||||||
|
"till cash payment must 409 while an online charge holds the payment lock, body: %s", cashRec.Body.String())
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
require.Equal(t, http.StatusOK, onlineRec.Code, "the online charge must succeed once the till payment was blocked: %s", onlineRec.Body.String())
|
||||||
|
|
||||||
|
// No cash payment row, no tip row (the till payment was never recorded),
|
||||||
|
// and the booking's real-money ledger is exactly the online charge.
|
||||||
|
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTerminalGiftCard_BlockedByConcurrentOnlineCharge is the gift-card half
|
||||||
|
// of the C2 fix: the till GIFT-CARD branch takes the same advisory lock, so a
|
||||||
|
// concurrent online charge blocks it too (the pre-fix FOR UPDATE only
|
||||||
|
// serialized against other row-lock holders).
|
||||||
|
func TestTerminalGiftCard_BlockedByConcurrentOnlineCharge(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||||
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||||
|
commitSetupTx(t, ctx)
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second}
|
||||||
|
SquareClient = slow
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
cardToken := "cnon:c2-online-inflight-gc"
|
||||||
|
onlineReq := CreateBookingPaymentRequest{
|
||||||
|
Amount: 5000,
|
||||||
|
PaymentType: "full",
|
||||||
|
NewCardToken: &cardToken,
|
||||||
|
IdempotencyKey: "c2-online-inflight-gc-" + bookingID,
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var onlineRec *httptest.ResponseRecorder
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-slow.entered
|
||||||
|
|
||||||
|
gcReq := CreateTerminalPaymentRequest{
|
||||||
|
Amount: 2000,
|
||||||
|
PaymentType: "full",
|
||||||
|
PaymentMethod: strPtr("giftcard"),
|
||||||
|
GiftCardID: strPtr("GC-0000-0000-0000"),
|
||||||
|
}
|
||||||
|
gcRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", gcReq, adminToken, pool)
|
||||||
|
require.Equal(t, http.StatusConflict, gcRec.Code,
|
||||||
|
"till gift-card payment must 409 while an online charge holds the payment lock, body: %s", gcRec.Body.String())
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
require.Equal(t, http.StatusOK, onlineRec.Code, onlineRec.Body.String())
|
||||||
|
|
||||||
|
assertNoTillMoneyRecorded(t, pool, bookingID, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnlineCharge_BlockedWhileTillHoldsLock is the reverse direction of the
|
||||||
|
// C2 fix: while the till cash/giftcard branch holds the SAME advisory lock
|
||||||
|
// (here held directly to deterministically simulate a till charge in flight),
|
||||||
|
// an online CreateBookingPayment charge must fail fast with 409 — the two
|
||||||
|
// paths now contend on one primitive instead of passing their independent
|
||||||
|
// remaining-balance checks.
|
||||||
|
func TestOnlineCharge_BlockedWhileTillHoldsLock(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||||
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
|
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||||
|
commitSetupTx(t, ctx)
|
||||||
|
|
||||||
|
pool := context.Background()
|
||||||
|
key := "crussell:payment:" + bookingID
|
||||||
|
|
||||||
|
// Simulate a till cash/giftcard charge in flight: hold the same advisory
|
||||||
|
// lock on a dedicated connection for longer than the ~3s try-lock bound.
|
||||||
|
holder, err := db.Conn.Acquire(pool)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer holder.Release()
|
||||||
|
_, err = holder.Exec(pool, `SELECT pg_advisory_lock(hashtext($1))`, key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _, _ = holder.Exec(pool, `SELECT pg_advisory_unlock(hashtext($1))`, key) }()
|
||||||
|
|
||||||
|
cardToken := "cnon:c2-online-blocked"
|
||||||
|
req := CreateBookingPaymentRequest{
|
||||||
|
Amount: 2000,
|
||||||
|
PaymentType: "partial",
|
||||||
|
NewCardToken: &cardToken,
|
||||||
|
IdempotencyKey: "c2-online-blocked-" + bookingID,
|
||||||
|
}
|
||||||
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, pool)
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code,
|
||||||
|
"online charge must 409 while the till holds the payment lock, body: %s", w.Body.String())
|
||||||
|
|
||||||
|
var payCount int
|
||||||
|
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||||
|
require.Zero(t, payCount, "no payment record may exist when the online charge was blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoTillMoneyRecorded pins the "not silently double-record" half of C2:
|
||||||
|
// after a till cash/giftcard payment was blocked, the booking must carry no
|
||||||
|
// till-originated row, no tip row (the pre-fix double-charge carved the
|
||||||
|
// overflow into a non-refundable tip), and exactly wantPence of real-money
|
||||||
|
// completed payments.
|
||||||
|
func assertNoTillMoneyRecorded(t *testing.T, ctx context.Context, bookingID string, wantPence int64) {
|
||||||
|
t.Helper()
|
||||||
|
var cashRows int
|
||||||
|
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&cashRows))
|
||||||
|
require.Zero(t, cashRows, "the blocked till cash payment must not be recorded")
|
||||||
|
var gcRows int
|
||||||
|
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&gcRows))
|
||||||
|
require.Zero(t, gcRows, "the blocked till gift-card payment must not be recorded")
|
||||||
|
var tipRows int
|
||||||
|
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipRows))
|
||||||
|
require.Zero(t, tipRows, "no non-refundable tip row may be minted by the blocked till payment")
|
||||||
|
var paidPence int64
|
||||||
|
require.NoError(t, db.Conn.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(ROUND(SUM(amount) * 100), 0) FROM payments
|
||||||
|
WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'
|
||||||
|
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||||
|
`, bookingID).Scan(&paidPence))
|
||||||
|
require.Equal(t, wantPence, paidPence, "the booking ledger must reflect exactly the online charge")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateBookingPayment_SCATokenizeResult_UsedAsSource pins the SCA wire
|
||||||
|
// contract: a saved-card charge carrying new_card_token (the SCA
|
||||||
|
// tokenize-result from card.tokenize(verificationDetails, cardId)) plus
|
||||||
|
// saved_card_id must call Square with source_id = the tokenize-result token —
|
||||||
|
// NOT the stored ccof id — and with customer_id derived from the saved card,
|
||||||
|
// and must NOT require a verification_token for the new path.
|
||||||
|
func TestCreateBookingPayment_SCATokenizeResult_UsedAsSource(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-test", "VISA", "4242")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
|
rec := installRecordingClient(t)
|
||||||
|
|
||||||
|
token := "cnon:sca-tokenize-result"
|
||||||
|
req := CreateBookingPaymentRequest{
|
||||||
|
Amount: 5000,
|
||||||
|
PaymentType: "full",
|
||||||
|
NewCardToken: &token,
|
||||||
|
UserSavedCardID: &cardID,
|
||||||
|
IdempotencyKey: "sca-tokenize-" + bookingID,
|
||||||
|
}
|
||||||
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
rec.mu.Lock()
|
||||||
|
last := rec.lastReq
|
||||||
|
rec.mu.Unlock()
|
||||||
|
require.Equal(t, token, last.SourceID, "the SCA tokenize-result token must be the charge source_id")
|
||||||
|
require.NotEqual(t, "ccof:sca-tokenize-test", last.SourceID, "the stored ccof id must NOT be the source for the tokenize-result flow")
|
||||||
|
require.NotEmpty(t, last.CustomerID, "customer_id must derive from the saved card row")
|
||||||
|
require.Empty(t, last.VerificationToken, "the tokenize-result flow must not require a legacy verification_token")
|
||||||
|
|
||||||
|
// The recorded payment row must carry the token as its square source and
|
||||||
|
// reference the saved card row.
|
||||||
|
var squareSource, uscID sql.NullString
|
||||||
|
require.NoError(t, tx.QueryRow(ctx, `SELECT square_source_id, user_saved_card_id FROM payments WHERE booking_id = $1`, bookingID).Scan(&squareSource, &uscID))
|
||||||
|
require.Equal(t, token, squareSource.String, "the payment row must record the tokenize-result token as its square source")
|
||||||
|
require.Equal(t, cardID, uscID.String, "the payment row must reference the saved card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate pins that the SCA
|
||||||
|
// tokenize-result flow is SCA-primary: with 2FA enforced, a charge carrying
|
||||||
|
// new_card_token + saved_card_id (no verification_code, no verification_token)
|
||||||
|
// succeeds — the tokenize-result token itself proves buyer verification, so no
|
||||||
|
// homegrown fallback authorization is demanded.
|
||||||
|
func TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate(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-2fa", "VISA", "4242")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
|
installRecordingClient(t)
|
||||||
|
|
||||||
|
token := "cnon:sca-tokenize-2fa"
|
||||||
|
req := CreateBookingPaymentRequest{
|
||||||
|
Amount: 5000,
|
||||||
|
PaymentType: "full",
|
||||||
|
NewCardToken: &token,
|
||||||
|
UserSavedCardID: &cardID,
|
||||||
|
IdempotencyKey: "sca-tokenize-2fa-" + bookingID,
|
||||||
|
}
|
||||||
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code,
|
||||||
|
"an SCA tokenize-result charge must skip the 2FA gate, body: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog locks the M8 fix
|
||||||
|
// for the SYNC path: when the RefundPayment handler's FIRST Square attempt
|
||||||
|
// returns COMPLETED immediately (no pending row for the sweep to re-issue), the
|
||||||
|
// synchronous terminal-success path must write the SAME admin_audit_log row the
|
||||||
|
// sweep's re-issue writes — action 'admin_refund', admin actor, payment id,
|
||||||
|
// pence amount and reason, via the shared insertManualRefundAudit helper.
|
||||||
|
// The row is marked completed so the sweep (which only processes still-pending
|
||||||
|
// rows) can never re-process this refund, guaranteeing exactly one audit row.
|
||||||
|
// Mirrors TestManualRefund_IssuesAdminAuditLog (refunds_test.go) but drives
|
||||||
|
// the handler end-to-end instead of the sweep.
|
||||||
|
func TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog(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)
|
||||||
|
serviceID, err := fixtures.CreateTestService(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
||||||
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
||||||
|
require.NoError(t, err)
|
||||||
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||||
|
require.NoError(t, err)
|
||||||
|
// The mock refunds any non-"pay_mock_" id leniently and returns COMPLETED
|
||||||
|
// synchronously, so the handler's FIRST attempt completes immediately.
|
||||||
|
const chargeID = "sqp_m8_sync_audit"
|
||||||
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The audit insert runs in its OWN transaction via InsertAdminAuditCharge
|
||||||
|
// (never the per-test tx), and the refund's advisory-lock serialization
|
||||||
|
// needs pool-level rows — commit the setup so the sync path executes at
|
||||||
|
// pool level, exactly like the sweep test.
|
||||||
|
commitSetupTx(t, ctx)
|
||||||
|
freshCtx := context.Background()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_audit_log WHERE action_type = 'admin_refund' AND admin_id = $1`, adminID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
|
||||||
|
})
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
SquareClient = square.NewDevClient()
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||||
|
req := RefundRequest{Amount: 5000, Reason: "customer request"}
|
||||||
|
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, freshCtx)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, "synchronous refund should complete 200, body: %s", rec.Body.String())
|
||||||
|
|
||||||
|
var resp RefundResponse
|
||||||
|
require.NoError(t, parsePaymentResponseBody(rec, &resp))
|
||||||
|
require.Equal(t, "completed", resp.Status, "the synchronous COMPLETED refund must resolve to 'completed'")
|
||||||
|
|
||||||
|
// The refund row must be completed so the sweep can never re-process it
|
||||||
|
// (and duplicate the audit).
|
||||||
|
var status string
|
||||||
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, resp.ID).Scan(&status))
|
||||||
|
require.Equal(t, "completed", status)
|
||||||
|
|
||||||
|
// Exactly one admin_refund audit row, carrying payment id, pence amount and
|
||||||
|
// reason.
|
||||||
|
var auditCount int
|
||||||
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `
|
||||||
|
SELECT COUNT(*) FROM admin_audit_log
|
||||||
|
WHERE admin_id = $1 AND action_type = 'admin_refund'
|
||||||
|
`, adminID).Scan(&auditCount))
|
||||||
|
require.Equal(t, 1, auditCount, "expected exactly 1 'admin_refund' audit row for the synchronous refund")
|
||||||
|
var details string
|
||||||
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `
|
||||||
|
SELECT details::text FROM admin_audit_log
|
||||||
|
WHERE admin_id = $1 AND action_type = 'admin_refund'
|
||||||
|
`, adminID).Scan(&details))
|
||||||
|
for _, want := range []string{paymentID, "5000", "customer request"} {
|
||||||
|
require.True(t, strings.Contains(details, want), "expected audit details to carry %q, got %s", want, details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds
|
||||||
|
// pins the M11 fix: in an ENFORCED (SCA-only) deployment a card save that
|
||||||
|
// carries a GENUINE SCA tokenize-result as card_token succeeds — the
|
||||||
|
// STORE-intent SCA performed at tokenization (SquareCardInput.tokenizeForStore)
|
||||||
|
// IS the verification (PSR 2017 reg 100), so the save skips the 2FA gate at the
|
||||||
|
// call site exactly like the charge surfaces' scaTokenizedSavedCard path, and
|
||||||
|
// the card is persisted. Before the fix the handler demanded the (removed) 2FA
|
||||||
|
// fallback and refused every save 402 verification_required, so add-card could
|
||||||
|
// not complete in an enforced deployment.
|
||||||
|
func TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds(t *testing.T) {
|
||||||
|
helperEnvEnforce2FAStaging(t)
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
SquareClient = square.NewDevClient()
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
req := CreatePaymentMethodRequest{CardToken: "cnon:sca-tokenize-store"}
|
||||||
|
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code,
|
||||||
|
"a genuine SCA tokenize-result card save must succeed in an enforced deployment, body: %s", w.Body.String())
|
||||||
|
|
||||||
|
var card SavedCard
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||||
|
require.NotEmpty(t, card.ID, "the saved card must be returned")
|
||||||
|
|
||||||
|
var cardCount int
|
||||||
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
|
||||||
|
require.Equal(t, 1, cardCount, "the SCA-compliant save must persist exactly one card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402 pins
|
||||||
|
// auth-F1 on the add-card surface: a verification_token is CLIENT-ASSERTED and
|
||||||
|
// never forwarded to Square on a SAVE surface (CreateCardOnFile takes no token),
|
||||||
|
// so it must NOT skip the gate — enforced + a raw nonce card_token + a (forged)
|
||||||
|
// non-empty verification_token is refused 402 verification_required and no card
|
||||||
|
// is persisted. The M11 fix cannot be a client-asserted token bypass.
|
||||||
|
func TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402(t *testing.T) {
|
||||||
|
helperEnvEnforce2FAStaging(t)
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
forged := "forged-verification-token"
|
||||||
|
req := CreatePaymentMethodRequest{
|
||||||
|
CardToken: "cnon:2fa-forged-save",
|
||||||
|
VerificationToken: &forged,
|
||||||
|
}
|
||||||
|
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||||
|
require.Equal(t, http.StatusPaymentRequired, w.Code,
|
||||||
|
"a forged verification_token must not skip the save gate, 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 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 forged-token save must not persist a card")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402 pins the M11
|
||||||
|
// refusal half in the SCA suite's own staging helper: a legacy token-less save
|
||||||
|
// (a raw card.tokenize() nonce carrying no SCA proof) is refused 402
|
||||||
|
// verification_required in an enforced deployment and no card is persisted.
|
||||||
|
// Companion to errors_test.go's TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402.
|
||||||
|
func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402(t *testing.T) {
|
||||||
|
helperEnvEnforce2FAStaging(t)
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
token := jwt.GenerateUserToken(userID)
|
||||||
|
|
||||||
|
req := CreatePaymentMethodRequest{CardToken: "cnon:2fa-tokenless-save"}
|
||||||
|
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||||
|
require.Equal(t, http.StatusPaymentRequired, w.Code,
|
||||||
|
"a token-less save must be refused 402 in an enforced deployment, 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 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")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"crussell/internal/square"
|
"crussell/internal/square"
|
||||||
@@ -36,6 +37,24 @@ func truncateIdempotencyKey(prefix, candidate string) string {
|
|||||||
return prefix + "-" + hex.EncodeToString(sum[:16])
|
return prefix + "-" + hex.EncodeToString(sum[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deriveRefundIdempotencyKey returns the deterministic SERVER-SIDE idempotency
|
||||||
|
// key for a refund issued WITHOUT a client-supplied key (M1): a retry of the
|
||||||
|
// same logical refund re-derives the SAME key, so Square's idempotency dedup
|
||||||
|
// returns the original refund instead of minting a SECOND Square refund — even
|
||||||
|
// after the sweep has resolved the first attempt (a client keyed to
|
||||||
|
// (payment_id, amount, refund type) can never regenerate the fresh random
|
||||||
|
// suffix the old no-key fallback used). The key is derived ONLY from stable
|
||||||
|
// request fields — never a random value — and routed through
|
||||||
|
// truncateIdempotencyKey so an over-length candidate stays deterministic and
|
||||||
|
// inside Square's 45-char /v2/refunds limit (preserving its semantics). The
|
||||||
|
// distinct refundType (e.g. "manual" vs "cancellation") keeps a partial refund
|
||||||
|
// of the same payment+amount distinct from a cancellation refund of the same
|
||||||
|
// size, and the paymentID prefix prevents cross-payment collisions.
|
||||||
|
func deriveRefundIdempotencyKey(paymentID string, amountPence int64, refundType string) string {
|
||||||
|
candidate := paymentID + "-refund-" + strconv.FormatInt(amountPence, 10) + "-" + refundType
|
||||||
|
return truncateIdempotencyKey("refund", candidate)
|
||||||
|
}
|
||||||
|
|
||||||
// nextIdempotencyCandidate returns the idempotency-key candidate for slot
|
// nextIdempotencyCandidate returns the idempotency-key candidate for slot
|
||||||
// sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then
|
// sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then
|
||||||
// truncated via truncateIdempotencyKey so the final key stays inside Square's
|
// truncated via truncateIdempotencyKey so the final key stays inside Square's
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crussell/internal/square"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeriveRefundIdempotencyKey_Deterministic locks the M1 property: the
|
||||||
|
// server-side refund key derived from (payment_id, amount pence, refund type)
|
||||||
|
// is DETERMINISTIC — a retry of the same logical refund re-derives the SAME
|
||||||
|
// key, so Square's idempotency dedup returns the original refund instead of
|
||||||
|
// minting a second one.
|
||||||
|
func TestDeriveRefundIdempotencyKey_Deterministic(t *testing.T) {
|
||||||
|
key1 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||||
|
key2 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||||
|
if key1 != key2 {
|
||||||
|
t.Errorf("expected the derived refund key to be deterministic, got %q vs %q", key1, key2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeriveRefundIdempotencyKey_DistinguishesInputs locks the M1 discriminator
|
||||||
|
// fields: the key MUST change when the payment, the amount, or the refund type
|
||||||
|
// changes (a distinct partial refund of the same amount must never collide with
|
||||||
|
// a cancellation refund of the same size).
|
||||||
|
func TestDeriveRefundIdempotencyKey_DistinguishesInputs(t *testing.T) {
|
||||||
|
base := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
|
||||||
|
|
||||||
|
otherPayment := deriveRefundIdempotencyKey("pay9999999999", 5000, "manual")
|
||||||
|
if otherPayment == base {
|
||||||
|
t.Errorf("expected a different payment id to produce a different refund key")
|
||||||
|
}
|
||||||
|
|
||||||
|
otherAmount := deriveRefundIdempotencyKey("pay1234567890", 5001, "manual")
|
||||||
|
if otherAmount == base {
|
||||||
|
t.Errorf("expected a different amount to produce a different refund key")
|
||||||
|
}
|
||||||
|
|
||||||
|
otherType := deriveRefundIdempotencyKey("pay1234567890", 5000, "cancellation")
|
||||||
|
if otherType == base {
|
||||||
|
t.Errorf("expected a different refund type to produce a different refund key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeriveRefundIdempotencyKey_RespectsSquareLimit locks the
|
||||||
|
// truncateIdempotencyKey semantics: every derived key stays within Square's
|
||||||
|
// 45-char /v2/refunds idempotency-key limit, even for an over-length candidate
|
||||||
|
// (a long payment id / large amount), and the truncation stays deterministic.
|
||||||
|
func TestDeriveRefundIdempotencyKey_RespectsSquareLimit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
paymentID string
|
||||||
|
amount int64
|
||||||
|
refundType string
|
||||||
|
}{
|
||||||
|
{"pay1234567890", 5000, "manual"},
|
||||||
|
{"pay1234567890", 5000, "cancellation"},
|
||||||
|
{"a-very-long-payment-id-that-exceeds-the-45-char-limit-when-combined", 999999999, "manual"},
|
||||||
|
{"pay1234567890", 999999999, "a-refund-type-that-is-itself-quite-long"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
key := deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType)
|
||||||
|
if len(key) > maxIdempotencyKeyLength {
|
||||||
|
t.Errorf("derived refund key %q (%d chars) exceeds Square's %d-char limit", key, len(key), maxIdempotencyKeyLength)
|
||||||
|
}
|
||||||
|
if key != deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) {
|
||||||
|
t.Errorf("derived refund key %q is not deterministic across calls", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund locks
|
||||||
|
// the M1 end-to-end dedup: a refund issued with the deterministic server-side
|
||||||
|
// key, resolved by a sweep pass, and then RETRIED maps to the SAME Square refund
|
||||||
|
// (the mock's refundByKey dedup returns the original) — a second Square refund
|
||||||
|
// is never minted. This is the mechanism the refund issuance uses so a
|
||||||
|
// no-client-key retry after sweep resolution dedups onto the first refund.
|
||||||
|
func TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund(t *testing.T) {
|
||||||
|
mock := square.NewDevClient().(*square.MockClient)
|
||||||
|
payment, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "seed-refund-dedup-payment",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed the payment at the mock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentID := "payrefund000001"
|
||||||
|
amountPence := int64(5000)
|
||||||
|
// The no-client-key refund issues with the deterministic server-side key.
|
||||||
|
key := deriveRefundIdempotencyKey(paymentID, amountPence, "manual")
|
||||||
|
|
||||||
|
first, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
||||||
|
PaymentID: payment.SquarePayID,
|
||||||
|
Amount: amountPence,
|
||||||
|
IdempotencyKey: key,
|
||||||
|
Reason: "test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first refund failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the sweep resolves the first attempt, the client retries the SAME
|
||||||
|
// logical refund. The retry re-derives the SAME deterministic key, so the
|
||||||
|
// mock's Square-style dedup returns the ORIGINAL refund — RefundKeyCount
|
||||||
|
// stays 1 and no second Square refund is minted.
|
||||||
|
retry, retryErr := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
|
||||||
|
PaymentID: payment.SquarePayID,
|
||||||
|
Amount: amountPence,
|
||||||
|
IdempotencyKey: deriveRefundIdempotencyKey(paymentID, amountPence, "manual"),
|
||||||
|
Reason: "test",
|
||||||
|
})
|
||||||
|
if retryErr != nil {
|
||||||
|
t.Fatalf("retry after sweep resolution failed: %v", retryErr)
|
||||||
|
}
|
||||||
|
if first.ID != retry.ID {
|
||||||
|
t.Errorf("expected the retry to dedup onto the original refund %s, got a different refund %s", first.ID, retry.ID)
|
||||||
|
}
|
||||||
|
if got := mock.RefundKeyCount(); got != 1 {
|
||||||
|
t.Errorf("expected exactly ONE Square refund after the retry, got %d distinct refund keys", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//go:build test && dev
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
// M18 refund-tier epsilon boundary tests. CalculateRefundForCancellation uses
|
||||||
|
// STRICT comparisons (`> FullRefundThreshold`, `>= PartialRefundThreshold`),
|
||||||
|
// so the exact-boundary tier selection and the just-inside/just-outside
|
||||||
|
// epsilon margins are distinct behaviours. The exact 72h / exact 24h tier
|
||||||
|
// selection is already locked in refunds_test.go
|
||||||
|
// (TestCalculateRefundForCancellation_Exact72hBoundary /
|
||||||
|
// _Exact24hBoundary); these tests pin the epsilon margins AROUND both
|
||||||
|
// boundaries and assert the refundable/kept amounts at the exact boundaries
|
||||||
|
// (which the existing exact-boundary tests only check for the tier string).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateRefundForCancellation_Boundary72h_EpsilonMargins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// 1s past 72h → the > comparison wins: full refund of all pre-payments.
|
||||||
|
over := CalculateRefundForCancellation(100, 50, start.Add(-72*time.Hour-1*time.Second), start)
|
||||||
|
if over.Tier != FullRefundTier {
|
||||||
|
t.Errorf("expected tier %q just past 72h, got %q", FullRefundTier, over.Tier)
|
||||||
|
}
|
||||||
|
if over.RefundableAmount != 50 || over.KeptAmount != 0 {
|
||||||
|
t.Errorf("expected full refund (50 refundable / 0 kept) just past 72h, got %.2f / %.2f", over.RefundableAmount, over.KeptAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1s inside 72h → the >= comparison wins: protected deposit kept.
|
||||||
|
under := CalculateRefundForCancellation(100, 50, start.Add(-72*time.Hour+1*time.Second), start)
|
||||||
|
if under.Tier != PartialRefundTier {
|
||||||
|
t.Errorf("expected tier %q 1s inside 72h, got %q", PartialRefundTier, under.Tier)
|
||||||
|
}
|
||||||
|
// Protected deposit = min(50, 100*0.5) = 50 → everything kept.
|
||||||
|
if under.KeptAmount != 50 || under.RefundableAmount != 0 {
|
||||||
|
t.Errorf("expected 50 kept / 0 refundable 1s inside 72h, got %.2f / %.2f", under.KeptAmount, under.RefundableAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateRefundForCancellation_Boundary24h_EpsilonMargins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// 1s past 24h → the >= comparison wins: protected deposit kept.
|
||||||
|
over := CalculateRefundForCancellation(100, 80, start.Add(-24*time.Hour-1*time.Second), start)
|
||||||
|
if over.Tier != PartialRefundTier {
|
||||||
|
t.Errorf("expected tier %q 1s past 24h, got %q", PartialRefundTier, over.Tier)
|
||||||
|
}
|
||||||
|
// Protected deposit = min(80, 50) = 50 → 30 refundable / 50 kept.
|
||||||
|
if over.RefundableAmount != 30 || over.KeptAmount != 50 {
|
||||||
|
t.Errorf("expected 30 refundable / 50 kept 1s past 24h, got %.2f / %.2f", over.RefundableAmount, over.KeptAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1s inside 24h → the default wins: nothing refundable, everything kept.
|
||||||
|
under := CalculateRefundForCancellation(100, 80, start.Add(-24*time.Hour+1*time.Second), start)
|
||||||
|
if under.Tier != NoRefundTier {
|
||||||
|
t.Errorf("expected tier %q 1s inside 24h, got %q", NoRefundTier, under.Tier)
|
||||||
|
}
|
||||||
|
if under.RefundableAmount != 0 || under.KeptAmount != 80 {
|
||||||
|
t.Errorf("expected 0 refundable / 80 kept 1s inside 24h, got %.2f / %.2f", under.RefundableAmount, under.KeptAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateRefundForCancellation_ExactBoundaries_Amounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Exactly 72h: NOT > 72h → partial tier. Amounts must match the partial
|
||||||
|
// tier computation (protected deposit kept), not the full tier.
|
||||||
|
at72 := CalculateRefundForCancellation(100, 100, start.Add(-72*time.Hour), start)
|
||||||
|
if at72.Tier != PartialRefundTier {
|
||||||
|
t.Fatalf("expected tier %q at exactly 72h, got %q", PartialRefundTier, at72.Tier)
|
||||||
|
}
|
||||||
|
if at72.ProtectedDeposit != 50 || at72.KeptAmount != 50 || at72.RefundableAmount != 50 {
|
||||||
|
t.Errorf("expected protected 50 / kept 50 / refundable 50 at exactly 72h, got %.2f / %.2f / %.2f", at72.ProtectedDeposit, at72.KeptAmount, at72.RefundableAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly 24h: >= 24h → partial tier (protected deposit kept).
|
||||||
|
at24 := CalculateRefundForCancellation(100, 60, start.Add(-24*time.Hour), start)
|
||||||
|
if at24.Tier != PartialRefundTier {
|
||||||
|
t.Fatalf("expected tier %q at exactly 24h, got %q", PartialRefundTier, at24.Tier)
|
||||||
|
}
|
||||||
|
// Protected deposit = min(60, 50) = 50 → 10 refundable / 50 kept.
|
||||||
|
if at24.RefundableAmount != 10 || at24.KeptAmount != 50 {
|
||||||
|
t.Errorf("expected 10 refundable / 50 kept at exactly 24h, got %.2f / %.2f", at24.RefundableAmount, at24.KeptAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
package payments
|
package payments
|
||||||
|
|
||||||
// Tests for the CreateTerminalPayment SCA/2FA decision model: the
|
// Tests for the CreateTerminalPayment SCA decision model: the VerificationToken
|
||||||
// VerificationToken field (handlers.go:114, validation 440-444, extraction
|
// field (handlers.go:114, validation 440-444, extraction 843-846, gate skip
|
||||||
// 843-846, gate skip 1024, forwarding 1113), the admin-actor 2FA-fallback
|
// 1024, forwarding 1113), the SCA-only token-less refusal (402
|
||||||
// audit rows on the terminal and till saved-card surfaces, and the
|
// verification_required) on the terminal and till saved-card surfaces, and the
|
||||||
// customer_initiated classification (Square's SCA/liability-shift signal) on
|
// customer_initiated classification (Square's SCA/liability-shift signal) on
|
||||||
// every saved-card charge site.
|
// every saved-card charge site.
|
||||||
//
|
//
|
||||||
@@ -29,7 +29,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env
|
// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env
|
||||||
// that still resolves to the in-memory mock client.
|
// 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) {
|
func helperEnvEnforce2FAStaging(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
@@ -144,11 +146,12 @@ func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) {
|
|||||||
require.Zero(t, auditCount, "an SCA-authorized terminal charge must not write a 2FA-fallback audit row")
|
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
|
// TestTerminalSavedCard_Tokenless_402 pins the SCA-only posture on the terminal
|
||||||
// terminal saved-card charge falls back to the card owner's 2FA code and writes
|
// saved-card surface: a token-less terminal saved-card charge is refused 402
|
||||||
// a strict 2fa_fallback_charge audit row for the ADMIN actor (admin_id = the
|
// verification_required even when the card owner holds a valid 2FA code (the
|
||||||
// charging admin, target_user_id = the card owner).
|
// homegrown 2FA fallback was removed entirely — PSR 2017 reg 100), and no
|
||||||
func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
// 2fa_fallback_charge audit row is written.
|
||||||
|
func TestTerminalSavedCard_Tokenless_402(t *testing.T) {
|
||||||
helperEnvEnforce2FAStaging(t)
|
helperEnvEnforce2FAStaging(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -160,45 +163,6 @@ func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
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)
|
installRecordingClient(t)
|
||||||
|
|
||||||
req := CreateTerminalPaymentRequest{
|
req := CreateTerminalPaymentRequest{
|
||||||
@@ -206,22 +170,26 @@ func TestTerminalSavedCard_2FAFallbackDisabled_402(t *testing.T) {
|
|||||||
PaymentType: "full",
|
PaymentType: "full",
|
||||||
PaymentMethod: strPtr("saved_card"),
|
PaymentMethod: strPtr("saved_card"),
|
||||||
UserSavedCardID: &cardID,
|
UserSavedCardID: &cardID,
|
||||||
IdempotencyKey: "terminal-sca-no-fallback-" + bookingID,
|
IdempotencyKey: "terminal-tokenless-" + bookingID,
|
||||||
VerificationCode: "998800",
|
VerificationCode: "334411",
|
||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
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())
|
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
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
require.Equal(t, "verification_required", body["code"])
|
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_2FAFallback_Audit_AdminActor pins (c): an admin till
|
// TestTillSavedCard_Tokenless_402 pins the SCA-only posture on the till saved-
|
||||||
// saved-card sale authorized by the 2FA fallback writes a strict
|
// card surface: an admin till saved-card sale is refused 402
|
||||||
// 2fa_fallback_charge audit row for the ADMIN actor with the till sale id as
|
// verification_required even with a valid 2FA code (the 2FA fallback was
|
||||||
// the reference.
|
// removed entirely), and no fallback audit row is written.
|
||||||
func TestTillSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
func TestTillSavedCard_Tokenless_402(t *testing.T) {
|
||||||
helperEnvEnforce2FAStaging(t)
|
helperEnvEnforce2FAStaging(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -249,11 +217,14 @@ func TestTillSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
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 tillSaleID string
|
var auditCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, key).Scan(&tillSaleID))
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'`).Scan(&auditCount))
|
||||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
require.Zero(t, auditCount, "the 2FA fallback was removed — no fallback audit row may be written")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated
|
// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated
|
||||||
|
|||||||
+145
-169
@@ -15,9 +15,6 @@ import (
|
|||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/internal/twofa"
|
"crussell/internal/twofa"
|
||||||
"crussell/mw"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// require2FADisabled reports whether REQUIRE_2FA explicitly disables 2FA
|
// require2FADisabled reports whether REQUIRE_2FA explicitly disables 2FA
|
||||||
@@ -53,195 +50,174 @@ func (s *PaymentService) TwoFactorEnforced() bool {
|
|||||||
return twoFactorEnforced()
|
return twoFactorEnforced()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserTwoFactorEnabled reports whether the user has completed 2FA setup
|
// errTwoFAPepperRequired is returned when TWO_FACTOR_PEPPER is unset in a
|
||||||
// (users.two_factor_enabled). It is the source of truth for the card-access
|
// production-style re-issue gate. Refusing to re-issue is the only safe outcome:
|
||||||
// gate: an enforced environment blocks online card access for users who have
|
// without the pepper the fresh code would be persisted as an unsalted SHA-256
|
||||||
// not enabled 2FA.
|
// digest in the 1M code space, which a log/DB leak could brute-force offline
|
||||||
func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string) (bool, error) {
|
// (mirrors handlers/user's errTwoFAPepperRequired). Defined here (no build tag)
|
||||||
var enabled bool
|
// so the pure gate (twoFAReissueIssueAllowedStrict) and the test,dev suite
|
||||||
err := db.Conn.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
// share it.
|
||||||
if err != nil {
|
var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)")
|
||||||
return false, err
|
|
||||||
|
// errTwoFADeliveryUnavailable is returned when a production-style re-issue has
|
||||||
|
// no delivery channel: email/SMS unwired and the operator has not opted into
|
||||||
|
// the insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). A fresh
|
||||||
|
// code the customer could never receive would strand them on the saved-card
|
||||||
|
// gate (mirrors handlers/user's errTwoFADeliveryUnavailable).
|
||||||
|
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
||||||
|
|
||||||
|
// twoFAPepperConfigured reports whether TWO_FACTOR_PEPPER is set — the pure,
|
||||||
|
// build-agnostic read behind the strict re-issue gate.
|
||||||
|
func twoFAPepperConfigured() bool { return os.Getenv("TWO_FACTOR_PEPPER") != "" }
|
||||||
|
|
||||||
|
// twoFADeliveryChannelConfigured reports whether the deployment has explicitly
|
||||||
|
// configured a 2FA code delivery channel: TWO_FACTOR_ALLOW_LOG_DELIVERY set to
|
||||||
|
// exactly "true" (the only production channel today — email/SMS unwired, P6).
|
||||||
|
// Pure env read, build-agnostic: the build-tagged twoFADeliveryAvailable
|
||||||
|
// (twofa_delivery_dev.go / twofa_delivery_prod.go) is the runtime-facing
|
||||||
|
// wrapper that turns this into the always-true dev channel or the prod env
|
||||||
|
// check.
|
||||||
|
func twoFADeliveryChannelConfigured() bool { return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" }
|
||||||
|
|
||||||
|
// twoFAReissueIssueAllowedStrict is the pure, build-agnostic production-style
|
||||||
|
// re-issue gate: a re-issued 2FA code may be minted ONLY when BOTH
|
||||||
|
// TWO_FACTOR_PEPPER is set (an unsalted digest in the 1M code space would be
|
||||||
|
// offline-brute-forceable) AND a delivery channel is configured (otherwise the
|
||||||
|
// fresh code could never reach the customer). Either way it fails closed. The
|
||||||
|
// build-tagged twoFAReissueIssueAllowed wraps it for production builds;
|
||||||
|
// dev/test builds always allow re-issue and never consult it — but the test,dev
|
||||||
|
// suite exercises THIS function directly, so the fail-closed branches are
|
||||||
|
// CI-visible even though the prod file (!dev && !test) is excluded there.
|
||||||
|
func twoFAReissueIssueAllowedStrict() error {
|
||||||
|
if !twoFAPepperConfigured() {
|
||||||
|
return errTwoFAPepperRequired
|
||||||
}
|
}
|
||||||
return enabled, nil
|
if !twoFADeliveryChannelConfigured() {
|
||||||
|
return errTwoFADeliveryUnavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single source of truth for 2FA verification: crussell/internal/twofa owns
|
// Single source of truth for 2FA verification: crussell/internal/twofa owns
|
||||||
// the code hashing (HMAC-SHA256 keyed by TWO_FACTOR_PEPPER, legacy SHA-256
|
// the code hashing (HMAC-SHA256 keyed by TWO_FACTOR_PEPPER, legacy SHA-256
|
||||||
// fallback), the constant-time compare, the code-lifetime check, and the
|
// fallback), the constant-time compare, the code-lifetime check, and the
|
||||||
// per-user brute-force lockout. The user package's interactive endpoints
|
// per-user brute-force lockout. The user package's interactive endpoints
|
||||||
// (setup/verify/disable) and this saved-card gate all share it; nothing is
|
// (setup/verify/disable) and this file's re-issue path share it; nothing is
|
||||||
// re-implemented locally here. Two agents once shipped a drift-risk duplicate
|
// re-implemented locally here. The saved-card charge gate no longer verifies
|
||||||
// of the hash+verify in this file (hashTwoFAVerificationCode +
|
// 2FA codes at all (SCA-only — the 2FA fallback was removed), so the only
|
||||||
// verifyPendingTwoFactorCode) — that copy is gone, and any future change to
|
// local consumer left is reissueTwoFACodeAfterFailedCharge below.
|
||||||
// the hashing or lockout rules must land in internal/twofa only.
|
|
||||||
|
|
||||||
// verifyPendingTwoFactorCode verifies the submitted code against the user's
|
// enforceSCAFallbackConsent is retained as a compile-compatible NO-OP so the
|
||||||
// stored pending 2FA code. It is a thin delegation shim over
|
// saved-card charge handlers (handlers.go / till.go / giftcards.go) keep
|
||||||
// twofa.VerifyForUser — the single source of truth for the verification core
|
// compiling unchanged. It used to enforce the C6 consent notice on the
|
||||||
// (per-user brute-force lockout, constant-time compare, legacy pre-pepper
|
// SCA-unavailable → 2FA fallback path, which is now REMOVED ENTIRELY: PSR 2017
|
||||||
// hash fallback, code lifetime). consume=true makes a verified code SINGLE-USE
|
// reg 100 makes Strong Customer Authentication mandatory and non-waivable for
|
||||||
// immediately (the pending code is NULLed on success); consume=false verifies
|
// customer-initiated stored-credential charges, and the homegrown 2FA (a
|
||||||
// WITHOUT consuming. Since finding 1 the saved-card CHARGE gates pass
|
// merchant-side check with no bank involvement) cannot legally act as an SCA
|
||||||
// consume=!reusePendingRecord: a FRESH charge consumes at the gate (one code
|
// fallback — authorising a token-less charge via 2FA leaves the MERCHANT liable
|
||||||
// authorizes exactly one charge), while a PENDING-REUSE retry passes false and
|
// for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation, and
|
||||||
// defers consumption to the completed-charge transaction via
|
// customer consent does not cure that. The gate now refuses a token-less
|
||||||
// twofa.ConsumePendingCode, so a retry that fails again keeps its code for one
|
// saved-card charge 402 verification_required BEFORE any fallback can be used,
|
||||||
// more attempt. The save-card SAVE gates pass true because saving a card is a
|
// so fallbackUsed is always false and there is no consent to demand. The
|
||||||
// terminal operation with no downstream charge to attach consumption to. It
|
// callers pass it through as before; it always returns true.
|
||||||
// returns nil on a valid code, or a classified twofa.ErrIncorrect /
|
func enforceSCAFallbackConsent(w http.ResponseWriter, consentVersion *string, consentAccepted bool, fallbackUsed bool) bool {
|
||||||
// twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a wrapped DB error) for
|
return true
|
||||||
// the caller to map to the correct HTTP status.
|
|
||||||
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consume bool) error {
|
|
||||||
return twofa.VerifyForUser(ctx, userID, code, consume)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
|
// consentVersionValue normalizes the request's optional consent_version pointer
|
||||||
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
|
// to a string ("" when absent). Retained because the saved-card charge handlers
|
||||||
// charge carries no Square verification_token). The parse is case-insensitive
|
// still read the field when writing their (now unreachable) fallback audit row.
|
||||||
// and alias-tolerant (false/0/off/no) — a value like "False" or "OFF" never
|
func consentVersionValue(version *string) string {
|
||||||
// silently leaves the fallback ON. Any other value — including empty and
|
if version == nil {
|
||||||
// unknown — keeps the fallback enabled (the shipped default). It is the
|
return ""
|
||||||
// TWO_FACTOR_FALLBACK policy switch read at startup by main.go and exposed via
|
|
||||||
// PaymentService.TwoFactorFallbackEnabled.
|
|
||||||
func twoFactorFallbackEnabled() bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("TWO_FACTOR_FALLBACK"))) {
|
|
||||||
case "false", "0", "off", "no":
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return *version
|
||||||
}
|
}
|
||||||
|
|
||||||
// TwoFactorFallbackEnabled is the exported form of twoFactorFallbackEnabled, so
|
// requireTwoFactorForCardAccess gates the saved-card online payment paths.
|
||||||
// main.go can log the SCA-primary/2FA-backup posture at startup without
|
// It returns (allowed, fallbackUsed): allowed is true when the request may
|
||||||
// re-implementing the env logic.
|
// proceed; fallbackUsed is ALWAYS false — the homegrown 2FA fallback for
|
||||||
func (s *PaymentService) TwoFactorFallbackEnabled() bool {
|
// token-less saved-card charges was REMOVED ENTIRELY, so no charge is ever
|
||||||
return twoFactorFallbackEnabled()
|
// authorized by a 2FA code and no fallback audit row is ever written (the
|
||||||
}
|
// callers' insertTwoFAFallbackAudit branches are unreachable).
|
||||||
|
//
|
||||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths under
|
// It is a thin wrapper over requireTwoFactorForCardAccessWithTokenValidation
|
||||||
// the SCA-primary / 2FA-backup decision model. It returns (allowed, fallbackUsed):
|
// that passes tokenForwardedToSquare=true — the legacy signature is retained
|
||||||
// allowed is true when the request may proceed; fallbackUsed is true when the
|
// because the saved-card CHARGE surfaces (booking, tip, gift-card buy, till)
|
||||||
// authorization was granted by the homegrown 2FA BACKUP (SCA was unavailable and
|
// forward the verification_token to Square in CreatePaymentReq.VerificationToken,
|
||||||
// the customer's 2FA code verified) — the caller must then write a strict
|
// so a non-empty token there IS Square-validated SCA and legitimately skips the
|
||||||
// insertTwoFAFallbackAudit row for the charge.
|
// gate. The card-SAVE surfaces call the WithTokenValidation variant directly
|
||||||
|
// with false (see that helper for the auth-F1 rationale).
|
||||||
//
|
//
|
||||||
// The decision model, in order:
|
// The decision model, in order:
|
||||||
//
|
//
|
||||||
// - 2FA is not enforced (dev/mock) → allowed, no fallback.
|
// - 2FA enforcement is not active (dev/mock, or REQUIRE_2FA disabled) →
|
||||||
|
// allowed. The dev mock simulates SCA (SimulateSavedCardVerificationRequired
|
||||||
|
// + cnon:sca-... tokenize-results), so development has full parity with the
|
||||||
|
// SCA-only production posture.
|
||||||
//
|
//
|
||||||
// - The request carries a Square verification_token (SCA performed — the
|
// - The request carries a Square verification_token (SCA performed — the
|
||||||
// issuer has already authenticated the buyer): SKIP the 2FA gate entirely.
|
// issuer has already authenticated the buyer) AND the token is forwarded to
|
||||||
// SCA is PRIMARY; the issuer did the job, so the homegrown gate is never
|
// Square on this surface (tokenForwardedToSquare=true, the charge paths):
|
||||||
// consulted (fallbackUsed=false). A charge that carries a token passes even
|
// SKIP the gate entirely. SCA is PRIMARY; a charge that carries a token
|
||||||
// for a user who has not enabled 2FA.
|
// passes even for a user who has not enabled 2FA.
|
||||||
//
|
//
|
||||||
// - Otherwise the gate is the FALLBACK authorization for a ccof charge with
|
// - Otherwise the charge is token-less, and there is NO homegrown 2FA
|
||||||
// no verification token. It only runs when the fallback is permitted:
|
// fallback anymore. PSR 2017 reg 100 makes Strong Customer Authentication
|
||||||
//
|
// mandatory and NON-WAIVABLE for customer-initiated stored-credential
|
||||||
// (a) TWO_FACTOR_FALLBACK is enabled (see twoFactorFallbackEnabled) — when
|
// charges, and a merchant-side 2FA check with no bank involvement cannot
|
||||||
// the deployment opts out, a token-less charge is denied 402
|
// legally act as an SCA substitute: authorising a token-less charge via 2FA
|
||||||
// verification_required: the frontend shows the SCA challenge, and if the
|
// would leave the MERCHANT liable for ECI 7 / SLI 210 chargebacks and PSR
|
||||||
// bank cannot do SCA the payment cannot proceed (security-first); and
|
// 2017 reg 77(6) compensation, and customer consent does not cure that. On
|
||||||
//
|
// genuine sca-unavailable the charge is therefore REFUSED 402
|
||||||
// (b) a 2FA code delivery channel exists (twoFADeliveryAvailable, build-
|
// verification_required and the customer is invited to pay online later.
|
||||||
// dependent like the user package's) — a code the customer can never
|
|
||||||
// receive would silently lock the gate, so it is denied 503
|
|
||||||
// ("2FA requires an email or SMS delivery channel").
|
|
||||||
//
|
|
||||||
// - The user has completed 2FA setup (two_factor_enabled) AND the request
|
|
||||||
// carries a verification_code matching the user's stored pending code.
|
|
||||||
//
|
|
||||||
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
|
|
||||||
// environment requires an actual one-time code challenge at charge time, so
|
|
||||||
// merely enabling 2FA (a setup flag) can never unlock saved-card access with
|
|
||||||
// no challenge. The code is the customer's current pending 2FA code, which an
|
|
||||||
// operator relays (delivery is the user package's build-dependent [2FA] log /
|
|
||||||
// email-SMS channel).
|
|
||||||
//
|
|
||||||
// consume controls whether a verified code is NULLed immediately (consume=true
|
|
||||||
// — a FRESH charge's single-use burn at the gate, closing the TOCTOU where a
|
|
||||||
// verified-but-unconsumed code could authorize a second charge; and the
|
|
||||||
// save-card SAVE gate, a terminal operation) or left intact for the caller to
|
|
||||||
// consume when a PENDING-REUSE retry reaches terminal success
|
|
||||||
// (consume=false — see verifyPendingTwoFactorCode / twofa.ConsumePendingCode,
|
|
||||||
// finding 1). In every case the 5-attempt lockout and the
|
|
||||||
// code-destroy-on-lockout semantics are unchanged (twofa.Check).
|
|
||||||
//
|
|
||||||
// The code check is delegated to crussell/internal/twofa via
|
|
||||||
// verifyPendingTwoFactorCode, so this gate participates in the SAME per-user
|
|
||||||
// brute-force lockout (5 failed attempts invalidate the pending code) as the
|
|
||||||
// user package's setup/verify/disable flows. Classified errors map to the HTTP
|
|
||||||
// statuses the frontend expects: incorrect → 400, locked out → 429, missing or
|
|
||||||
// expired → 400, DB failure → 500.
|
|
||||||
//
|
//
|
||||||
// On any denial an error JSON is written (parseable by the frontend via
|
// On any denial an error JSON is written (parseable by the frontend via
|
||||||
// extractErrorMessage) and allowed=false is returned — the caller must abort
|
// extractErrorMessage) and allowed=false is returned — the caller must abort
|
||||||
// the charge.
|
// the charge.
|
||||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
||||||
|
return requireTwoFactorForCardAccessWithTokenValidation(w, r, service, userID, verificationCode, verificationToken, consume, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireTwoFactorForCardAccessWithTokenValidation is the real gate:
|
||||||
|
// requireTwoFactorForCardAccess above is the charge-surface wrapper that passes
|
||||||
|
// tokenForwardedToSquare=true. The extra flag distinguishes surfaces where the
|
||||||
|
// verification_token WILL be forwarded to Square (charge — Square validates it
|
||||||
|
// server-side and rejects a forged token, so a non-empty token legitimately
|
||||||
|
// proves SCA) from surfaces where it is client-asserted and NEVER forwarded
|
||||||
|
// (card SAVE — the card is persisted via CreateCardOnFile, which takes no
|
||||||
|
// verification_token, so Square never validates it).
|
||||||
|
//
|
||||||
|
// auth-F1: on a SAVE surface a forged non-empty verification_token must NOT
|
||||||
|
// skip the gate — with tokenForwardedToSquare=false the token is ignored and
|
||||||
|
// the token-less refusal below applies. An authenticated client can therefore
|
||||||
|
// no longer persist a card to the account with `verification_token: "anything"`
|
||||||
|
// and no SCA. The one legitimate SAVE skip is the call-site's scaTokenizedSavedCard
|
||||||
|
// flow (new_card_token + saved_card_id): there the SCA tokenize-result token IS
|
||||||
|
// the charge source and Square validates it as source_id, so the gate is skipped
|
||||||
|
// by the caller before this helper is ever reached.
|
||||||
|
func requireTwoFactorForCardAccessWithTokenValidation(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool, tokenForwardedToSquare bool) (allowed, fallbackUsed bool) {
|
||||||
if !twoFactorEnforced() {
|
if !twoFactorEnforced() {
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
if service == nil {
|
|
||||||
service = &PaymentService{}
|
|
||||||
}
|
|
||||||
// SCA-primary: a Square verification_token means the issuer already
|
// SCA-primary: a Square verification_token means the issuer already
|
||||||
// completed Strong Customer Authentication — the 2FA gate is skipped and no
|
// completed Strong Customer Authentication — the gate is skipped and no
|
||||||
// fallback audit applies.
|
// fallback applies. This skip is only valid when the token WILL be
|
||||||
if verificationToken != "" {
|
// forwarded to Square (tokenForwardedToSquare=true, the charge surfaces):
|
||||||
|
// Square validates it and rejects a forged value. On a SAVE surface the
|
||||||
|
// token is client-asserted and never reaches Square, so a forged non-empty
|
||||||
|
// token must not skip the gate (auth-F1) — the token-less refusal below
|
||||||
|
// applies instead.
|
||||||
|
if verificationToken != "" && tokenForwardedToSquare {
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
// 2FA is now the BACKUP authorization for a token-less ccof charge. Fail
|
// SCA-only: a token-less saved-card charge has NO 2FA fallback (the
|
||||||
// closed when the deployment disabled the fallback (TWO_FACTOR_FALLBACK) or
|
// homegrown fallback was REMOVED — see the gate doc above for the PSR 2017
|
||||||
// has no delivery channel for its codes (twoFADeliveryAvailable).
|
// legal rationale). It is refused 402 verification_required; the customer is
|
||||||
if !twoFactorFallbackEnabled() {
|
// invited to pay online later (or through Square's buyer-verification flow
|
||||||
writeVerificationRequiredResponse(w)
|
// on a retry). fallbackUsed stays false.
|
||||||
return false, false
|
writeVerificationRequiredResponse(w)
|
||||||
}
|
return false, false
|
||||||
if !twoFADeliveryAvailable() {
|
|
||||||
mw.RespondError(w, http.StatusServiceUnavailable, "2FA requires an email or SMS delivery channel; contact the salon")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
|
|
||||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
if !enabled {
|
|
||||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
// B10: an enforced charge of a saved card needs a live one-time code, not
|
|
||||||
// just the enabled setup flag.
|
|
||||||
if verificationCode == "" {
|
|
||||||
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
|
||||||
case err == nil:
|
|
||||||
// The 2FA BACKUP authorized this token-less saved-card charge. The
|
|
||||||
// caller writes the strict fallback audit row on the charge's success.
|
|
||||||
return true, true
|
|
||||||
case errors.Is(err, twofa.ErrIncorrect):
|
|
||||||
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
|
||||||
return false, false
|
|
||||||
case errors.Is(err, twofa.ErrLockedOut):
|
|
||||||
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
|
||||||
return false, false
|
|
||||||
case errors.Is(err, twofa.ErrMissingOrExpired):
|
|
||||||
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
|
||||||
return false, false
|
|
||||||
default:
|
|
||||||
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
|
|
||||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor verification code")
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reissueTwoFACodeAfterFailedCharge mints a fresh 2FA code after a saved-card
|
// reissueTwoFACodeAfterFailedCharge mints a fresh 2FA code after a saved-card
|
||||||
@@ -363,13 +339,13 @@ func reissueTwoFACodeAfterFailedCharge(ctx context.Context, q db.Querier, userID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
st.SetLastMintAtLocked(clock.Now())
|
st.SetLastMintAtLocked(clock.Now())
|
||||||
// Delivery mirrors the user package's build-dependent behaviour (the
|
// Delivery is build-dependent (twofa_delivery_dev.go / twofa_delivery_prod.go),
|
||||||
// operator relays the [2FA] log line). Production logs the plaintext code
|
// mirroring the user package's twoFADeliverCode: dev/test builds always write
|
||||||
// only when explicitly opted in; dev/test always.
|
// the [2FA] log line (the operator relays the code); production writes it ONLY
|
||||||
if IsExplicitDevOrMockEnv() || os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
|
// when the operator explicitly opted into log delivery
|
||||||
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
|
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — otherwise the plaintext code is never
|
||||||
log.Printf("[2FA] code: %s", code)
|
// logged.
|
||||||
}
|
twoFAReissueDeliverCode(userID, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// twoFAMintCooldown bounds how often the re-issue path mints a fresh 2FA code
|
// twoFAMintCooldown bounds how often the re-issue path mints a fresh 2FA code
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package payments
|
package payments
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||||
// this build. Dev/test builds always have one — the [2FA] log line is the
|
// this build. Dev/test builds always have one — the [2FA] log line is the
|
||||||
// documented loose-fake delivery channel — so the 2FA BACKUP authorization
|
// documented loose-fake delivery channel — so the 2FA BACKUP authorization
|
||||||
@@ -18,3 +20,16 @@ func twoFADeliveryAvailable() bool { return true }
|
|||||||
// twofa_dev.go). Production builds fail closed here — no pepper, no delivery
|
// twofa_dev.go). Production builds fail closed here — no pepper, no delivery
|
||||||
// channel, no codes (see twofa_delivery_prod.go).
|
// channel, no codes (see twofa_delivery_prod.go).
|
||||||
func twoFAReissueIssueAllowed() error { return nil }
|
func twoFAReissueIssueAllowed() error { return nil }
|
||||||
|
|
||||||
|
// twoFAReissueDeliverCode delivers a re-issued code (a fresh saved-card charge
|
||||||
|
// consumed the customer's code at the gate and the charge failed at Square).
|
||||||
|
// Dev/test builds always deliver via the [2FA] log line — the documented
|
||||||
|
// loose-fake delivery channel — so the operator can relay the fresh code to the
|
||||||
|
// customer. Mirrors the user package's twoFADeliverCode; production logs it
|
||||||
|
// ONLY with the explicit TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in (see
|
||||||
|
// twofa_delivery_prod.go). MEDIUM-3b: the user id and the plaintext code go to
|
||||||
|
// SEPARATE log lines so a single record cannot trivially pair them.
|
||||||
|
func twoFAReissueDeliverCode(userID, code string) {
|
||||||
|
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
|
||||||
|
log.Printf("[2FA] code: %s", code)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,18 +13,13 @@ import (
|
|||||||
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
|
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
|
||||||
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
|
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
|
||||||
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
|
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
|
||||||
// denied 503 (see requireTwoFactorForCardAccess). Mirrors
|
// denied 503 (see requireTwoFactorForCardAccess). Delegates to the pure
|
||||||
// handlers/user/twofa_prod.go; dev/test builds always deliver (twofa_delivery_dev.go).
|
// build-agnostic twoFADeliveryChannelConfigured (twofa.go); dev/test builds
|
||||||
|
// always deliver (twofa_delivery_dev.go).
|
||||||
func twoFADeliveryAvailable() bool {
|
func twoFADeliveryAvailable() bool {
|
||||||
return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true"
|
return twoFADeliveryChannelConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
// errTwoFAPepperRequired is returned by twoFAReissueIssueAllowed when
|
|
||||||
// TWO_FACTOR_PEPPER is unset in a production build — the re-issue would
|
|
||||||
// otherwise persist an offline-brute-forceable unsalted SHA-256 digest in the
|
|
||||||
// 1M code space (mirrors handlers/user's errTwoFAPepperRequired).
|
|
||||||
var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)")
|
|
||||||
|
|
||||||
// twoFAReissueIssueAllowed is the re-issue path's issuance gate
|
// twoFAReissueIssueAllowed is the re-issue path's issuance gate
|
||||||
// (reissueTwoFACodeAfterFailedCharge, handlers.go), mirroring the user
|
// (reissueTwoFACodeAfterFailedCharge, handlers.go), mirroring the user
|
||||||
// package's twoFAEnsureIssueAllowed (handlers/user/twofa_prod.go) build-tagged
|
// package's twoFAEnsureIssueAllowed (handlers/user/twofa_prod.go) build-tagged
|
||||||
@@ -32,7 +27,10 @@ var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing
|
|||||||
// Without a channel the code could never reach the customer, and without the
|
// Without a channel the code could never reach the customer, and without the
|
||||||
// pepper every stored code would be an offline-brute-forceable unsalted digest
|
// pepper every stored code would be an offline-brute-forceable unsalted digest
|
||||||
// — either way the re-issue refuses (fail-closed), exactly like the interactive
|
// — either way the re-issue refuses (fail-closed), exactly like the interactive
|
||||||
// mint paths. Dev/test builds always allow issuance (twofa_delivery_dev.go).
|
// mint paths. Delegates to the pure build-agnostic gate
|
||||||
|
// twoFAReissueIssueAllowedStrict (twofa.go), which the test,dev suite also
|
||||||
|
// exercises directly; dev/test builds always allow issuance
|
||||||
|
// (twofa_delivery_dev.go).
|
||||||
//
|
//
|
||||||
// The pepper check is the ONLY hard gate on the re-issue (plus the delivery
|
// The pepper check is the ONLY hard gate on the re-issue (plus the delivery
|
||||||
// channel). PEPPER-CHANGE HAZARD (Loop B finding 2): the pepper keys the
|
// channel). PEPPER-CHANGE HAZARD (Loop B finding 2): the pepper keys the
|
||||||
@@ -43,11 +41,23 @@ var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing
|
|||||||
// re-run 2FA setup), or a fresh saved-card charge whose code was consumed at
|
// re-run 2FA setup), or a fresh saved-card charge whose code was consumed at
|
||||||
// the gate will strand the customer with 400 ErrMissingOrExpired on retry.
|
// the gate will strand the customer with 400 ErrMissingOrExpired on retry.
|
||||||
func twoFAReissueIssueAllowed() error {
|
func twoFAReissueIssueAllowed() error {
|
||||||
if os.Getenv("TWO_FACTOR_PEPPER") == "" {
|
return twoFAReissueIssueAllowedStrict()
|
||||||
return errTwoFAPepperRequired
|
}
|
||||||
|
|
||||||
|
// twoFAReissueDeliverCode delivers a re-issued code after a failed fresh
|
||||||
|
// saved-card charge (the gate consumed the customer's code at verify time).
|
||||||
|
// Production's ONLY channel is the operator's explicit, insecure opt-in to log
|
||||||
|
// delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true — anyone with backend log access
|
||||||
|
// could defeat the 2FA gate on saved-card charges). WITHOUT that flag the
|
||||||
|
// plaintext code is NEVER written to the log (issuance was already refused by
|
||||||
|
// twoFAReissueIssueAllowed, so this no-op is unreachable); with it, the code
|
||||||
|
// goes to the [2FA] log line for the operator to relay to the customer, exactly
|
||||||
|
// like the documented dev flow. MEDIUM-3b: the user id and the plaintext code
|
||||||
|
// go to SEPARATE log lines so a single record cannot trivially pair them.
|
||||||
|
func twoFAReissueDeliverCode(userID, code string) {
|
||||||
|
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
|
||||||
|
log.Printf("[2FA] code delivery requested (user=%s, purpose=re-issue after failed saved-card charge)", userID)
|
||||||
|
log.Printf("[2FA] code: %s", code)
|
||||||
}
|
}
|
||||||
if !twoFADeliveryAvailable() {
|
// Otherwise: deliberate no-op — never log the plaintext code by default.
|
||||||
return errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//go:build test
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
// M17 follow-up (ITEM 2): twofa_delivery_prod.go is excluded from the test,dev
|
||||||
|
// suite (`!dev && !test`), so its fail-closed re-issue branches were never
|
||||||
|
// exercised in CI — the old twofa_delivery_prod_test.go only runs its
|
||||||
|
// assertions in a genuine production build and skips under the test tag. The
|
||||||
|
// pure decision logic now lives build-agnostically in twofa.go
|
||||||
|
// (twoFAReissueIssueAllowedStrict / twoFAPepperConfigured /
|
||||||
|
// twoFADeliveryChannelConfigured); these tests exercise those branches in the
|
||||||
|
// STANDARD test,dev run, so a regression in the prod fail-closed behaviour is
|
||||||
|
// CI-visible even though the prod file itself is only compiled in a genuine
|
||||||
|
// production build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTwoFAReissueIssueAllowedStrict_FailClosed pins the production-style
|
||||||
|
// re-issue gate that twofa_delivery_prod.go's twoFAReissueIssueAllowed
|
||||||
|
// delegates to (reissueTwoFACodeAfterFailedCharge): (a) pepper unset →
|
||||||
|
// re-issue refused (errTwoFAPepperRequired — an unsalted digest in the 1M code
|
||||||
|
// space would be offline-brute-forceable); (b) delivery channel absent →
|
||||||
|
// re-issue refused (errTwoFADeliveryUnavailable — the 503-style error);
|
||||||
|
// (c) both configured → re-issue succeeds.
|
||||||
|
func TestTwoFAReissueIssueAllowedStrict_FailClosed(t *testing.T) {
|
||||||
|
t.Run("pepper_unset_refuses_reissue", func(t *testing.T) {
|
||||||
|
t.Setenv("TWO_FACTOR_PEPPER", "")
|
||||||
|
t.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true")
|
||||||
|
require.ErrorIs(t, twoFAReissueIssueAllowedStrict(), errTwoFAPepperRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delivery_channel_absent_refuses_reissue", func(t *testing.T) {
|
||||||
|
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper")
|
||||||
|
t.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "")
|
||||||
|
require.ErrorIs(t, twoFAReissueIssueAllowedStrict(), errTwoFADeliveryUnavailable)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pepper_and_channel_present_allows_reissue", func(t *testing.T) {
|
||||||
|
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper")
|
||||||
|
t.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true")
|
||||||
|
require.NoError(t, twoFAReissueIssueAllowedStrict())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsTwoFADeliveryChannelConfigured pins the pure delivery-channel
|
||||||
|
// predicate behind the payments 503 refusal: only the exact value "true" opens
|
||||||
|
// the channel.
|
||||||
|
func TestPaymentsTwoFADeliveryChannelConfigured(t *testing.T) {
|
||||||
|
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper")
|
||||||
|
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
|
||||||
|
t.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", v)
|
||||||
|
require.False(t, twoFADeliveryChannelConfigured(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||||
|
}
|
||||||
|
t.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true")
|
||||||
|
require.True(t, twoFADeliveryChannelConfigured())
|
||||||
|
}
|
||||||
@@ -2,36 +2,27 @@
|
|||||||
|
|
||||||
package payments
|
package payments
|
||||||
|
|
||||||
// Tests pinning the Loop B 2FA single-use consume-at-gate fix on the two
|
// Tests pinning the SCA-only behavior on the till and gift-card saved-card
|
||||||
// saved-card charge gates that were still consuming AFTER the charge landed:
|
// charge gates (till.go / giftcards.go) after the 2FA fallback removal: the
|
||||||
// the till saved-card charge (till.go) and the gift-card purchase saved-card
|
// gate NEVER verifies or consumes a 2FA code anymore — a token-less saved-card
|
||||||
// gate (giftcards.go).
|
// 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
|
||||||
// Semantics (mirroring CreateBookingPayment, handlers.go):
|
// re-issue on failure). The consume-at-gate / re-issue semantics that these
|
||||||
// - A FRESH charge verifies the 2FA code WITH consumption at the gate
|
// tests used to pin are GONE: the homegrown 2FA fallback was removed entirely.
|
||||||
// (consume = !reusePendingRecord). The code is single-use, closing the
|
|
||||||
// TOCTOU where a verified-but-unconsumed code could authorize a second
|
|
||||||
// concurrent charge. If Square then fails, a fresh code is re-issued
|
|
||||||
// (reissueTwoFACodeAfterFailedCharge) so the same-key retry has a live
|
|
||||||
// code to verify.
|
|
||||||
// - A PENDING-REUSE retry verifies WITHOUT consuming: the code was re-issued
|
|
||||||
// for exactly this retry, and the post-charge success path consumes it on
|
|
||||||
// terminal success, so a retry that fails again keeps its code for one
|
|
||||||
// more attempt.
|
|
||||||
//
|
//
|
||||||
// These tests flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv and therefore
|
// 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
|
// must stay sequential (no t.Parallel) — see the note at the top of
|
||||||
// twofa_test.go. They use SQUARE_ENVIRONMENT=staging (NOT production) for
|
// twofa_test.go. They use SQUARE_ENVIRONMENT=staging (NOT production) for
|
||||||
// enforcement: twoFactorEnforced() is fail-closed, so any non-mock/dev value
|
// enforcement: twoFactorEnforced() is fail-closed, so any non-mock/dev value
|
||||||
// enforces the gate, while square.NewDevClient() — which the injected fault
|
// enforces the gate, while square.NewDevClient() — which the injected fault
|
||||||
// client and structuredSquareErrorWithCode construct at call time — returns
|
// client constructs at call time — returns the in-memory mock for every env
|
||||||
// the in-memory mock for every env except production/sandbox. The shared
|
// except production/sandbox. The shared helperEnvEnforce2FA sets production,
|
||||||
// helperEnvEnforce2FA sets production, which would panic NewDevClient in a
|
// which would panic NewDevClient in a dev build.
|
||||||
// dev build.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -46,13 +37,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateTillSale_SavedCard_FreshCharge_Failure pins the
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming
|
||||||
// till saved-card gate on a FRESH charge: the 2FA code is consumed AT THE GATE
|
// pins the till saved-card gate: a token-less charge is refused 402
|
||||||
// (single-use), and a failed Square charge re-issues a fresh code so the
|
// verification_required BEFORE the 2FA code is consulted, so the customer's
|
||||||
// same-key retry can verify again. The stored hash must differ from the seeded
|
// pending code is never consumed and no re-issue runs (the fallback was
|
||||||
// one — if the gate still used consume=false the seeded hash would survive
|
// removed — the gate has no code path at all).
|
||||||
// unchanged and there would be no re-issue.
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) {
|
||||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_FreshCharge_Failure_ConsumesAtGate_Reissues(t *testing.T) {
|
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -83,19 +73,24 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_FreshCharge_Failure_Consumes
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a failed fresh saved-card charge must re-issue a live 2FA code for the same-key retry")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.NotEqual(t, twofa.Hash("556677"), hash.String, "the gate must have consumed the seeded code at verification time (single-use)")
|
require.Equal(t, twofa.Hash("556677"), hash.String, "the gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Failure pins the
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused
|
||||||
// till saved-card gate on a PENDING-REUSE retry: the code is verified WITHOUT
|
// pins the same SCA-only refusal on a PENDING-REUSE till retry: the gate still
|
||||||
// consumption, so a retry that fails again keeps its seeded code unchanged and
|
// refuses 402 verification_required (the code is irrelevant), leaving the
|
||||||
// no re-issue runs (the fresh-charge-only guard must not fire).
|
// seeded code untouched.
|
||||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Failure_KeepsCode(t *testing.T) {
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) {
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -142,18 +137,19 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_PendingReuse_Failure_KeepsCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a pending-reuse retry must not consume the code at the gate")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.Equal(t, twofa.Hash("667788"), hash.String, "a failed pending-reuse retry must keep its seeded code unchanged (no re-issue)")
|
require.Equal(t, twofa.Hash("667788"), hash.String, "the gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fresh_Failure pins the gift-card
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming
|
||||||
// purchase saved-card gate on a FRESH charge: consume-at-gate + re-issue on
|
// pins the SCA-only refusal on the gift-card purchase saved-card gate: a
|
||||||
// failure, exactly as the till gate above.
|
// token-less charge is refused 402 verification_required and the seeded code is
|
||||||
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fresh_Failure_ConsumesAtGate_Reissues(t *testing.T) {
|
// untouched (no consumption, no re-issue).
|
||||||
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_RefusedWithoutConsuming(t *testing.T) {
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -179,18 +175,22 @@ func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fresh_Failure_ConsumesAtGate_Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a failed fresh saved-card purchase must re-issue a live 2FA code for the same-key retry")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.NotEqual(t, twofa.Hash("112233"), hash.String, "the gate must have consumed the seeded code at verification time (single-use)")
|
require.Equal(t, twofa.Hash("112233"), hash.String, "the gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Failure pins the
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused
|
||||||
// gift-card purchase gate on a PENDING-REUSE retry: verify-without-consume and
|
// pins the SCA-only refusal on a PENDING-REUSE gift-card purchase retry: the
|
||||||
// no re-issue, so the seeded code survives a second failed retry unchanged.
|
// gate refuses 402 verification_required regardless of the pending state,
|
||||||
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Failure_KeepsCode(t *testing.T) {
|
// leaving the seeded code untouched.
|
||||||
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Tokenless_Refused(t *testing.T) {
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -225,24 +225,20 @@ func TestTwoFactorEnforced_BuyGiftCard_SavedCard_PendingReuse_Failure_KeepsCode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a pending-reuse retry must not consume the code at the gate")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.Equal(t, twofa.Hash("334455"), hash.String, "a failed pending-reuse retry must keep its seeded code unchanged (no re-issue)")
|
require.Equal(t, twofa.Hash("334455"), hash.String, "the gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Failure_Reissues
|
// TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused
|
||||||
// pins the save-gate code-burn re-issue for the BOOKING path: a NEW-card
|
// pins the SCA-only refusal on the booking NEW-card + save_card SAVE gate: a
|
||||||
// (cnon) charge with save_card=true passes the SAVE gate (consume=true — the
|
// token-less save is refused 402 verification_required and the seeded code is
|
||||||
// single-use code is burned there), so when the subsequent Square charge is
|
// untouched (no consumption at the save gate, no re-issue — the fallback is
|
||||||
// definitively declined the re-issue guard must fire (req.SaveCard) and mint a
|
// gone).
|
||||||
// fresh code — otherwise every same-key retry hits "Verification code expired"
|
func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) {
|
||||||
// forever (finding: 2FA code burned by the SAVE gate never re-issued). The
|
|
||||||
// stored hash must differ from the seeded one: if the re-issue did not run the
|
|
||||||
// gate's consumption would leave no pending code at all.
|
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Failure_Reissues(t *testing.T) {
|
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -266,20 +262,20 @@ func TestTwoFactorEnforced_CreateBookingPayment_NewCardSaveCard_Failure_Reissues
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a failed new-card + save_card booking charge must re-issue a live 2FA code for the same-key retry")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.NotEqual(t, twofa.Hash("999001"), hash.String, "the SAVE gate consumed the seeded code at verification time — the failure must re-issue a fresh one")
|
require.Equal(t, twofa.Hash("999001"), hash.String, "the save gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Failure_Reissues pins
|
// TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused pins
|
||||||
// the same save-gate code-burn re-issue for the TIP path (mirrors the booking
|
// the same SCA-only refusal on the TIP new-card + save_card SAVE gate.
|
||||||
// test above): a NEW-card tip with save_card=true burns its code at the SAVE
|
func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Tokenless_Refused(t *testing.T) {
|
||||||
// gate, so a definitively declined charge must re-issue a fresh code for the
|
|
||||||
// same-key retry.
|
|
||||||
func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Failure_Reissues(t *testing.T) {
|
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -304,20 +300,22 @@ func TestTwoFactorEnforced_CreateTipPayment_NewCardSaveCard_Failure_Reissues(t *
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
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
|
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.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&hash))
|
||||||
require.True(t, hash.Valid, "a failed new-card + save_card tip charge must re-issue a live 2FA code for the same-key retry")
|
require.True(t, hash.Valid, "the pending 2FA code must survive a refused token-less charge")
|
||||||
require.NotEqual(t, twofa.Hash("999002"), hash.String, "the SAVE gate consumed the seeded code at verification time — the failure must re-issue a fresh one")
|
require.Equal(t, twofa.Hash("999002"), hash.String, "the save gate must not consume the code — it was never consulted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown pins the
|
// TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown pins the
|
||||||
// LOW-MEDIUM finding 2 contract on the re-issue path: the re-issue mints a
|
// LOW-MEDIUM finding 2 contract on the retained re-issue helper: the re-issue
|
||||||
// live code after a FRESH charge consumed one at the gate, respects the same
|
// mints a live code after a FRESH charge consumed one at the gate (in the
|
||||||
// per-user mint cooldown as the interactive mint endpoints (a second re-issue
|
// pre-removal world), respects the same per-user mint cooldown as the
|
||||||
// inside the window is a no-op), and runs again once the cooldown elapses
|
// interactive mint endpoints (a second re-issue inside the window is a no-op),
|
||||||
// (simulated by clearing the shared stamp the way a successful verify does).
|
// and runs again once the cooldown elapses (simulated by clearing the shared
|
||||||
|
// stamp). The helper is retained because handlers.go / till.go / giftcards.go
|
||||||
|
// still call it with their (now always-false) fallbackUsed flag.
|
||||||
func TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown(t *testing.T) {
|
func TestReissueTwoFACodeAfterFailedCharge_RespectsMintCooldown(t *testing.T) {
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||||
|
|||||||
@@ -2,22 +2,24 @@
|
|||||||
|
|
||||||
package payments
|
package payments
|
||||||
|
|
||||||
// Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env
|
// Tests for the PSD2 SCA gate (twofa.go): the twoFactorEnforced() env matrix,
|
||||||
// matrix, requireTwoFactorForCardAccess() gating, and the end-to-end
|
// requireTwoFactorForCardAccess() gating, and the end-to-end enforcement of the
|
||||||
// enforcement of the saved-card payment paths in CreateBookingPayment /
|
// saved-card payment paths in CreateBookingPayment / CreateTillSale. Saved-card
|
||||||
// CreateTillSale. Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv
|
// charges are SCA-ONLY: the homegrown 2FA fallback was REMOVED entirely (PSR
|
||||||
// must stay sequential (no t.Parallel): os.Getenv is process-global and
|
// 2017 reg 100 — SCA mandatory and non-waivable for customer-initiated
|
||||||
// t.Setenv panics under t.Parallel. Sequential tests run before this package's
|
// stored-credential charges), so a token-less saved-card charge is always
|
||||||
// parallel batch, so the enforced env never leaks into parallel tests.
|
// 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
@@ -37,11 +39,16 @@ func helperEnvEnforce2FA(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("REQUIRE_2FA", "true")
|
t.Setenv("REQUIRE_2FA", "true")
|
||||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
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
|
// seedTwoFAPendingCode stores a pending 2FA code hash + expiry for a user, the
|
||||||
// state the user package's deliverTwoFACode writes. The hash uses the shared
|
// state the user package's deliverTwoFACode writes. The hash uses the shared
|
||||||
// twofa.Hash — the same single source of truth the gate verifies with.
|
// 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) {
|
func seedTwoFAPendingCode(t *testing.T, q db.Querier, userID, code string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := q.Exec(context.Background(), `
|
_, err := q.Exec(context.Background(), `
|
||||||
@@ -103,7 +110,7 @@ func TestTwoFactorEnforced(t *testing.T) {
|
|||||||
|
|
||||||
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
|
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
|
||||||
// leg of the decision model: when the request carries a Square verification_token
|
// leg of the decision model: when the request carries a Square verification_token
|
||||||
// (the issuer already completed SCA), the 2FA gate is skipped entirely — even a
|
// (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.
|
// user with NO 2FA setup passes, no code is demanded, and no fallback is used.
|
||||||
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
|
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
@@ -113,39 +120,220 @@ func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
|
||||||
require.True(t, allowed, "SCA performed — the 2FA gate must be skipped")
|
require.True(t, allowed, "SCA performed — the gate must be skipped")
|
||||||
require.False(t, fallbackUsed, "SCA is primary — the 2FA fallback was not used")
|
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")
|
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured pins the
|
// TestRequireTwoFactorForCardAccess_TokenForwardedDistinction pins auth-F1 at
|
||||||
// SCA-only posture (TWO_FACTOR_FALLBACK=false): a token-less saved-card charge
|
// the gate level: the SAME non-empty verification_token must be treated
|
||||||
// has no 2FA fallback, so the gate denies 402 with the structured
|
// differently by surface. On a token-FORWARDED (charge) surface it is
|
||||||
// verification_required body the frontend keys on to trigger the SCA challenge.
|
// Square-validated SCA and skips the gate (tokenForwardedToSquare=true). On a
|
||||||
func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing.T) {
|
// 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)
|
helperEnvEnforce2FA(t)
|
||||||
t.Setenv("TWO_FACTOR_FALLBACK", "false")
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
seedTwoFAPendingCode(t, tx, userID, "123456")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
||||||
require.False(t, allowed, "SCA-only posture: no 2FA fallback for a token-less charge")
|
require.False(t, allowed, "SCA-only: no 2FA fallback for a token-less charge")
|
||||||
require.False(t, fallbackUsed)
|
require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed")
|
||||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
require.Equal(t, "verification_required", body["code"])
|
require.Equal(t, "verification_required", body["code"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits pins the strict audit
|
// TestTwoFactorEnforced_BookingSavedCard_Tokenless_402 pins the SCA-only
|
||||||
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
|
// posture end-to-end on the booking saved-card charge path: a token-less
|
||||||
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
|
// saved-card charge (even with a valid 2FA code) is refused 402
|
||||||
// the customer actor — the operator can distinguish SCA-authorized charges from
|
// verification_required, no payment row is created, and no 2fa_fallback_charge
|
||||||
// fallback-authorized ones — and the row's details JSON must carry the strict
|
// audit row is written (the fallback audit path is unreachable — the gate never
|
||||||
// shape (sca_performed:false, fallback_reason, card_last4, reference_id, notes).
|
// authorises a charge by 2FA).
|
||||||
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
func TestTwoFactorEnforced_BookingSavedCard_Tokenless_402(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -164,38 +352,22 @@ func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
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"])
|
||||||
|
|
||||||
details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
var payCount int
|
||||||
require.Equal(t, false, details["sca_performed"])
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||||
require.Equal(t, "verification_unavailable", details["fallback_reason"])
|
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))
|
||||||
// assertTwoFAFallbackAuditDetails reads the most recent '2fa_fallback_charge'
|
require.Zero(t, auditCount, "the 2FA fallback was removed — no fallback audit row may be written")
|
||||||
// admin_audit_log row for (target_user_id, admin_id) and asserts the strict
|
|
||||||
// insertTwoFAFallbackAudit details shape: sca_performed=false,
|
|
||||||
// fallback_reason="verification_unavailable", card_last4, reference_id, notes.
|
|
||||||
func assertTwoFAFallbackAuditDetails(t *testing.T, ctx context.Context, q db.Querier, userID, adminID, referenceID, notes, wantLast4 string) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
var detailsJSON []byte
|
|
||||||
require.NoError(t, q.QueryRow(ctx, `
|
|
||||||
SELECT details FROM admin_audit_log
|
|
||||||
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $2
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
`, userID, adminID).Scan(&detailsJSON))
|
|
||||||
var details map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(detailsJSON, &details))
|
|
||||||
require.Equal(t, false, details["sca_performed"], "details.sca_performed must be false")
|
|
||||||
require.Equal(t, "verification_unavailable", details["fallback_reason"], "details.fallback_reason")
|
|
||||||
require.Equal(t, wantLast4, details["card_last4"], "details.card_last4")
|
|
||||||
require.Equal(t, referenceID, details["reference_id"], "details.reference_id")
|
|
||||||
require.Equal(t, notes, details["notes"], "details.notes")
|
|
||||||
return details
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
|
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
|
||||||
// authorized charge (verification token present) writes NO 2fa_fallback_charge
|
// authorized charge (verification token present) writes NO 2fa_fallback_charge
|
||||||
// audit row: SCA is primary and the homegrown fallback was not used.
|
// audit row: SCA is primary and the fallback no longer exists at all.
|
||||||
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
|
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -224,11 +396,10 @@ func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
|
|||||||
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
|
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_TipSavedCard_Fallback_Audit pins the strict audit
|
// TestTwoFactorEnforced_TipSavedCard_Tokenless_402 pins the SCA-only posture on
|
||||||
// trail on the TIP SAVE gate (CreateTipPayment with save_card=true): the
|
// the TIP SAVE gate (CreateTipPayment with save_card=true): a token-less save
|
||||||
// token-less save is authorized by the 2FA fallback, and the resulting
|
// is refused 402 verification_required even with a valid 2FA code.
|
||||||
// charge writes a 2fa_fallback_charge row for the customer actor.
|
func TestTwoFactorEnforced_TipSavedCard_Tokenless_402(t *testing.T) {
|
||||||
func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) {
|
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -248,16 +419,17 @@ func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||||
|
var body map[string]string
|
||||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
|
require.Equal(t, "verification_required", body["code"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit pins the strict audit
|
// TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402 pins the SCA-only
|
||||||
// trail on the TIP CHARGE gate (CreateTipPayment charging an existing saved
|
// posture on the TIP CHARGE gate (CreateTipPayment charging an existing saved
|
||||||
// card): the token-less charge is authorized by the 2FA fallback and writes a
|
// card): a token-less charge is refused 402 verification_required even with a
|
||||||
// 2fa_fallback_charge row for the customer actor.
|
// valid 2FA code.
|
||||||
func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) {
|
func TestTwoFactorEnforced_TipChargeSavedCard_Tokenless_402(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -277,17 +449,16 @@ func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||||
|
var body map[string]string
|
||||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
|
require.Equal(t, "verification_required", body["code"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit pins the strict
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402 pins the SCA-only
|
||||||
// audit trail on the gift-card purchase saved-card gate (giftcards.go): the
|
// posture on the gift-card purchase saved-card gate (giftcards.go): a token-less
|
||||||
// token-less charge is authorized by the 2FA fallback and writes a
|
// charge is refused 402 verification_required even with a valid 2FA code.
|
||||||
// 2fa_fallback_charge row for the customer actor with the payment id as the
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Tokenless_402(t *testing.T) {
|
||||||
// reference.
|
|
||||||
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) {
|
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -308,27 +479,26 @@ func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
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 payID string
|
var payCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, key).Scan(&payID))
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", key).Scan(&payCount))
|
||||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, payID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)", "4242")
|
require.Zero(t, payCount, "a refused token-less gift-card purchase must not create a payment row")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit pins the strict audit
|
// TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402 pins the SCA-only
|
||||||
// trail on the add-card save gate (handlers.go CreatePaymentMethod): persisting
|
// posture on the add-card save gate (handlers.go CreatePaymentMethod): the
|
||||||
// a card via the 2FA fallback writes a 2fa_fallback_charge row for the customer
|
// dedicated add-card endpoint carries no verification_token field, so a save is
|
||||||
// actor with an empty reference id (no charge reference exists for a save).
|
// refused 402 verification_required even with a valid 2FA code (SCA-only).
|
||||||
func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) {
|
func TestTwoFactorEnforced_PaymentMethodSave_Tokenless_402(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() {
|
|
||||||
InvalidateSquareCustomerCache(userID)
|
|
||||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
|
||||||
})
|
|
||||||
token := jwt.GenerateUserToken(userID)
|
token := jwt.GenerateUserToken(userID)
|
||||||
seedTwoFAPendingCode(t, tx, userID, "998877")
|
seedTwoFAPendingCode(t, tx, userID, "998877")
|
||||||
|
|
||||||
@@ -338,9 +508,14 @@ func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
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"])
|
||||||
|
|
||||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, "", "card persisted via 2FA fallback (SCA unavailable)", "4242")
|
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
|
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
|
||||||
@@ -354,128 +529,82 @@ func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
|
||||||
require.True(t, allowed, "no response must be written when not enforced")
|
require.True(t, allowed, "no response must be written when not enforced")
|
||||||
require.False(t, fallbackUsed, "not enforced — the 2FA fallback did not authorize anything")
|
require.False(t, fallbackUsed, "not enforced — no fallback is ever used")
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
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) {
|
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
t.Run("user_not_enabled_writes_403_json", func(t *testing.T) {
|
t.Run("user_not_enabled_writes_402_json", func(t *testing.T) {
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON")
|
||||||
require.NotEmpty(t, body["error"])
|
require.Equal(t, "verification_required", body["code"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("user_enabled_but_no_code_writes_403_json", func(t *testing.T) {
|
t.Run("user_enabled_but_no_code_writes_402_json", func(t *testing.T) {
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
// B10: the enabled setup flag alone must NOT unlock the gate.
|
|
||||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("user_enabled_with_valid_code_allows", func(t *testing.T) {
|
t.Run("user_enabled_with_valid_code_still_refused_402", func(t *testing.T) {
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
||||||
require.True(t, allowed)
|
require.False(t, allowed, "a valid 2FA code cannot authorise a token-less charge (SCA-only)")
|
||||||
require.True(t, fallbackUsed, "a code-verified token-less charge uses the 2FA fallback")
|
require.False(t, fallbackUsed, "fallbackUsed must be false — the 2FA fallback was removed")
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("user_enabled_with_wrong_code_writes_400_json", func(t *testing.T) {
|
t.Run("user_enabled_with_wrong_code_writes_402_json", func(t *testing.T) {
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok, "the gate does not verify codes anymore — any token-less charge is refused 402")
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
var body map[string]string
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
||||||
require.Equal(t, "Invalid verification code", body["error"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
|
t.Run("unknown_user_writes_402_json", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code)
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "402 body must be structured JSON")
|
||||||
require.NotEmpty(t, body["error"])
|
require.Equal(t, "verification_required", body["code"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume pins the MEDIUM-2
|
|
||||||
// contract: the charge gate verifies the code WITHOUT consuming it (consume
|
|
||||||
// happens later, at the charge's terminal SUCCESS state via
|
|
||||||
// twofa.ConsumePendingCode), so a failed/ambiguous Square charge does NOT burn
|
|
||||||
// the operator-relayed code — a same-key retry can re-verify the SAME code.
|
|
||||||
// Only an explicit ConsumePendingCode (the completed-charge path) NULLs it,
|
|
||||||
// after which the code is dead ("expired").
|
|
||||||
func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
|
||||||
helperEnvEnforce2FA(t)
|
|
||||||
ctx, tx := testutils.SetupTestTx(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.True(t, allowed, "first gate pass must succeed")
|
|
||||||
require.True(t, fallbackUsed, "the code verification is the 2FA fallback")
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
// The code must still be present — the gate verified WITHOUT consuming.
|
|
||||||
var pendingHash sql.NullString
|
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
|
||||||
require.True(t, pendingHash.Valid, "the gate must NOT consume the code (MEDIUM-2)")
|
|
||||||
|
|
||||||
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
||||||
require.True(t, allowed, "a not-yet-consumed code must pass the gate again on retry")
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
// Consumption happens at the charge's terminal SUCCESS state.
|
|
||||||
require.NoError(t, twofa.ConsumePendingCode(ctx, tx, userID))
|
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
|
||||||
require.False(t, pendingHash.Valid, "ConsumePendingCode must NULL the pending code")
|
|
||||||
|
|
||||||
// A further attempt with the consumed code is denied as expired.
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
||||||
require.False(t, allowed, "a consumed code must not pass the gate")
|
|
||||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
var body map[string]string
|
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
||||||
require.Equal(t, "Verification code expired — request a new one", body["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
|
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
|
||||||
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 with
|
// end-to-end gate on the save-card path: enforced + no SCA → 402
|
||||||
// no payment row and no saved card (Square never called).
|
// verification_required with no payment row and no saved card (Square never
|
||||||
|
// called).
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -493,10 +622,10 @@ func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
require.Contains(t, body["error"], "Two-factor")
|
require.Equal(t, "verification_required", body["code"])
|
||||||
|
|
||||||
var payCount int
|
var payCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||||
@@ -507,7 +636,7 @@ func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
|
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
|
||||||
// gate on charging an existing saved card.
|
// gate on charging an existing saved card without SCA.
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
|
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -525,17 +654,20 @@ func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
require.Contains(t, body["error"], "Two-factor")
|
require.Equal(t, "verification_required", body["code"])
|
||||||
|
|
||||||
var payCount int
|
var payCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
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")
|
require.Zero(t, payCount, "blocked saved-card charge must not create a payment row")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *testing.T) {
|
// 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)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -554,14 +686,20 @@ func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
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
|
var payCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||||
require.Equal(t, 1, payCount)
|
require.Zero(t, payCount, "a valid 2FA code must not create a payment row (SCA-only)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *testing.T) {
|
// 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)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -580,12 +718,15 @@ func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *te
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
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
|
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
|
||||||
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
|
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
|
||||||
// when enforced.
|
// when enforced (SCA is performed by Square's buyer-verification flow).
|
||||||
func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
|
func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -606,8 +747,8 @@ func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
|
||||||
// saved-card charge path: an admin charging a customer's saved card while the
|
// saved-card charge path: an admin charging a customer's saved card without SCA
|
||||||
// card's owner has no 2FA is blocked with 403 and no till_sale is created.
|
// is blocked with 402 verification_required and no till_sale is created.
|
||||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
@@ -643,17 +784,20 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
|
|||||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||||
require.Contains(t, body["error"], "Two-factor")
|
require.Equal(t, "verification_required", body["code"])
|
||||||
|
|
||||||
var saleCount int
|
var saleCount int
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount))
|
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")
|
require.Zero(t, saleCount, "blocked till saved-card sale must not create a till_sale row")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.T) {
|
// 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)
|
helperEnvEnforce2FA(t)
|
||||||
_, tx := testutils.SetupTestTx(t)
|
_, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
@@ -690,145 +834,48 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.
|
|||||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
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_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted
|
// TestSCASuccess_BookingSavedCard_NeverRequiresConsent pins the SCA-primary
|
||||||
// pins the Loop B MEDIUM gate-ordering fix: the save-card 2FA gate runs AFTER
|
// carve-out: a charge carrying a Square verification_token (SCA performed) is
|
||||||
// the idempotency dedup's completed short-circuit. A same-key lost-response
|
// never gated on the (removed) fallback consent — with no consent fields and no
|
||||||
// retry re-sends the SAME single-use verification code that the original
|
// 2FA setup it succeeds and writes no fallback audit row.
|
||||||
// attempt already consumed; if the gate ran first it would 400 "Verification
|
func TestSCASuccess_BookingSavedCard_NeverRequiresConsent(t *testing.T) {
|
||||||
// code expired". With the gate below the dedup, the retry returns the
|
|
||||||
// already-completed payment instead of re-entering the gate.
|
|
||||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted(t *testing.T) {
|
|
||||||
helperEnvEnforce2FA(t)
|
helperEnvEnforce2FA(t)
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
userToken := jwt.GenerateUserToken(userID)
|
userToken := jwt.GenerateUserToken(userID)
|
||||||
seedTwoFAPendingCode(t, tx, userID, "778899")
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:consent_sca", "VISA", "4242")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
cardToken := "cnon:2fa-save-card-retry"
|
vrf := "vrf_consent_sca_success"
|
||||||
req := CreateBookingPaymentRequest{
|
req := CreateBookingPaymentRequest{
|
||||||
Amount: 2500,
|
Amount: 5000,
|
||||||
PaymentType: "deposit",
|
PaymentType: "full",
|
||||||
NewCardToken: &cardToken,
|
CardID: &cardID,
|
||||||
SaveCard: true,
|
IdempotencyKey: "consent-sca",
|
||||||
IdempotencyKey: "2fa-save-card-retry",
|
VerificationToken: &vrf,
|
||||||
VerificationCode: "778899",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := withNonGuest(CreateBookingPayment)
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||||
w := makePaymentRequest(handler, "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())
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
// Same-key retry re-sends the identical request, whose code is now
|
var auditCount int
|
||||||
// consumed. The completed-dedup must return the payment before the gate.
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'").Scan(&auditCount))
|
||||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
require.Zero(t, auditCount, "an SCA-success charge must not write a fallback audit row")
|
||||||
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed payment, not re-run the gate: %s", w2.Body.String())
|
|
||||||
|
|
||||||
var payCount int
|
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
|
||||||
require.Equal(t, 1, payCount, "the retry must not create a second payment")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted pins
|
|
||||||
// the Loop B MEDIUM gate-ordering fix on the tip endpoint: the saved-card
|
|
||||||
// charge 2FA gate runs AFTER the tip idempotency dedup's completed
|
|
||||||
// short-circuit. A same-key lost-response retry re-sends the SAME single-use
|
|
||||||
// verification code the original attempt consumed; with the gate first it would
|
|
||||||
// 400 "expired", with the gate below the dedup the retry returns the completed
|
|
||||||
// tip instead.
|
|
||||||
func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *testing.T) {
|
|
||||||
helperEnvEnforce2FA(t)
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
|
||||||
|
|
||||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
||||||
userToken := jwt.GenerateUserToken(userID)
|
|
||||||
seedTwoFAPendingCode(t, tx, userID, "667788")
|
|
||||||
|
|
||||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
||||||
require.NoError(t, err)
|
|
||||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_tip_retry", "VISA", "4321")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req := CreateTipPaymentRequest{
|
|
||||||
Amount: 500,
|
|
||||||
CardID: &cardID,
|
|
||||||
IdempotencyKey: "2fa-tip-saved-card-retry",
|
|
||||||
VerificationCode: "667788",
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := withNonGuest(CreateTipPayment)
|
|
||||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
||||||
|
|
||||||
// Same-key retry re-sends the identical request, whose code is now
|
|
||||||
// consumed. The completed-dedup must return the tip before the gate.
|
|
||||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
||||||
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed tip, not re-run the gate: %s", w2.Body.String())
|
|
||||||
|
|
||||||
var tipCount int
|
|
||||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
|
|
||||||
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTwoFactorFallbackEnabled pins the TWO_FACTOR_FALLBACK policy-switch
|
|
||||||
// parse matrix (twofa.go:100-107): false/0/off/no are case-insensitive
|
|
||||||
// DISABLES; every other value — empty, unset, unknown, true/1/on/yes — keeps
|
|
||||||
// the fallback enabled (the shipped default). The exported
|
|
||||||
// PaymentService.TwoFactorFallbackEnabled wrapper must match the unexported
|
|
||||||
// predicate. Sequential (no t.Parallel): flips process-global env vars.
|
|
||||||
func TestTwoFactorFallbackEnabled(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
val string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"false disables", "false", false},
|
|
||||||
{"zero disables", "0", false},
|
|
||||||
{"off disables", "off", false},
|
|
||||||
{"no disables", "no", false},
|
|
||||||
{"case_insensitive_False_disables", "False", false},
|
|
||||||
{"case_insensitive_OFF_disables", "OFF", false},
|
|
||||||
{"case_insensitive_No_disables", "No", false},
|
|
||||||
{"empty_keeps_enabled", "", true},
|
|
||||||
{"unknown_keeps_enabled", "enable", true},
|
|
||||||
{"true_keeps_enabled", "true", true},
|
|
||||||
{"one_keeps_enabled", "1", true},
|
|
||||||
{"on_keeps_enabled", "on", true},
|
|
||||||
{"yes_keeps_enabled", "yes", true},
|
|
||||||
{"case_insensitive_TRUE_keeps_enabled", "TRUE", true},
|
|
||||||
{"case_insensitive_ON_keeps_enabled", "ON", true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Setenv("TWO_FACTOR_FALLBACK", tt.val)
|
|
||||||
require.Equal(t, tt.want, twoFactorFallbackEnabled())
|
|
||||||
require.Equal(t, tt.want, NewPaymentService().TwoFactorFallbackEnabled(), "exported wrapper must match twoFactorFallbackEnabled")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("unset_keeps_enabled_default", func(t *testing.T) {
|
|
||||||
prev, had := os.LookupEnv("TWO_FACTOR_FALLBACK")
|
|
||||||
os.Unsetenv("TWO_FACTOR_FALLBACK")
|
|
||||||
defer func() {
|
|
||||||
if had {
|
|
||||||
os.Setenv("TWO_FACTOR_FALLBACK", prev)
|
|
||||||
} else {
|
|
||||||
os.Unsetenv("TWO_FACTOR_FALLBACK")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
require.True(t, twoFactorFallbackEnabled())
|
|
||||||
require.True(t, NewPaymentService().TwoFactorFallbackEnabled())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
|
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
|
||||||
// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a
|
// 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
|
// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or
|
||||||
// SMS delivery channel" branch (twofa.go:193) is UNREACHABLE in this build.
|
// SMS delivery channel" branch is UNREACHABLE in this build. The production
|
||||||
// The production predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered
|
// predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered by
|
||||||
// by twofa_delivery_prod_test.go in a !dev build (see its header for the
|
// twofa_delivery_prod_test.go in a !dev build (see its header for the
|
||||||
// documented limitation).
|
// documented limitation).
|
||||||
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
|
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
|
||||||
require.True(t, twoFADeliveryAvailable())
|
require.True(t, twoFADeliveryAvailable())
|
||||||
|
|||||||
@@ -3537,3 +3537,42 @@ func TestGetVATConfig_QueryError(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Nil(t, cfg)
|
assert.Nil(t, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyVATToTillSale_Idempotent locks the M5 safety property the sweep
|
||||||
|
// rescue relies on: apply_vat_to_till_sale is guarded on vat_amount IS NULL, so
|
||||||
|
// applying VAT to a till sale that already has VAT is a no-op — a rescue that
|
||||||
|
// re-applies VAT inside its transaction (the sweep rescues rows that may carry
|
||||||
|
// VAT from a prior synchronous completion attempt) can never double-book it.
|
||||||
|
func TestApplyVATToTillSale_Idempotent(t *testing.T) {
|
||||||
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil {
|
||||||
|
t.Fatalf("failed to update business_settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create admin user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saleID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
||||||
|
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`, adminID).Scan(&saleID); err != nil {
|
||||||
|
t.Fatalf("failed to seed till sale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyVATToTillSale(ctx, tx, saleID)
|
||||||
|
ApplyVATToTillSale(ctx, tx, saleID)
|
||||||
|
|
||||||
|
var vatAmount, netAmount sql.NullFloat64
|
||||||
|
var isVATApplicable bool
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM till_sales WHERE id = $1`, saleID).Scan(&isVATApplicable, &vatAmount, &netAmount); err != nil {
|
||||||
|
t.Fatalf("failed to query till sale VAT fields: %v", err)
|
||||||
|
}
|
||||||
|
if !isVATApplicable || !vatAmount.Valid || vatAmount.Float64 != 8.33 || !netAmount.Valid || netAmount.Float64 != 41.67 {
|
||||||
|
t.Errorf("expected the twice-applied VAT to stay single (8.33/41.67), got applicable=%v vat=%v net=%v", isVATApplicable, vatAmount, netAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
@@ -60,6 +61,53 @@ const twoFAPepperEnv = "TWO_FACTOR_PEPPER"
|
|||||||
// channel and never return it (see twofa_dev.go).
|
// channel and never return it (see twofa_dev.go).
|
||||||
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon")
|
||||||
|
|
||||||
|
// twoFAAllowLogDeliveryEnv is the explicit operator opt-in that makes a
|
||||||
|
// production build deliver 2FA codes via the server log ([2FA] prefix) — the
|
||||||
|
// documented INSECURE stand-in for the not-yet-wired email/SMS transport (P6).
|
||||||
|
// Production builds fail closed without it (see twoFAEnsureIssueAllowedStrict);
|
||||||
|
// dev/test builds always deliver via the log and never consult this flag.
|
||||||
|
const twoFAAllowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY"
|
||||||
|
|
||||||
|
// errTwoFAPepperRequired is returned when TWO_FACTOR_PEPPER is unset in a
|
||||||
|
// production-style issuance gate. Refusing to issue is the only safe outcome:
|
||||||
|
// without the pepper a pending code would be persisted as an unsalted SHA-256
|
||||||
|
// digest in the 1M code space, which a log/DB leak could brute-force offline.
|
||||||
|
var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)")
|
||||||
|
|
||||||
|
// twoFAPepperConfigured reports whether TWO_FACTOR_PEPPER is set — the pure,
|
||||||
|
// build-agnostic read behind every strict issuance gate. Dev/test builds fall
|
||||||
|
// back to the legacy unsalted SHA-256 digest when it is unset (twofa_dev.go);
|
||||||
|
// production-style issuance refuses instead (twoFAEnsureIssueAllowedStrict).
|
||||||
|
func twoFAPepperConfigured() bool { return os.Getenv(twoFAPepperEnv) != "" }
|
||||||
|
|
||||||
|
// twoFADeliveryChannelConfigured reports whether the deployment has explicitly
|
||||||
|
// configured a 2FA code delivery channel: TWO_FACTOR_ALLOW_LOG_DELIVERY set to
|
||||||
|
// exactly "true" (the only production channel today — email/SMS unwired, P6).
|
||||||
|
// Pure env read, build-agnostic: the build-tagged twoFADeliveryAvailable
|
||||||
|
// (twofa_dev.go / twofa_prod.go) is the runtime-facing wrapper that turns this
|
||||||
|
// into the always-true dev channel or the prod env check.
|
||||||
|
func twoFADeliveryChannelConfigured() bool { return os.Getenv(twoFAAllowLogDeliveryEnv) == "true" }
|
||||||
|
|
||||||
|
// twoFAEnsureIssueAllowedStrict is the pure, build-agnostic production-style
|
||||||
|
// issuance gate: code issuance is allowed ONLY when BOTH TWO_FACTOR_PEPPER is
|
||||||
|
// set (an unsalted digest in the 1M code space would be offline-brute-forceable)
|
||||||
|
// AND a delivery channel is configured (otherwise a minted code could never
|
||||||
|
// reach the user and would silently dead-end the enforced saved-card-payments
|
||||||
|
// gate). Either way it fails closed with the actionable errors the handlers map
|
||||||
|
// to a 503. The build-tagged twoFAEnsureIssueAllowed wraps it for production
|
||||||
|
// builds; dev/test builds always allow issuance and never consult it — but the
|
||||||
|
// test,dev suite exercises THIS function directly, so the fail-closed branches
|
||||||
|
// are CI-visible even though the prod file (!dev && !test) is excluded there.
|
||||||
|
func twoFAEnsureIssueAllowedStrict() error {
|
||||||
|
if !twoFAPepperConfigured() {
|
||||||
|
return errTwoFAPepperRequired
|
||||||
|
}
|
||||||
|
if !twoFADeliveryChannelConfigured() {
|
||||||
|
return errTwoFADeliveryUnavailable
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// twoFAPepper reads TWO_FACTOR_PEPPER (plus the one-time unset warning) and
|
// twoFAPepper reads TWO_FACTOR_PEPPER (plus the one-time unset warning) and
|
||||||
// twoFAEnsureIssueAllowed guards code issuance; both are build-dependent.
|
// twoFAEnsureIssueAllowed guards code issuance; both are build-dependent.
|
||||||
// Dev/test builds keep the documented loose-fake fallback (twofa_dev.go);
|
// Dev/test builds keep the documented loose-fake fallback (twofa_dev.go);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//go:build test
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
// M17 follow-up (ITEM 2): twofa_prod.go is excluded from the test,dev suite
|
||||||
|
// (`!dev && !test`), so its fail-closed branches were never exercised in CI —
|
||||||
|
// the old twofa_prod_test.go only runs its assertions in a genuine production
|
||||||
|
// build and skips under the test tag. The pure decision logic now lives
|
||||||
|
// build-agnostically in twofa.go (twoFAEnsureIssueAllowedStrict /
|
||||||
|
// twoFAPepperConfigured / twoFADeliveryChannelConfigured); these tests exercise
|
||||||
|
// those branches in the STANDARD test,dev run, so a regression in the prod
|
||||||
|
// fail-closed behaviour is CI-visible even though the prod file itself is only
|
||||||
|
// compiled in a genuine production build.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTwoFAEnsureIssueAllowedStrict_FailClosed pins the production-style
|
||||||
|
// issuance gate that twofa_prod.go's twoFAEnsureIssueAllowed delegates to:
|
||||||
|
// (a) pepper unset → issuance refused (errTwoFAPepperRequired — an unsalted
|
||||||
|
// digest in the 1M code space would be offline-brute-forceable);
|
||||||
|
// (b) delivery channel absent → issuance refused (errTwoFADeliveryUnavailable —
|
||||||
|
// the 503-style error the handlers surface as StatusServiceUnavailable);
|
||||||
|
// (c) both configured → issuance succeeds.
|
||||||
|
func TestTwoFAEnsureIssueAllowedStrict_FailClosed(t *testing.T) {
|
||||||
|
t.Run("pepper_unset_refuses_issuance", func(t *testing.T) {
|
||||||
|
t.Setenv(twoFAPepperEnv, "")
|
||||||
|
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||||
|
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFAPepperRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delivery_channel_absent_refuses_issuance", func(t *testing.T) {
|
||||||
|
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||||
|
t.Setenv(twoFAAllowLogDeliveryEnv, "")
|
||||||
|
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFADeliveryUnavailable)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pepper_and_channel_present_allows_issuance", func(t *testing.T) {
|
||||||
|
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||||
|
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||||
|
require.NoError(t, twoFAEnsureIssueAllowedStrict())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFADeliveryChannelConfigured pins the pure delivery-channel predicate
|
||||||
|
// behind the 503 refusal: only the exact value "true" opens the channel.
|
||||||
|
func TestTwoFADeliveryChannelConfigured(t *testing.T) {
|
||||||
|
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||||
|
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
|
||||||
|
t.Setenv(twoFAAllowLogDeliveryEnv, v)
|
||||||
|
require.False(t, twoFADeliveryChannelConfigured(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||||
|
}
|
||||||
|
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
|
||||||
|
require.True(t, twoFADeliveryChannelConfigured())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFAPepperConfigured pins the pure pepper predicate behind the
|
||||||
|
// pepper-required refusal.
|
||||||
|
func TestTwoFAPepperConfigured(t *testing.T) {
|
||||||
|
t.Setenv(twoFAPepperEnv, "")
|
||||||
|
require.False(t, twoFAPepperConfigured())
|
||||||
|
t.Setenv(twoFAPepperEnv, "test-pepper")
|
||||||
|
require.True(t, twoFAPepperConfigured())
|
||||||
|
}
|
||||||
@@ -25,29 +25,14 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crussell/internal/twofa"
|
"crussell/internal/twofa"
|
||||||
"errors"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
// twoFAAllowLogDeliveryEnv is the explicit operator opt-in that makes this
|
// twoFAAllowLogDeliveryEnv (the TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in) and
|
||||||
// production build deliver 2FA codes via the server log ([2FA] prefix) — the
|
// errTwoFAPepperRequired are defined in twofa.go — shared by the pure issuance
|
||||||
// documented INSECURE stand-in for the not-yet-wired email/SMS transport (P6).
|
// gate (twoFAEnsureIssueAllowedStrict), which the test,dev suite exercises
|
||||||
// Production builds fail closed without it: no delivery channel is configured,
|
// directly, and this production build.
|
||||||
// so code issuance is refused (see twoFAEnsureIssueAllowed) and setup surfaces
|
|
||||||
// errTwoFADeliveryUnavailable. Set it ONLY to keep the operator-relays-the-code
|
|
||||||
// flow working in a deployment that understands the risk (anyone with backend
|
|
||||||
// log access can defeat the 2FA gate on saved-card charges). Defined here in
|
|
||||||
// the prod build only — dev/test builds always deliver via the log and never
|
|
||||||
// consult this flag.
|
|
||||||
const twoFAAllowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY"
|
|
||||||
|
|
||||||
// errTwoFAPepperRequired is returned by twoFAEnsureIssueAllowed when
|
|
||||||
// TWO_FACTOR_PEPPER is unset in a production build. Refusing to issue is the
|
|
||||||
// only safe outcome: without the pepper a pending code would be persisted as an
|
|
||||||
// unsalted SHA-256 digest in the 1M code space, which a log/DB leak could
|
|
||||||
// brute-force offline.
|
|
||||||
var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)")
|
|
||||||
|
|
||||||
// init registers the production pepper reader into the shared verification
|
// init registers the production pepper reader into the shared verification
|
||||||
// core (crussell/internal/twofa): raw env read, no fallback — code issuance
|
// core (crussell/internal/twofa): raw env read, no fallback — code issuance
|
||||||
@@ -62,9 +47,11 @@ func init() {
|
|||||||
// insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) or a real
|
// insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) or a real
|
||||||
// email/SMS transport is wired (not yet — P6). Default false: no channel, so
|
// email/SMS transport is wired (not yet — P6). Default false: no channel, so
|
||||||
// code issuance is refused and setup surfaces errTwoFADeliveryUnavailable
|
// code issuance is refused and setup surfaces errTwoFADeliveryUnavailable
|
||||||
// instead of a silent dead-end.
|
// instead of a silent dead-end. Delegates to the pure build-agnostic
|
||||||
|
// twoFADeliveryChannelConfigured (twofa.go); dev/test builds always return true
|
||||||
|
// (twofa_dev.go).
|
||||||
func twoFADeliveryAvailable() bool {
|
func twoFADeliveryAvailable() bool {
|
||||||
return os.Getenv(twoFAAllowLogDeliveryEnv) == "true"
|
return twoFADeliveryChannelConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
// twoFAEnsureIssueAllowed reports whether a 2FA code may be issued in this
|
// twoFAEnsureIssueAllowed reports whether a 2FA code may be issued in this
|
||||||
@@ -73,8 +60,9 @@ func twoFADeliveryAvailable() bool {
|
|||||||
// code could never reach the user — issuing one would silently lock the user
|
// code could never reach the user — issuing one would silently lock the user
|
||||||
// out of the enforced saved-card-payments gate; and without the pepper every
|
// out of the enforced saved-card-payments gate; and without the pepper every
|
||||||
// stored code would be an offline-brute-forceable unsalted digest. Either way
|
// stored code would be an offline-brute-forceable unsalted digest. Either way
|
||||||
// issuance is refused (fail-closed). Dev/test builds always allow issuance
|
// issuance is refused (fail-closed). Delegates to the pure build-agnostic gate
|
||||||
// (twofa_dev.go).
|
// twoFAEnsureIssueAllowedStrict (twofa.go), which the test,dev suite also
|
||||||
|
// exercises directly; dev/test builds always allow issuance (twofa_dev.go).
|
||||||
//
|
//
|
||||||
// The pepper check is the ONLY hard gate here (plus the delivery channel), and
|
// The pepper check is the ONLY hard gate here (plus the delivery channel), and
|
||||||
// it is also the ONLY hard gate on the payments re-issue path
|
// it is also the ONLY hard gate on the payments re-issue path
|
||||||
@@ -86,13 +74,7 @@ func twoFADeliveryAvailable() bool {
|
|||||||
// (or have each user re-run 2FA setup), or enforced saved-card charges will
|
// (or have each user re-run 2FA setup), or enforced saved-card charges will
|
||||||
// strand customers with 400 ErrMissingOrExpired forever.
|
// strand customers with 400 ErrMissingOrExpired forever.
|
||||||
func twoFAEnsureIssueAllowed() error {
|
func twoFAEnsureIssueAllowed() error {
|
||||||
if os.Getenv(twoFAPepperEnv) == "" {
|
return twoFAEnsureIssueAllowedStrict()
|
||||||
return errTwoFAPepperRequired
|
|
||||||
}
|
|
||||||
if !twoFADeliveryAvailable() {
|
|
||||||
return errTwoFADeliveryUnavailable
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// twoFADeliverCode delivers a fresh verification code to the user. Production
|
// twoFADeliverCode delivers a fresh verification code to the user. Production
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build !dev
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
// Tests for the PRODUCTION 2FA issuance gate (twofa_prod.go).
|
||||||
|
//
|
||||||
|
// LIMITATION (documented — M17): twofa_prod.go is compiled only in a genuine
|
||||||
|
// production build (`!dev && !test`). Under BOTH required test runs — the
|
||||||
|
// "test,dev" run and the "test,!dev" prod-shape run — the dev/test variant
|
||||||
|
// (twofa_dev.go, build tag `dev || test`) is the compiled function and its
|
||||||
|
// fail-closed branches (no TWO_FACTOR_PEPPER → refuse; no delivery channel →
|
||||||
|
// 503) are unreachable. These tests compile in every `!dev` build and run
|
||||||
|
// their assertions ONLY when the prod variant marker reports the real prod
|
||||||
|
// functions are live; under the test tag they skip with the same documented
|
||||||
|
// rationale the payments package uses (twofa_delivery_prod_test.go).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// twoFAAllowLogDeliveryEnv is only defined in twofa_prod.go (!dev && !test);
|
||||||
|
// use the literal env name so this test also compiles under `test,!dev`.
|
||||||
|
const allowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY"
|
||||||
|
|
||||||
|
// TestTwoFAEnsureIssueAllowed_ProdPredicate pins the production issuance gate:
|
||||||
|
// it fails closed without TWO_FACTOR_PEPPER (an unsalted digest in the 1M code
|
||||||
|
// space would be offline-brute-forceable) or without a delivery channel, and
|
||||||
|
// allows issuance only when both are configured.
|
||||||
|
func TestTwoFAEnsureIssueAllowed_ProdPredicate(t *testing.T) {
|
||||||
|
if !twofaProdVariant {
|
||||||
|
t.Skip("twoFAEnsureIssueAllowed() is the dev/test build's always-allowed variant (twofa_dev.go, `dev || test`); the prod fail-closed branches are unreachable under the test tag — see the file header for the documented limitation")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv(twoFAPepperEnv)
|
||||||
|
os.Unsetenv(allowLogDeliveryEnv)
|
||||||
|
if err := twoFAEnsureIssueAllowed(); err == nil {
|
||||||
|
t.Error("expected issuance refused without TWO_FACTOR_PEPPER in a production build")
|
||||||
|
} else if err.Error() != "TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)" {
|
||||||
|
t.Errorf("expected the pepper-required error without the pepper, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv(twoFAPepperEnv, "test-pepper")
|
||||||
|
os.Unsetenv(allowLogDeliveryEnv)
|
||||||
|
if err := twoFAEnsureIssueAllowed(); err == nil {
|
||||||
|
t.Error("expected issuance refused without a delivery channel in a production build")
|
||||||
|
} else if err != errTwoFADeliveryUnavailable {
|
||||||
|
t.Errorf("expected errTwoFADeliveryUnavailable without a channel, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv(allowLogDeliveryEnv, "true")
|
||||||
|
if err := twoFAEnsureIssueAllowed(); err != nil {
|
||||||
|
t.Errorf("expected issuance allowed with both the pepper and a delivery channel, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoFADeliveryAvailable_ProdPredicate pins the production delivery
|
||||||
|
// predicate: TWO_FACTOR_ALLOW_LOG_DELIVERY unset → no channel (false), exactly
|
||||||
|
// "true" → channel (true), any other value → no channel.
|
||||||
|
func TestTwoFADeliveryAvailable_ProdPredicate(t *testing.T) {
|
||||||
|
if !twofaProdVariant {
|
||||||
|
t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_dev.go, `dev || test`); the 503 delivery-unavailable branch is unreachable under the test tag — see the file header for the documented limitation")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Unsetenv(allowLogDeliveryEnv)
|
||||||
|
if twoFADeliveryAvailable() {
|
||||||
|
t.Error("production without the explicit opt-in must have NO 2FA delivery channel")
|
||||||
|
}
|
||||||
|
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
|
||||||
|
os.Setenv(allowLogDeliveryEnv, v)
|
||||||
|
if twoFADeliveryAvailable() {
|
||||||
|
t.Errorf("value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Setenv(allowLogDeliveryEnv, "true")
|
||||||
|
if !twoFADeliveryAvailable() {
|
||||||
|
t.Error("the explicit insecure log-delivery opt-in must open the channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build dev || test
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
// twofaProdVariant reports whether the PRODUCTION 2FA issuance/delivery
|
||||||
|
// functions (twofa_prod.go, !dev && !test) are the compiled ones in this
|
||||||
|
// build. Under `dev` OR `test` tags the dev/test variants (twofa_dev.go) are
|
||||||
|
// compiled instead — always-allowed issuance, trivially-true delivery — so
|
||||||
|
// the prod fail-closed branches are unreachable there.
|
||||||
|
//lint:ignore U1000 referenced only from the prod-tag test (twofa_prod_test.go, !dev && !test); deliberately unused under dev/test tags
|
||||||
|
const twofaProdVariant = false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !dev && !test
|
||||||
|
|
||||||
|
package user
|
||||||
|
|
||||||
|
// twofaProdVariant reports whether the PRODUCTION 2FA issuance/delivery
|
||||||
|
// functions (twofa_prod.go, !dev && !test) are the compiled ones in this
|
||||||
|
// build. True only in a genuine production build — neither dev nor test tag.
|
||||||
|
const twofaProdVariant = true
|
||||||
+19
-17
@@ -191,11 +191,11 @@ func initSquare() {
|
|||||||
// 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is
|
// 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is
|
||||||
// OFF only when REQUIRE_2FA explicitly disables it (false/0/off/no,
|
// OFF only when REQUIRE_2FA explicitly disables it (false/0/off/no,
|
||||||
// case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
|
// case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
|
||||||
// stack. Warn loudly when a non-dev env (empty/unknown — a likely
|
// stack. In an enforced env a token-less saved-card charge is refused 402
|
||||||
// misconfiguration) leaves the gate disabled, so saved-card charges can
|
// verification_required (SCA-only — see the posture note below); in a
|
||||||
// never silently ship without this merchant-level authorization gate (an
|
// dev/mock env the gate lifts and the mock simulates SCA. Warn loudly when
|
||||||
// additional fraud control; NOT PSD2 SCA — Square buyer verification is the
|
// a non-dev env (empty/unknown — a likely misconfiguration) leaves the gate
|
||||||
// SCA mechanism, wired for new-card charges).
|
// disabled.
|
||||||
enforced := payments.NewPaymentService().TwoFactorEnforced()
|
enforced := payments.NewPaymentService().TwoFactorEnforced()
|
||||||
if enforced {
|
if enforced {
|
||||||
// Delivery is build-dependent (handlers/user/twofa_dev.go /
|
// Delivery is build-dependent (handlers/user/twofa_dev.go /
|
||||||
@@ -206,24 +206,26 @@ func initSquare() {
|
|||||||
// misled into thinking codes are reaching users when issuance is
|
// misled into thinking codes are reaching users when issuance is
|
||||||
// actually failing closed.
|
// actually failing closed.
|
||||||
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
|
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
|
||||||
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.")
|
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the account 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete setup or disable. Every enforced saved-card online payment for a user without 2FA will 403 with no way to enable it. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).")
|
log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete 2FA setup or disable. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !enforced && !payments.IsExplicitDevOrMockEnv() {
|
if !enforced && !payments.IsExplicitDevOrMockEnv() {
|
||||||
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
|
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
|
||||||
}
|
}
|
||||||
// SCA-primary / 2FA-backup posture (TWO_FACTOR_FALLBACK, default "true"):
|
// SCA-only posture (permanent — no switch): saved-card charges are
|
||||||
// the homegrown 2FA gate is the BACKUP authorization for saved-card charges
|
// authorised exclusively by Square PSD2 SCA. PSR 2017 reg 100 makes SCA
|
||||||
// that carry no Square verification_token. TWO_FACTOR_FALLBACK=false makes
|
// mandatory and non-waivable for customer-initiated stored-credential
|
||||||
// the deployment SCA-only: a token-less saved-card charge is then rejected
|
// charges, and the homegrown 2FA cannot legally act as an SCA fallback: a
|
||||||
// 402 verification_required (the frontend shows the 3DS challenge; if the
|
// token-less saved-card charge is refused 402 verification_required ("pay
|
||||||
// bank cannot do SCA the payment cannot proceed). Warn when the operator
|
// online later") — a fallback-authorised charge would leave the MERCHANT
|
||||||
// opted out so the SCA-only posture is a deliberate, visible choice.
|
// liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6)
|
||||||
if !payments.NewPaymentService().TwoFactorFallbackEnabled() && !payments.IsExplicitDevOrMockEnv() {
|
// compensation, and consent does not cure it. The dev mock simulates SCA
|
||||||
log.Printf("WARNING: TWO_FACTOR_FALLBACK=false with SQUARE_ENVIRONMENT=%q — the homegrown 2FA is DISABLED as a saved-card charge fallback. Only charges carrying a Square verification_token (SCA performed) can proceed; a token-less charge is rejected 402 verification_required.", env)
|
// (SimulateSavedCardVerificationRequired + cnon:sca-... tokenize-results),
|
||||||
}
|
// so development has full parity. There is no TWO_FACTOR_FALLBACK switch to
|
||||||
|
// warn about — it was removed; a token-less charge can never be authorised
|
||||||
|
// by 2FA in any environment.
|
||||||
// The mirror-image confusion: enforcement is ON but the Square client fell
|
// The mirror-image confusion: enforcement is ON but the Square client fell
|
||||||
// back to the in-memory mock (internal/square.NewDevClient only picks the
|
// back to the in-memory mock (internal/square.NewDevClient only picks the
|
||||||
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
|
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
|
||||||
|
|||||||
Reference in New Issue
Block a user