Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
355 lines
14 KiB
Go
355 lines
14 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},
|
|
// REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of
|
|
// false/0/off/no (any casing) disables, nothing else does.
|
|
{"require2fa_capitalized_false_disables", "False", "production", false},
|
|
{"require2fa_uppercase_false_disables", "FALSE", "production", false},
|
|
{"require2fa_zero_disables", "0", "production", false},
|
|
{"require2fa_off_disables", "off", "production", false},
|
|
{"require2fa_uppercase_off_disables", "OFF", "production", false},
|
|
{"require2fa_no_disables", "no", "production", false},
|
|
{"require2fa_true_stays_enforced", "true", "production", true},
|
|
{"require2fa_one_stays_enforced", "1", "production", true},
|
|
{"require2fa_yes_stays_enforced", "yes", "production", true},
|
|
{"require2fa_on_stays_enforced", "on", "production", true},
|
|
{"require2fa_unknown_stays_enforced", "enable", "production", true},
|
|
{"require2fa_off_but_dev_never_enforced", "off", "mock", 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())
|
|
}
|