fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change

Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 67cf5b9a45
commit 5cc5a7f6d2
28 changed files with 3328 additions and 208 deletions
+98 -33
View File
@@ -22,15 +22,18 @@ package square
//
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateCardTokenUsed) that let dev/tests drive Square
// failure modes that are otherwise only reachable against the real API.
// FailAfterCommit simulates the exact "charged but response lost → same-key
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
// drive Square failure modes that are otherwise only reachable against the real
// API. FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
// and source in the ledgers exactly like a successful charge) and THEN returns
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
// key + SAME source dedups to the committed payment, proving no double charge.
// SimulateCardTokenUsed simulates Square's CARD_TOKEN_USED rejection of a card
// token (cnon: nonce) reused after a previous save.
// SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
// (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
// CreatePayment's payment status while returning nil error — the "Square
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
// handler (records 'completed' on nil error alone) is caught in dev.
//
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
// the real PRODUCTION Square API on an env-string match alone — a typo'd or
@@ -111,18 +114,29 @@ type MockClient struct {
// committed payment — never a second charge — exercising the retry path
// devs hit in prod when Square processes a charge but the response is lost.
FailAfterCommit bool
// SimulateCardTokenUsed makes CreateCardOnFile enforce Square's
// CARD_TOKEN_USED rejection: a card token (cnon: nonce) already used to
// create a card on this mock instance is rejected with the same structured
// 400 CARD_TOKEN_USED error real Square returns. Off by default — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests, so
// enforcement is enabled only in tests that exercise the reused-token
// rejection. UsedCardTokens() reports the tokens consumed so far.
SimulateCardTokenUsed bool
// usedCardTokens records card tokens consumed by CreateCardOnFile while
// SimulateCardTokenUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with CARD_TOKEN_USED).
usedCardTokens map[string]bool
// SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
// rejection: a card source (cnon: nonce) already used to create a card on
// this mock instance is rejected with the same structured 400 SOURCE_USED
// error real Square's CreateCard API returns (SOURCE_USED — NOT the
// CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
// reuse plain "cnon:test-card"-style tokens across requests, so enforcement
// is enabled only in tests that exercise the reused-source rejection.
// UsedSources() reports the sources consumed so far.
SimulateSourceUsed bool
// usedSources records card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
// creation, so reusing it is rejected with SOURCE_USED).
usedSources map[string]bool
// ForcePaymentStatus forces CreatePayment's payment status instead of the
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
// CreatePayment returns a payment carrying the forced status with nil
// error — the "Square returned 200 with a non-terminal payment" prod
// scenario. It proves a status-blind handler (one that records 'completed'
// on nil error alone) is a regression: the client surfaces Status
// faithfully (paymentFromSquare never errors on a non-terminal status —
// see square_http_client.go), so only the handler's own status check can
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
ForcePaymentStatus string
}
type devProdClient struct{}
@@ -211,7 +225,7 @@ func NewDevClient() SquareClient {
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedCardTokens: make(map[string]bool),
usedSources: make(map[string]bool),
}
}
}
@@ -326,6 +340,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
if req.Autocomplete != nil && !*req.Autocomplete {
status = "APPROVED"
}
if m.ForcePaymentStatus != "" {
// Drive the "Square returned 200 with a non-terminal payment" prod
// scenario: the payment comes back with a non-default status and nil
// error, so a status-blind caller (records 'completed' on nil error
// alone) is exposed as a regression.
status = m.ForcePaymentStatus
}
amount := req.Amount
tipAmount := int64(0)
@@ -343,6 +364,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano())
// Sign-convention parity (finding A): Square reports processing_fee amounts
// as NEGATIVE on the wire, and paymentFromSquare negates them so
// PaymentResult.Fees is POSITIVE — the value handlers store as p.fees. The
// mock fabricates the same positive magnitude directly: online rate 1.4% +
// 25p (amount*14/1000+25). Mock and real client must agree on the sign;
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
locationID := req.LocationID
@@ -404,6 +431,26 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
if m.FailCreateCheckout {
return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)")
}
// Real Square's TerminalCheckout API REQUIRES device_options.device_id: a
// checkout with an empty device id is rejected with a 400. The real client
// resolves the per-request device ID with an env fallback
// (SQUARE_TERMINAL_DEVICE_ID, square_http_client.go:516); the mock mirrors
// the SAME resolution and rejects when neither is set — so a missing
// terminal misconfiguration is caught in dev instead of silently
// "succeeding" where prod 400s.
deviceID := req.DeviceID
if deviceID == "" {
deviceID = os.Getenv("SQUARE_TERMINAL_DEVICE_ID")
}
if deviceID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Detail: "device_options.device_id is required to create a terminal checkout",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"),
}
}
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
now := clock.Now().UTC()
@@ -488,6 +535,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
ReferenceID: req.ReferenceID,
}
m.completed[checkoutID] = paymentResult
// Real Square registers the terminal payment under its own ID:
// GET /v2/payments/{id} succeeds on a completed terminal
// checkout's payment in prod, but failed in dev because the
// payment was never added to m.payments (finding I). Mirror prod
// by registering it under both the ID and SquarePayID keys, exactly
// like CreatePayment, so the reconcile/sweep GetPayment path
// behaves identically.
m.payments[paymentID] = paymentResult
m.payments[paymentResult.SquarePayID] = paymentResult
m.checkouts[checkoutID].Status = "COMPLETED"
m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339)
m.checkouts[checkoutID].PaymentIDs = []string{paymentID}
@@ -697,15 +753,15 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey)
}
// UsedCardTokens returns the card tokens consumed by CreateCardOnFile while
// SimulateCardTokenUsed is enabled. Test accessor for asserting that a reused
// token is rejected with CARD_TOKEN_USED after a previous save.
func (m *MockClient) UsedCardTokens() []string {
// UsedSources returns the card sources consumed by CreateCardOnFile while
// SimulateSourceUsed is enabled. Test accessor for asserting that a reused
// source is rejected with SOURCE_USED after a previous save.
func (m *MockClient) UsedSources() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.usedCardTokens))
for tok := range m.usedCardTokens {
out = append(out, tok)
out := make([]string, 0, len(m.usedSources))
for src := range m.usedSources {
out = append(out, src)
}
return out
}
@@ -738,16 +794,18 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
m.mu.Lock()
defer m.mu.Unlock()
if m.SimulateCardTokenUsed && m.usedCardTokens[cardToken] {
if m.SimulateSourceUsed && m.usedSources[cardToken] {
// Real Square consumes a cnon: nonce on card creation — reusing it to
// create another card is rejected with CARD_TOKEN_USED. The mock
// mirrors that structured 400 rejection (opt-in, see the struct doc).
// create another card is rejected with SOURCE_USED (the CreateCard
// error; CARD_TOKEN_USED is a CreatePayment code and would be wrong
// here). The mock mirrors that structured 400 rejection (opt-in, see
// the struct doc).
return nil, &squareAPIError{
Code: "CARD_TOKEN_USED",
Detail: "The card token has already been used.",
Code: "SOURCE_USED",
Detail: "The provided source id was already used to create a card",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)),
err: fmt.Errorf("square: card source %s has already been used to create a card", tokenPrefix(cardToken)),
}
}
@@ -779,8 +837,8 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
}
m.cards[userID][cardID] = card
m.cardByToken[card.CardID] = card
if m.SimulateCardTokenUsed {
m.usedCardTokens[cardToken] = true
if m.SimulateSourceUsed {
m.usedSources[cardToken] = true
}
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil
@@ -799,6 +857,13 @@ func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
var cards []CardOnFile
for _, card := range userCards {
// Real Square's List Cards API EXCLUDES disabled cards by default
// (the client sends no include_disabled param) — a disabled/deleted
// card disappears from GetCardsOnFile. Mirror that so dev parity
// matches prod (finding C).
if !card.Enabled {
continue
}
cards = append(cards, *card)
}
return cards, nil