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.
341 lines
13 KiB
Go
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())
|
|
}
|