fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
@@ -485,7 +485,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the login response contract
|
||||
Token: tokenString,
|
||||
JTI: jti,
|
||||
RefreshToken: refreshToken,
|
||||
@@ -561,7 +561,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{ // #nosec G117 — the refresh token is the intended part of the refresh-token response contract
|
||||
Token: newToken,
|
||||
JTI: jti,
|
||||
RefreshToken: newRefreshToken,
|
||||
|
||||
@@ -339,11 +339,6 @@ type AdminBookingDetail struct {
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// roundTo2 rounds a float64 to 2 decimal places
|
||||
func roundTo2(f float64) float64 {
|
||||
return float64(int(f*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// Helper function to parse query parameters
|
||||
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
|
||||
req := GetAllBookingsRequest{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -103,9 +104,9 @@ func TestChargeFailureStatus(t *testing.T) {
|
||||
// classifications.
|
||||
func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"structured 429 rate limited (retryable) → 503", structuredSquareAPIError(t, http.StatusTooManyRequests), http.StatusServiceUnavailable},
|
||||
{"structured 408 request timeout (ambiguous) → 503", structuredSquareAPIError(t, http.StatusRequestTimeout), http.StatusServiceUnavailable},
|
||||
@@ -565,6 +566,139 @@ func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
|
||||
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
|
||||
require.Contains(t, body["error"], "card issuer requires verification")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Verification-required surfacing at the OTHER 4 charge sites (booking, tip,
|
||||
// terminal, gift-card). The till site is covered by
|
||||
// TestCreateTillSale_SCARequired_ReturnsStructured402 above.
|
||||
// =============================================================================
|
||||
|
||||
func assertStructuredVerificationRequired(t *testing.T, w *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
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"], "an SCA-required charge must surface the structured verification_required code")
|
||||
require.Contains(t, body["error"], "card issuer requires verification")
|
||||
}
|
||||
|
||||
func assertPlain402(t *testing.T, w *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||
require.NotContains(t, w.Body.String(), "verification_required", "a real decline must stay a plain 402, never the SCA challenge body")
|
||||
}
|
||||
|
||||
// TestVerificationRequiredSurfacing_AllChargeSites drives a Square
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED failure through the booking, tip,
|
||||
// terminal-saved-card, and gift-card charge sites: each must surface 402 with
|
||||
// the structured {code:verification_required} body (so the frontend triggers
|
||||
// the 3DS challenge), while a real CARD_DECLINED decline at the same site
|
||||
// stays a plain 402.
|
||||
func TestVerificationRequiredSurfacing_AllChargeSites(t *testing.T) {
|
||||
scaErr := func(t *testing.T) error {
|
||||
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")
|
||||
}
|
||||
declineErr := func(t *testing.T) error {
|
||||
return structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")
|
||||
}
|
||||
installErr := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
origClient := SquareClient
|
||||
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: err}
|
||||
t.Cleanup(func() { SquareClient = origClient })
|
||||
}
|
||||
|
||||
t.Run("booking_site_sca_required", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
installErr(t, scaErr(t))
|
||||
cardToken := "cnon:sca-booking"
|
||||
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "sca-site-booking"}
|
||||
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("booking_site_plain_decline", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
||||
installErr(t, declineErr(t))
|
||||
cardToken := "cnon:decline-booking"
|
||||
req := CreateBookingPaymentRequest{Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "decline-site-booking"}
|
||||
assertPlain402(t, makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("tip_site_sca_required", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
installErr(t, scaErr(t))
|
||||
cardToken := "cnon:sca-tip"
|
||||
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "sca-site-tip"}
|
||||
assertStructuredVerificationRequired(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("tip_site_plain_decline", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
installErr(t, declineErr(t))
|
||||
cardToken := "cnon:decline-tip"
|
||||
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken, IdempotencyKey: "decline-site-tip"}
|
||||
assertPlain402(t, makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("terminal_saved_card_site_sca_required", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_sca_terminal", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
installErr(t, scaErr(t))
|
||||
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sca-site-terminal-" + bookingID}
|
||||
assertStructuredVerificationRequired(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("terminal_saved_card_site_plain_decline", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_decline_terminal", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
installErr(t, declineErr(t))
|
||||
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "decline-site-terminal-" + bookingID}
|
||||
assertPlain402(t, makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx))
|
||||
})
|
||||
|
||||
t.Run("gift_card_site_sca_required", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
installErr(t, scaErr(t))
|
||||
cardToken := "cnon:sca-giftcard"
|
||||
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "sca-site-giftcard"}
|
||||
assertStructuredVerificationRequired(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
|
||||
})
|
||||
|
||||
t.Run("gift_card_site_plain_decline", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
installErr(t, declineErr(t))
|
||||
cardToken := "cnon:decline-giftcard"
|
||||
req := BuyGiftCardRequest{Amount: 2000, RecipientType: "self", NewCardToken: &cardToken, IdempotencyKey: "decline-site-giftcard"}
|
||||
assertPlain402(t, makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
|
||||
// having completed 2FA setup, persisting a card is blocked with 403 and no card
|
||||
// row is created — the save-card endpoint is not an un-gated side door.
|
||||
|
||||
@@ -2313,12 +2313,13 @@ func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string,
|
||||
st.CancellationReason = "The 14-day cancellation period has expired"
|
||||
default:
|
||||
paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt)
|
||||
if perr != nil {
|
||||
switch {
|
||||
case perr != nil:
|
||||
log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr)
|
||||
st.CancellationReason = "The original purchase payment could not be verified"
|
||||
} else if !paymentOK {
|
||||
case !paymentOK:
|
||||
st.CancellationReason = "The original purchase payment could not be found"
|
||||
} else {
|
||||
default:
|
||||
// A completed/pending refund row means the money already
|
||||
// returned (or is in flight) — the card cannot be cancelled a
|
||||
// second time.
|
||||
@@ -2628,7 +2629,7 @@ func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
// returned. Refund the DIFFERENCE via Square with a fresh
|
||||
// deterministic key before neutralising, so the un-refunded remainder
|
||||
// is never silently swallowed.
|
||||
refundAmount = refundAmount - priorAmount
|
||||
refundAmount -= priorAmount
|
||||
refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10)
|
||||
log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount)
|
||||
case "pending":
|
||||
|
||||
@@ -3099,7 +3099,6 @@ func buildTerminalSplitRecords(primary PaymentRecord, info *BookingPaymentInfo,
|
||||
bal.IdempotencyKey = &k
|
||||
}
|
||||
records = append(records, bal)
|
||||
splitIdx++
|
||||
}
|
||||
|
||||
if tipAmount > 0.004 {
|
||||
@@ -3309,10 +3308,7 @@ func isDefinitiveCardSaveFailure(err error) bool {
|
||||
case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD":
|
||||
return true
|
||||
}
|
||||
if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return square.ErrorCategory(err) == "INVALID_REQUEST_ERROR"
|
||||
}
|
||||
|
||||
func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -695,11 +695,12 @@ func ProcessCancellationRefundTx(
|
||||
log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err)
|
||||
} else if loyaltyUsed {
|
||||
loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
|
||||
if loyaltyErr != nil {
|
||||
switch {
|
||||
case loyaltyErr != nil:
|
||||
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
|
||||
} else if loyaltyTag.RowsAffected() == 0 {
|
||||
case loyaltyTag.RowsAffected() == 0:
|
||||
slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID)
|
||||
} else {
|
||||
default:
|
||||
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2013,7 +2013,9 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
|
||||
appliedCampaignsBeforeInsert := false
|
||||
if hasTip && bookingInfo != nil && bErr == nil {
|
||||
if pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount) > 0.004 {
|
||||
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil)
|
||||
if applyErr := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil); applyErr != nil {
|
||||
slog.Error("Failed to apply eligible campaigns before terminal payment insert", "booking_id", bookingID, "err", applyErr)
|
||||
}
|
||||
appliedCampaignsBeforeInsert = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Tests for the CreateTerminalPayment SCA/2FA decision model: the
|
||||
// VerificationToken field (handlers.go:114, validation 440-444, extraction
|
||||
// 843-846, gate skip 1024, forwarding 1113), the admin-actor 2FA-fallback
|
||||
// audit rows on the terminal and till saved-card surfaces, and the
|
||||
// customer_initiated classification (Square's SCA/liability-shift signal) on
|
||||
// every saved-card charge site.
|
||||
//
|
||||
// Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv stay sequential
|
||||
// (no t.Parallel) — see the note at the top of twofa_test.go. They use
|
||||
// SQUARE_ENVIRONMENT=staging (enforced, but square.NewDevClient() returns the
|
||||
// in-memory mock — helperEnvEnforce2FA's production would panic a dev-build
|
||||
// NewDevClient, see twofa_gate_consume_test.go).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// helperEnvEnforce2FAStaging flips the env to an ENFORCED non-mock Square env
|
||||
// that still resolves to the in-memory mock client.
|
||||
func helperEnvEnforce2FAStaging(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "staging")
|
||||
}
|
||||
|
||||
// installRecordingClient wraps the dev mock in the shared recording client so
|
||||
// tests can assert the exact CreatePaymentReq the handler sent to Square.
|
||||
func installRecordingClient(t *testing.T) *recordingPaymentClient {
|
||||
t.Helper()
|
||||
origClient := SquareClient
|
||||
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
||||
SquareClient = rec
|
||||
t.Cleanup(func() { SquareClient = origClient })
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_VerificationToken_Passthrough pins (d): a saved-card
|
||||
// charge through CreateTerminalPayment forwards the SCA verification token
|
||||
// verbatim on the CreatePaymentReq.
|
||||
func TestTerminalSavedCard_VerificationToken_Passthrough(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
vrf := "vrf_terminal_sca_1"
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "terminal-sca-passthrough-" + bookingID,
|
||||
VerificationToken: &vrf,
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.VerificationToken
|
||||
rec.mu.Unlock()
|
||||
require.Equal(t, vrf, got, "the SCA verification token must be forwarded to Square on the CreatePaymentReq")
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_VerificationToken_TooLong_Rejected pins (d): an
|
||||
// oversized verification_token is rejected with 400 by ValidateVerificationToken
|
||||
// before any charge branch runs.
|
||||
func TestTerminalSavedCard_VerificationToken_TooLong_Rejected(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
big := make([]byte, 600)
|
||||
for i := range big {
|
||||
big[i] = 'a'
|
||||
}
|
||||
token := string(big)
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
IdempotencyKey: "terminal-sca-too-long-" + bookingID,
|
||||
VerificationToken: &token,
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "an oversized verification_token must be rejected with 400, body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_VerificationToken_Skips2FA pins (d): when the charge
|
||||
// carries a Square verification_token (SCA performed), the 2FA gate is SKIPPED
|
||||
// even for a user with NO 2FA setup — a charge that would 403 on the fallback
|
||||
// path succeeds via the token.
|
||||
func TestTerminalSavedCard_VerificationToken_Skips2FA(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_skip", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
installRecordingClient(t)
|
||||
|
||||
vrf := "vrf_terminal_sca_skip_1"
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "terminal-sca-skip-" + bookingID,
|
||||
VerificationToken: &vrf,
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "SCA performed — the token must skip the 2FA gate, body: %s", w.Body.String())
|
||||
|
||||
var auditCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'`).Scan(&auditCount))
|
||||
require.Zero(t, auditCount, "an SCA-authorized terminal charge must not write a 2FA-fallback audit row")
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_2FAFallback_Audit_AdminActor pins (c)+(d): a token-less
|
||||
// terminal saved-card charge falls back to the card owner's 2FA code and writes
|
||||
// a strict 2fa_fallback_charge audit row for the ADMIN actor (admin_id = the
|
||||
// charging admin, target_user_id = the card owner).
|
||||
func TestTerminalSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedTwoFAPendingCode(t, tx, userID, "334411")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_fallback", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "terminal-2fa-fallback-" + bookingID,
|
||||
VerificationCode: "334411",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
got := rec.lastReq.VerificationToken
|
||||
rec.mu.Unlock()
|
||||
require.Empty(t, got, "a 2FA-fallback charge must not carry a verification token")
|
||||
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// TestTerminalSavedCard_2FAFallbackDisabled_402 pins (d): when the deployment
|
||||
// opts out of the 2FA fallback (TWO_FACTOR_FALLBACK=false), a token-less saved-
|
||||
// card terminal charge is denied 402 with the structured verification_required
|
||||
// body the frontend keys on to run the SCA challenge.
|
||||
func TestTerminalSavedCard_2FAFallbackDisabled_402(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
t.Setenv("TWO_FACTOR_FALLBACK", "false")
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
seedTwoFAPendingCode(t, tx, userID, "998800")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_terminal_sca_no_fallback", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
installRecordingClient(t)
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "terminal-sca-no-fallback-" + bookingID,
|
||||
VerificationCode: "998800",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less charge with the fallback disabled must be denied 402, body: %s", w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, "verification_required", body["code"])
|
||||
}
|
||||
|
||||
// TestTillSavedCard_2FAFallback_Audit_AdminActor pins (c): an admin till
|
||||
// saved-card sale authorized by the 2FA fallback writes a strict
|
||||
// 2fa_fallback_charge audit row for the ADMIN actor with the till sale id as
|
||||
// the reference.
|
||||
func TestTillSavedCard_2FAFallback_Audit_AdminActor(t *testing.T) {
|
||||
helperEnvEnforce2FAStaging(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
seedTwoFAPendingCode(t, tx, userID, "665544")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_till_audit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
installRecordingClient(t)
|
||||
|
||||
key := "2fa-till-fallback-audit"
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: key,
|
||||
VerificationCode: "665544",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
|
||||
var tillSaleID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, key).Scan(&tillSaleID))
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, adminID, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// TestCustomerInitiated_ChargeClassification pins (f): the customer_initiated
|
||||
// flag on the CreatePaymentReq — false for the ADMIN surfaces (terminal
|
||||
// saved-card handlers.go:1121, till saved-card till.go:1179: merchant-
|
||||
// initiated) and true for the CUSTOMER surfaces (booking handlers.go:2467,
|
||||
// tip handlers.go:4708, gift-card giftcards.go:1676).
|
||||
func TestCustomerInitiated_ChargeClassification(t *testing.T) {
|
||||
t.Run("admin_terminal_saved_card_merchant_initiated", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_terminal", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
req := CreateTerminalPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "ci-terminal-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
cd := rec.lastReq.CustomerDetails
|
||||
rec.mu.Unlock()
|
||||
require.NotNil(t, cd, "an admin saved-card terminal charge must carry customer_details")
|
||||
require.False(t, cd.CustomerInitiated, "an admin-initiated terminal saved-card charge is merchant-initiated")
|
||||
})
|
||||
|
||||
t.Run("admin_till_saved_card_merchant_initiated", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_till", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: "ci-till",
|
||||
}
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
cd := rec.lastReq.CustomerDetails
|
||||
rec.mu.Unlock()
|
||||
require.NotNil(t, cd, "an admin till saved-card charge must carry customer_details")
|
||||
require.False(t, cd.CustomerInitiated, "an admin-initiated till saved-card charge is merchant-initiated")
|
||||
})
|
||||
|
||||
t.Run("customer_booking_cardholder_initiated", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
cardToken := "cnon:ci-booking"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "ci-booking-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
cd := rec.lastReq.CustomerDetails
|
||||
rec.mu.Unlock()
|
||||
require.NotNil(t, cd)
|
||||
require.True(t, cd.CustomerInitiated, "a customer booking charge is cardholder-initiated")
|
||||
})
|
||||
|
||||
t.Run("customer_tip_cardholder_initiated", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
cardToken := "cnon:ci-tip"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "ci-tip-" + bookingID,
|
||||
}
|
||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
cd := rec.lastReq.CustomerDetails
|
||||
rec.mu.Unlock()
|
||||
require.NotNil(t, cd)
|
||||
require.True(t, cd.CustomerInitiated, "a customer tip charge is cardholder-initiated")
|
||||
})
|
||||
|
||||
t.Run("customer_gift_card_cardholder_initiated", func(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_ci_gc", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
rec := installRecordingClient(t)
|
||||
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 2000,
|
||||
RecipientType: "self",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "ci-gc",
|
||||
}
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
|
||||
rec.mu.Lock()
|
||||
cd := rec.lastReq.CustomerDetails
|
||||
rec.mu.Unlock()
|
||||
require.NotNil(t, cd)
|
||||
require.True(t, cd.CustomerInitiated, "a customer gift-card purchase is cardholder-initiated")
|
||||
})
|
||||
}
|
||||
@@ -656,7 +656,6 @@ func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
GetTillCheckoutStatus(w, req)
|
||||
req = adminRequestCtx(req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build !dev
|
||||
|
||||
package payments
|
||||
|
||||
// Tests for the PRODUCTION 2FA delivery predicate (twofa_delivery_prod.go).
|
||||
//
|
||||
// LIMITATION (documented): the 503 "2FA requires an email or SMS delivery
|
||||
// channel" branch in requireTwoFactorForCardAccess (twofa.go:193) is only
|
||||
// reachable when twoFADeliveryAvailable() returns false, which happens ONLY in
|
||||
// a production build (!dev && !test). Under BOTH required test runs — the
|
||||
// "test,dev" run and the "test,!dev" prod-shape run — the dev/test delivery
|
||||
// variant (twofa_delivery_dev.go, build tag `dev || test`) is the compiled
|
||||
// function and is trivially true, so the 503 branch cannot be exercised there.
|
||||
// The two test invocations DO however compile this file, and the prod-variant
|
||||
// marker (twofaDeliveryProdVariant) tells the test which delivery function is
|
||||
// live: a genuine production build (no dev/test tags, e.g. `go test ./...`)
|
||||
// compiles twofa_delivery_prod.go, and this test then asserts the real prod
|
||||
// predicate end to end.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestTwoFADeliveryAvailable_ProdPredicate asserts the production gating that
|
||||
// twofa_delivery_prod.go implements: TWO_FACTOR_ALLOW_LOG_DELIVERY unset →
|
||||
// no channel (false), exactly "true" → channel (true), any other value →
|
||||
// no channel. In a dev/test build the marker is false and the test skips,
|
||||
// because the always-true dev variant is compiled and the 503 branch is
|
||||
// unreachable (documented limitation — see the file header).
|
||||
func TestTwoFADeliveryAvailable_ProdPredicate(t *testing.T) {
|
||||
if !twofaDeliveryProdVariant {
|
||||
t.Skip("twoFADeliveryAvailable() is the dev/test build's trivially-true variant (twofa_delivery_dev.go, `dev || test`); the 503 delivery-unavailable branch is unreachable under the test tag — see the file header for the documented limitation")
|
||||
}
|
||||
|
||||
t.Run("unset_env_is_no_channel", func(t *testing.T) {
|
||||
os.Unsetenv("TWO_FACTOR_ALLOW_LOG_DELIVERY")
|
||||
require.False(t, twoFADeliveryAvailable(), "production without the explicit opt-in must have NO 2FA delivery channel")
|
||||
})
|
||||
|
||||
t.Run("empty_env_is_no_channel", func(t *testing.T) {
|
||||
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "")
|
||||
require.False(t, twoFADeliveryAvailable())
|
||||
})
|
||||
|
||||
t.Run("exact_true_is_a_channel", func(t *testing.T) {
|
||||
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", "true")
|
||||
require.True(t, twoFADeliveryAvailable(), "the explicit insecure log-delivery opt-in must open the channel")
|
||||
})
|
||||
|
||||
t.Run("any_other_value_is_no_channel", func(t *testing.T) {
|
||||
for _, v := range []string{"1", "yes", "on", "True", "TRUE", "false"} {
|
||||
os.Setenv("TWO_FACTOR_ALLOW_LOG_DELIVERY", v)
|
||||
require.False(t, twoFADeliveryAvailable(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build dev || test
|
||||
|
||||
package payments
|
||||
|
||||
// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate
|
||||
// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this
|
||||
// build. Under `dev` OR `test` tags the dev/test delivery variant
|
||||
// (twofa_delivery_dev.go) is compiled instead — always true, so the 503
|
||||
// delivery-unavailable branch is unreachable there.
|
||||
//lint:ignore U1000 referenced only from the prod-tag test (twofa_delivery_prod_test.go, !dev && !test); deliberately unused under dev/test tags
|
||||
const twofaDeliveryProdVariant = false
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !dev && !test
|
||||
|
||||
package payments
|
||||
|
||||
// twofaDeliveryProdVariant reports whether the PRODUCTION delivery predicate
|
||||
// (twofa_delivery_prod.go, !dev && !test) is the compiled function in this
|
||||
// build. True only in a genuine production build — neither dev nor test tag —
|
||||
// where twoFADeliveryAvailable() gates on TWO_FACTOR_ALLOW_LOG_DELIVERY.
|
||||
const twofaDeliveryProdVariant = true
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
@@ -141,7 +142,8 @@ func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing
|
||||
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
|
||||
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
|
||||
// the customer actor — the operator can distinguish SCA-authorized charges from
|
||||
// fallback-authorized ones.
|
||||
// fallback-authorized ones — and the row's details JSON must carry the strict
|
||||
// shape (sca_performed:false, fallback_reason, card_last4, reference_id, notes).
|
||||
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
@@ -163,12 +165,31 @@ func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
var auditCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log
|
||||
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $1
|
||||
`, userID).Scan(&auditCount))
|
||||
require.Equal(t, 1, auditCount, "a 2FA-fallback saved-card charge must write a strict audit row for the customer actor")
|
||||
details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
require.Equal(t, false, details["sca_performed"])
|
||||
require.Equal(t, "verification_unavailable", details["fallback_reason"])
|
||||
}
|
||||
|
||||
// assertTwoFAFallbackAuditDetails reads the most recent '2fa_fallback_charge'
|
||||
// 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-
|
||||
@@ -202,6 +223,125 @@ 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")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_TipSavedCard_Fallback_Audit pins the strict audit
|
||||
// trail on the TIP SAVE gate (CreateTipPayment with save_card=true): the
|
||||
// token-less save is authorized by the 2FA fallback, and the resulting
|
||||
// charge writes a 2fa_fallback_charge row for the customer actor.
|
||||
func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "556600")
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
cardToken := "cnon:2fa-tip-save-audit"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "2fa-tip-save-audit",
|
||||
VerificationCode: "556600",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit pins the strict audit
|
||||
// trail on the TIP CHARGE gate (CreateTipPayment charging an existing saved
|
||||
// card): the token-less charge is authorized by the 2FA fallback and writes a
|
||||
// 2fa_fallback_charge row for the customer actor.
|
||||
func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "112211")
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-tip-charge-audit",
|
||||
VerificationCode: "112211",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit pins the strict
|
||||
// audit trail on the gift-card purchase saved-card gate (giftcards.go): the
|
||||
// token-less charge is authorized by the 2FA fallback and writes a
|
||||
// 2fa_fallback_charge row for the customer actor with the payment id as the
|
||||
// reference.
|
||||
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "778811")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
key := "2fa-buy-gc-fallback-audit"
|
||||
req := BuyGiftCardRequest{
|
||||
Amount: 2000,
|
||||
RecipientType: "self",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: key,
|
||||
VerificationCode: "778811",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
|
||||
var payID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, key).Scan(&payID))
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, payID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit pins the strict audit
|
||||
// trail on the add-card save gate (handlers.go CreatePaymentMethod): persisting
|
||||
// a card via the 2FA fallback writes a 2fa_fallback_charge row for the customer
|
||||
// actor with an empty reference id (no charge reference exists for a save).
|
||||
func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
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)
|
||||
seedTwoFAPendingCode(t, tx, userID, "998877")
|
||||
|
||||
req := CreatePaymentMethodRequest{
|
||||
CardToken: "cnon:2fa-pm-save-audit",
|
||||
VerificationCode: "998877",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, "", "card persisted via 2FA fallback (SCA unavailable)", "4242")
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -631,3 +771,64 @@ func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *
|
||||
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
|
||||
// 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
|
||||
// SMS delivery channel" branch (twofa.go:193) is UNREACHABLE in this build.
|
||||
// The production predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered
|
||||
// by twofa_delivery_prod_test.go in a !dev build (see its header for the
|
||||
// documented limitation).
|
||||
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
|
||||
require.True(t, twoFADeliveryAvailable())
|
||||
}
|
||||
|
||||
@@ -1046,7 +1046,7 @@ func GetDefaultHoursConflictingBookings(w http.ResponseWriter, r *http.Request)
|
||||
bookingLondon := ob.StartTime.In(londonLocation)
|
||||
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
|
||||
|
||||
proposed, _ := proposedByWeekday[ourWeekday]
|
||||
proposed := proposedByWeekday[ourWeekday]
|
||||
isConflict := false
|
||||
if !proposed.IsOpen {
|
||||
isConflict = true
|
||||
@@ -1204,7 +1204,11 @@ func GetScheduledDefaultHoursChange(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var scheduledHours []DefaultHours
|
||||
json.Unmarshal([]byte(*hoursJSON), &scheduledHours)
|
||||
if err := json.Unmarshal([]byte(*hoursJSON), &scheduledHours); err != nil {
|
||||
log.Printf("Failed to unmarshal scheduled default hours JSON: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := ScheduledHoursChange{
|
||||
EffectiveDate: *effDate,
|
||||
|
||||
@@ -483,7 +483,7 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re
|
||||
// Go: Sun=0,Mon=1,...,Sat=6 → Our convention: Mon=0,...,Sun=6
|
||||
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
|
||||
|
||||
proposed, _ := proposedByWeekday[ourWeekday]
|
||||
proposed := proposedByWeekday[ourWeekday]
|
||||
|
||||
isConflict := false
|
||||
if !proposed.IsOpen {
|
||||
@@ -501,13 +501,14 @@ func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Re
|
||||
// For midnight-crossing bookings (endMinutes < startMinutes), the booking
|
||||
// extends past midnight and always conflicts with daily hours since the
|
||||
// day's open window cannot span past midnight.
|
||||
if startMinutes < 0 || endMinutes < 0 {
|
||||
switch {
|
||||
case startMinutes < 0 || endMinutes < 0:
|
||||
// parse error — treat as conflict
|
||||
isConflict = true
|
||||
} else if endMinutes < startMinutes {
|
||||
case endMinutes < startMinutes:
|
||||
// Booking crosses midnight — always a conflict with daily hours
|
||||
isConflict = true
|
||||
} else if startMinutes < propStartMinutes || endMinutes > propEndMinutes {
|
||||
case startMinutes < propStartMinutes || endMinutes > propEndMinutes:
|
||||
isConflict = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -3888,7 +3889,7 @@ func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T
|
||||
}
|
||||
|
||||
origSquare := payments.SquareClient
|
||||
payments.SquareClient = square.NewDevClient()
|
||||
payments.SquareClient = testutils.NewTestSquareClient()
|
||||
defer func() { payments.SquareClient = origSquare }()
|
||||
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) })
|
||||
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
) // ============================================================
|
||||
// GetGDPRExportHandler Tests
|
||||
// ============================================================
|
||||
|
||||
@@ -503,6 +501,71 @@ func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback closes the GDPR erasure gap
|
||||
// for admin_audit_log: a '2fa_fallback_charge' row (insertTwoFAFallbackAudit,
|
||||
// handlers/payments) carries target_user_id = the erased user PLUS
|
||||
// details.card_last4 — the audit row MUST survive erasure (GDPR Art 30 records
|
||||
// of processing / financial audit trail) but de-identified: the user link is
|
||||
// NULLed exactly as delete_guest_user() does (which scrubs target_user_id only,
|
||||
// leaving details untouched — anonymize_user mirrors that consistency).
|
||||
func TestAnonymizeUser_ScrubsAdminAuditLog2FAFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// A 2FA-fallback audit row for a CIT saved-card charge: target_user_id is
|
||||
// the customer and admin_id is the customer's own userID (the CIT actor —
|
||||
// see insertTwoFAFallbackAudit). details carries the card_last4 PII.
|
||||
var auditID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
|
||||
VALUES ($1, '2fa_fallback_charge', $1, $2::jsonb)
|
||||
RETURNING id
|
||||
`, userID, `{"sca_performed": false, "fallback_reason": "verification_unavailable", "card_last4": "4242", "reference_id": "booking123", "notes": "saved-card charge authorized via 2FA fallback (SCA unavailable)"}`).Scan(&auditID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert 2fa_fallback_charge audit row: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
// The audit row survives erasure (audit retention) but is de-identified:
|
||||
// target_user_id is NULLed. details is left untouched, mirroring
|
||||
// delete_guest_user() exactly (it scrubs target_user_id only).
|
||||
var targetUserID interface{}
|
||||
var details json.RawMessage
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT target_user_id, details FROM admin_audit_log WHERE id = $1
|
||||
`, auditID).Scan(&targetUserID, &details)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query audit row after anonymization: %v", err)
|
||||
}
|
||||
if targetUserID != nil {
|
||||
t.Errorf("expected target_user_id to be NULL after anonymization, got %v", targetUserID)
|
||||
}
|
||||
if len(details) == 0 {
|
||||
t.Error("expected the audit row to survive erasure (retained, de-identified)")
|
||||
}
|
||||
|
||||
// No residual audit rows may still reference the erased user.
|
||||
var remaining int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log WHERE target_user_id = $1
|
||||
`, userID).Scan(&remaining)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count residual audit rows: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("expected 0 audit rows still referencing the erased user, got %d", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build test
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crussell/mw"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// deleteAccountRequest builds the DELETE /api/user/account request the handler
|
||||
// requires (finding 3): the current password in the body, plus a 2FA code
|
||||
// when one is supplied (enforced environments + 2FA-enabled users only).
|
||||
//
|
||||
// Defined in a shared, non-dev test helper file so prod-tag (test,!dev) test
|
||||
// builds can keep exercising the deletion path (gdpr_test.go, profile_test.go)
|
||||
// even though the coverage suite in user_coverage_test.go is dev-only.
|
||||
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
|
||||
t.Helper()
|
||||
body := map[string]string{"current_password": password}
|
||||
if code != "" {
|
||||
body["verification_code"] = code
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
|
||||
return req
|
||||
}
|
||||
@@ -74,30 +74,13 @@ var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS deliv
|
||||
// imports neither handlers/user nor handlers/payments, so the payments
|
||||
// card-access gate can verify a real 2FA challenge without an import cycle
|
||||
// (B11c). The identifiers below are thin aliases/wrappers so the HTTP handlers
|
||||
// and the existing tests keep their original names.
|
||||
|
||||
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
|
||||
// before the pending code is invalidated and a new one must be requested.
|
||||
const twoFAMaxAttempts = twofa.MaxAttempts
|
||||
|
||||
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
|
||||
// resetting, and doubles as the stale-entry eviction horizon for the map.
|
||||
const twoFAAttemptWindow = twofa.AttemptWindow
|
||||
// keep their original names.
|
||||
|
||||
// hashTwoFACode returns the hex digest of a verification code as stored in the
|
||||
// DB (pepper-driven HMAC-SHA256, or the legacy plain SHA-256 when the pepper is
|
||||
// unset). Delegates to the shared implementation.
|
||||
func hashTwoFACode(code string) string { return twofa.Hash(code) }
|
||||
|
||||
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
|
||||
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
|
||||
|
||||
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
|
||||
// digest, always in constant time. Delegates to the shared implementation.
|
||||
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
|
||||
return twofa.VerifyHash(reqCode, storedHash)
|
||||
}
|
||||
|
||||
// twoFAAttemptState aliases the shared per-user attempt state.
|
||||
type twoFAAttemptState = twofa.AttemptState
|
||||
|
||||
@@ -116,12 +99,6 @@ func twoFAMintThrottled(st *twoFAAttemptState, now time.Time) bool {
|
||||
return !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown
|
||||
}
|
||||
|
||||
// twoFAResetAttempts zeroes the shared per-user attempt counter in place.
|
||||
// Called on successful verify only — a fresh code mint must NOT reset it (B11b).
|
||||
func twoFAResetAttempts(userID string) {
|
||||
twofa.ResetAttempts(userID)
|
||||
}
|
||||
|
||||
// deliverTwoFACode generates a fresh verification code and persists only its
|
||||
// digest plus the pending expiry (updating two_factor_method when method is
|
||||
// non-empty).
|
||||
|
||||
@@ -44,6 +44,23 @@ func twofaEnvEnforced(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
}
|
||||
|
||||
// twoFAMaxAttempts is the number of consecutive failed verify attempts allowed
|
||||
// before the pending code is invalidated and a new one must be requested.
|
||||
const twoFAMaxAttempts = twofa.MaxAttempts
|
||||
|
||||
// twoFAAttemptWindow bounds how long a per-user attempt counter lives before
|
||||
// resetting, and doubles as the stale-entry eviction horizon for the map.
|
||||
const twoFAAttemptWindow = twofa.AttemptWindow
|
||||
|
||||
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest.
|
||||
func legacyHashTwoFACode(code string) string { return twofa.LegacyHash(code) }
|
||||
|
||||
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
|
||||
// digest, always in constant time. Delegates to the shared implementation.
|
||||
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
|
||||
return twofa.VerifyHash(reqCode, storedHash)
|
||||
}
|
||||
|
||||
func twofaEnvUnenforced(t *testing.T) {
|
||||
t.Helper()
|
||||
// Explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
|
||||
|
||||
@@ -45,23 +45,6 @@ import (
|
||||
// DeleteAccountHandler Coverage Tests
|
||||
// =============================================================================
|
||||
|
||||
// deleteAccountRequest builds the DELETE /api/user/account request the handler
|
||||
// now requires (finding 3): the current password in the body, plus a 2FA code
|
||||
// when one is supplied (enforced environments + 2FA-enabled users only).
|
||||
func deleteAccountRequest(t *testing.T, ctx context.Context, userID, password, code string) *http.Request {
|
||||
t.Helper()
|
||||
body := map[string]string{"current_password": password}
|
||||
if code != "" {
|
||||
body["verification_code"] = code
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
|
||||
return req
|
||||
}
|
||||
|
||||
// TestDeleteAccount_Unauthorized verifies that deleting an account without
|
||||
// setting user ID in context returns 401 Unauthorized.
|
||||
func TestDeleteAccount_Unauthorized(t *testing.T) {
|
||||
@@ -996,8 +979,15 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
|
||||
// the same (anonymized) user must re-mint a fresh Square customer instead of
|
||||
// reusing the deleted one's stale cached id.
|
||||
func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
|
||||
// Use a prod-safe in-memory Square stand-in whose delete methods always
|
||||
// succeed. The handler fires an ASYNC goroutine for Square erasure; if a
|
||||
// delete fails it raises a critical notification through notifyCtx, which
|
||||
// in tests carries the request transaction — writing to the SAME pgx.Tx the
|
||||
// test below reads, racing it (pgx LRUCache/PgConn). Deterministic success
|
||||
// keeps the goroutine on its own background pool context, so the test's tx
|
||||
// is never touched concurrently.
|
||||
savedSquareClient := payments.SquareClient
|
||||
payments.SquareClient = square.NewDevClient()
|
||||
payments.SquareClient = testutils.NewTestSquareClient()
|
||||
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
|
||||
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
Reference in New Issue
Block a user