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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user