Files
Crussell/backend/handlers/payments/twofa_test.go
T
popertots e9b0f0f2a7 fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
2026-08-22 00:34:49 +01:00

341 lines
13 KiB
Go

//go:build test && dev
package payments
// Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env
// matrix, requireTwoFactorForCardAccess() gating, and the end-to-end
// enforcement of the saved-card payment paths in CreateBookingPayment /
// CreateTillSale. 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 (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
func helperEnvEnforce2FA(t *testing.T) {
t.Helper()
t.Setenv("REQUIRE_2FA", "true")
t.Setenv("SQUARE_ENVIRONMENT", "production")
}
func TestTwoFactorEnforced(t *testing.T) {
tests := []struct {
name string
require2FA string
squareEnv string
wantEnforced bool
}{
// Fail-closed default: empty/unknown SQUARE_ENVIRONMENT is treated as
// production-enforced, so a mistyped env var can never silently disarm
// the gate.
{"empty_env_fail_closed_enforced", "", "", true},
{"unknown_env_fail_closed_enforced", "", "staging", true},
{"require2fa_false_disables_prod", "false", "production", false},
{"require2fa_false_disables_sandbox", "false", "sandbox", false},
{"require2fa_false_disables_unknown_env", "false", "staging", false},
{"production_enforced", "", "production", true},
{"sandbox_enforced", "", "sandbox", true},
{"require2fa_true_prod_enforced", "true", "production", true},
{"mock_never_enforced", "", "mock", false},
{"dev_never_enforced", "", "dev", false},
{"development_never_enforced", "", "development", false},
{"test_never_enforced", "", "test", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REQUIRE_2FA", tt.require2FA)
t.Setenv("SQUARE_ENVIRONMENT", tt.squareEnv)
require.Equal(t, tt.wantEnforced, twoFactorEnforced())
require.Equal(t, tt.wantEnforced, NewPaymentService().TwoFactorEnforced(), "exported wrapper must match twoFactorEnforced")
})
}
}
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
// allows every request without touching the DB (no user rows are consulted).
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
// (fail-closed).
func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
t.Setenv("REQUIRE_2FA", "")
t.Setenv("SQUARE_ENVIRONMENT", "mock")
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001"))
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
}
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
t.Run("user_not_enabled_writes_403_json", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID)
require.False(t, ok)
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
require.NotEmpty(t, body["error"])
})
t.Run("user_enabled_allows", 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)
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID))
require.Equal(t, http.StatusOK, w.Code)
})
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000"))
require.Equal(t, http.StatusForbidden, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
require.NotEmpty(t, body["error"])
})
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 with
// no payment row and no saved card (Square never called).
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_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"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-blocked",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
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 2FA 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 2FA request must not persist a card")
}
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
// gate on charging an existing saved card.
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-saved-card-blocked",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
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 saved-card charge must not create a payment row")
}
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
require.NoError(t, err)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "2fa-save-card-ok",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.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)
}
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
require.NoError(t, err)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "2fa-saved-card-ok",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
// when enforced.
func TestTwoFactorEnforced_NewCardCharge_NotGated(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"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "2fa-new-card-not-gated",
}
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
// saved-card charge path: an admin charging a customer's saved card while the
// card's owner has no 2FA is blocked with 403 and no till_sale is created.
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "2fa-till-saved-blocked",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Contains(t, body["error"], "Two-factor")
var saleCount int
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")
}
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
require.NoError(t, err)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
require.NoError(t, err)
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: "2fa-till-saved-ok",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
}