fix: review round 6 — P0 deposit charge, idempotency rotation, dev-safety guard, 2FA/webhook hardening

Sixth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). QA FAILED the deposit-required new-card flow; the P0 root
cause was backend + frontend, now fixed. All 20 packages green.

P0 money-safety:
- Deposit-required bookings now actually charge the deposit on new-card
  payment. Two-part fix: (1) CreateBookingHandler re-reads the
  trigger-maintained total_amount/total_duration_minutes from the DB after the
  booking_services insert (the INSERT..RETURNING row predates the recalc
  trigger, so TotalAmount serialized as 0 and DepositPaid computed TRUE on an
  unpaid booking — the frontend gate trusted deposit_paid:true, never charged,
  and confirmed the booking with zero payment rows); (2) BookingFlow.svelte
  gates the confirmation view on depositPaid and guards against re-creating a
  booking on retry. Regression test
  TestBookings_Create_DepositPaidFalseOnUnpaidBooking.

Payments (idempotency + money):
- deriveBookingPaymentIdempotencyKey: no-client-key fallback now advances a
  sequence for repeatable types (partial) and rotates past refunded completed
  rows, so refund-then-repay and equal-amount partials diverge onto distinct
  keys; an un-refunded completed row keeps its key (double-charge protection
  holds). Dedup hits on refunded rows now 409, never stale success.
- chargeFailureStatus default is 503 (ambiguous), never 402; table test.
- Flaky TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance fixed
  (ORDER BY payment_type).
- resolveChargeSource: orphaned card-on-file disabled via DeleteCardOnFile
  when SaveCardForUser fails (best-effort, redacted log); retry path preserved.

Square client:
- Dev builds HARD-FAIL (panic) on SQUARE_ENVIRONMENT=production without
  SQUARE_ALLOW_REAL_API=1; sandbox routes with a loud banner.
- Mock fault-injection FailAfterCommit (commit-then-5xx) exercises the exact
  lost-response same-key retry; SimulateCardTokenUsed; 45-char idempotency-key
  cap parity; SquareEnvironment/SquareLocationID shared env helpers used by
  the sweep (env contract no longer comment-only).
- listRefunds truncation now errors (money-sensitive reconcile retries
  instead of over-refunding); getCardsOnFile truncation loudly logged.

Webhooks + 2FA:
- square-environment header checked fail-closed (403) when configured env is
  production/sandbox; dispatch DB work bounded by 30s timeout contexts.
- 2FA codes HMAC-SHA256 pepper'd (TWO_FACTOR_PEPPER) with legacy-hash
  migration + upgrade-on-verify; disable-flow mint cooldown (1/min, 429) caps
  the brute-force loop; in-lockout records never LRU-evicted.

Repo hygiene:
- env-docs CI gate green again (FRONTEND_ORIGIN + SQUARE_ALLOW_REAL_API +
  TWO_FACTOR_PEPPER documented; Vite DEV built-in allowlisted).
- Dead square_deposits schema dropped; obsidian/README/legal-page drift fixed
  (consumeradvice.scot signposting, CORS allowlist, p11 R3/P13, T1).
- 2FA disable residual documented; P6 email/SMS delivery and P12 sandbox
  smoke test remain the pre-go-live gates.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs
gate OK, live deposit-required flow re-verified end-to-end (deposit £11
charged, square_payment_id recorded).
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 39cc42b239
commit 67cf5b9a45
31 changed files with 1946 additions and 192 deletions
+15
View File
@@ -2381,6 +2381,21 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// booking.TotalAmount is still 0 here: the INSERT..RETURNING row predates
// the recalc_booking_duration_and_total trigger (fired by the
// booking_services INSERT above). Re-read the trigger-maintained values so
// the response and deposit fields use the real total — with 0, DepositPaid
// computes TRUE on an unpaid booking, and the deposit is never charged.
var bookingTotal float64
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_amount, total_duration_minutes FROM bookings WHERE id = $1
`, booking.ID).Scan(&bookingTotal, &booking.DurationMinutes); err != nil {
log.Printf("Failed to re-read booking total after creation: %v", err)
}
booking.TotalAmount = bookingTotal
booking.AmountPaid = 0
booking.AmountDue = bookingTotal
// Populate deposit display fields on the creation response.
// No payments exist yet so pre-start paid is 0 and DepositPaid will be false.
populateDepositFields(&booking, depositRequiredSnapshot, 0)
@@ -4232,6 +4232,65 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) {
}
}
// TestBookings_Create_DepositPaidFalseOnUnpaidBooking is a regression test for
// the P0 deposit-never-charged bug: CreateBookingHandler returned the booking
// from INSERT..RETURNING, which predates the recalc trigger, so TotalAmount
// serialized as 0 and DepositPaid computed TRUE on an unpaid booking. The
// frontend gate then trusted deposit_paid:true and never charged the deposit.
// The create response must report the real trigger-maintained total and
// deposit_paid=false.
func TestBookings_Create_DepositPaidFalseOnUnpaidBooking(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)
}
defer fixtures.DeleteUser(tx, userID)
if _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID); err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
var price float64
if err := tx.QueryRow(ctx, "SELECT price FROM services WHERE id = $1", serviceID).Scan(&price); err != nil {
t.Fatalf("failed to read service price: %v", err)
}
token := jwt.GenerateUserToken(userID)
start := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
start = time.Date(start.Year(), start.Month(), start.Day(), 10, 0, 0, 0, start.Location())
w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
CreateBookingRequest{StartTime: start, ServiceIDs: []string{serviceID}}, token, ctx)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var booking Booking
if err := parseResponseBody(w, &booking); err != nil {
t.Fatalf("failed to parse create response: %v", err)
}
if booking.TotalAmount != price {
t.Errorf("create response TotalAmount = %v, want service price %v (must not be 0)", booking.TotalAmount, price)
}
if booking.DepositRequired && booking.DepositAmount <= 0 {
t.Errorf("create response DepositAmount = %v, want > 0 for a deposit-required booking", booking.DepositAmount)
}
if booking.DepositPaid {
t.Error("create response DepositPaid = true for a freshly created UNPAID booking — this defeats the frontend deposit gate and the deposit is never charged")
}
}
// TestBookings_Get_DepositFieldsReturned verifies that GET /api/bookings returns
// the deposit-related fields (deposit_required, deposit_amount, deposit_paid, deposit_deadline).
func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
+17 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"crussell/db"
"crussell/internal/square"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -55,6 +56,20 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen
savedRowID, saveErr := svc.SaveCardForUser(ctx, userID, sqCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if saveErr != nil {
log.Printf("Failed to save card: %v", saveErr)
// The Square card was JUST created by this call (CreateCardOnFile
// above) but the local DB save failed, so the card-on-file is
// orphaned at Square — no user_saved_cards row references it, yet
// it is a live, chargeable card. Best-effort cleanup: disable it
// so it cannot be charged without a DB row. This is deliberately
// NOT the payment-failure path below — that path intentionally
// keeps the card so the pending record's retry re-creates it via
// the deterministic sha256 idempotency key. Here the save never
// landed, so there is no retry to preserve. A cleanup failure must
// never fail the charge: log the redacted card id so the orphan is
// auditable for manual cleanup.
if delErr := SquareClient.DeleteCardOnFile(ctx, cardOnFile.CardID); delErr != nil {
log.Printf("WARN: created Square card %s not disabled after local save failed — orphan card-on-file requires manual cleanup: %v", square.TokenPrefix(cardOnFile.CardID), delErr)
}
} else {
savedCardID = &savedRowID
}
@@ -65,7 +80,8 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen
sourceID = *newCardToken
}
if savedCardID == nil && saveCard {
log.Printf("Card was not saved despite save_card=true for user %s", userID)
// sourceID is cardOnFile.CardID on this branch (the ccof card).
log.Printf("Card was not saved despite save_card=true for user %s (card %s)", userID, square.TokenPrefix(sourceID))
}
return sourceID, savedCardID, squareCustomerID, true
}
@@ -0,0 +1,134 @@
//go:build test && dev
package payments
import (
"context"
"errors"
"net/http/httptest"
"strings"
"sync"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
// orphanCardClient wraps the dev Square client to record every DeleteCardOnFile
// call. Used to assert the save-card orphan cleanup in resolveChargeSource
// WITHOUT needing to reach the real Square API. failDelete simulates a Square
// disable failure so tests can verify the charge is not failed by cleanup.
type orphanCardClient struct {
square.SquareClient
mu sync.Mutex
deleted []string
failDelete bool
}
func (c *orphanCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
c.mu.Lock()
c.deleted = append(c.deleted, cardID)
c.mu.Unlock()
if c.failDelete {
return errors.New("square: network error disabling card at Square")
}
return nil
}
func (c *orphanCardClient) deletedIDs() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.deleted...)
}
// TestResolveChargeSource_SaveCard_HappyPath guards the save-card branch: the
// Square card is created, persisted locally via SaveCardForUser, and NO
// DeleteCardOnFile cleanup is triggered (a saved card must never be deleted).
func TestResolveChargeSource_SaveCard_HappyPath(t *testing.T) {
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-save-happy"
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "save-card source resolution must succeed on the happy path")
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
require.NotNil(t, savedCardID, "a successful SaveCardForUser must return the local row id")
require.NotEmpty(t, sqCustID, "the provisioned Square customer id must be returned")
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1 AND square_card_id = $2`, userID, sourceID).Scan(&rows))
require.Equal(t, 1, rows, "the card must be persisted as a user_saved_cards row")
require.Empty(t, rec.deletedIDs(), "a successfully saved card must never be disabled at Square")
}
// TestResolveChargeSource_SaveCard_OrphanCleanedUp verifies the orphan-card
// fix: when CreateCardOnFile succeeds but the local DB save fails, the
// just-created Square card is disabled (DeleteCardOnFile) so no card-on-file
// is left at Square without a DB row. The charge must still resolve ok=true.
func TestResolveChargeSource_SaveCard_OrphanCleanedUp(t *testing.T) {
ctx := context.Background()
// A non-existent user: EnsureSquareCustomer is bypassed via the cache, and
// SaveCardForUser's INSERT fails on the users(id) FK — a clean injection of
// the DB-save failure without touching other test state.
userID := "c_orphan_00"
squareCustomerCache.Store(userID, "cus_orphan")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-orphan-cleanup"
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "a local save failure must NOT fail the charge")
require.Nil(t, savedCardID, "no local saved-card row must exist after the failed save")
require.Equal(t, "cus_orphan", sqCustID)
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must still be the created card-on-file, got %q", sourceID)
deletes := rec.deletedIDs()
require.Len(t, deletes, 1, "the just-created Square card must be disabled exactly once")
require.Equal(t, sourceID, deletes[0], "the disabled card must be the one this call just created")
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphaned saved-card row may exist")
}
// TestResolveChargeSource_SaveCard_CleanupFailureStillCharges verifies the
// best-effort contract: when the DB save fails AND the Square disable also
// fails, the charge must still resolve ok=true (the orphan is only logged for
// manual cleanup, never allowed to fail the request).
func TestResolveChargeSource_SaveCard_CleanupFailureStillCharges(t *testing.T) {
ctx := context.Background()
userID := "c_orphan_01"
squareCustomerCache.Store(userID, "cus_orphan")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
rec := &orphanCardClient{SquareClient: square.NewDevClient(), failDelete: true}
SquareClient = rec
defer func() { SquareClient = origClient }()
token := "cnon:test-orphan-cleanup-fail"
sourceID, savedCardID, _, ok := resolveChargeSource(ctx, httptest.NewRecorder(), NewPaymentService(), userID, &token, nil, true, "")
require.True(t, ok, "a failed Square disable must never fail the charge")
require.Nil(t, savedCardID)
require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID)
require.Equal(t, []string{sourceID}, rec.deletedIDs(), "the disable must be attempted even when it will fail")
}
+5
View File
@@ -50,5 +50,10 @@ func chargeFailureStatus(err error) int {
if status >= 400 && status < 500 {
return http.StatusPaymentRequired
}
// Anything else (1xx/2xx/3xx — impossible in practice, but defensive) is
// AMBIGUOUS: the money state at Square is unknown, so the failure must be
// retryable. The default is deliberately 503, never 402 — a definitive
// decline classification on an ambiguous outcome would suppress the
// same-key retry that resumes the pending record.
return http.StatusServiceUnavailable
}
+41
View File
@@ -118,6 +118,47 @@ func TestChargeFailureStatus_RetryableCarveOuts(t *testing.T) {
}
}
// TestChargeFailureStatus_DefaultAndEdgeStatuses pins the full status-space
// classification, including the ambiguous DEFAULT branch (1xx/2xx/3xx): the
// default MUST be 503 (ambiguous → retryable) — never 402, which labels a
// definitive decline and suppresses the same-key retry that resumes the pending
// record. 409 (Square IDEMPOTENCY_KEY_REUSED — a key reused with a different
// request body) is a definitive client error and must stay 402, not fall into
// the ambiguous bucket.
func TestChargeFailureStatus_DefaultAndEdgeStatuses(t *testing.T) {
tests := []struct {
name string
status int
want int
}{
{"0 (plain/transport error) → 503", 0, http.StatusServiceUnavailable},
{"1xx → 503 (ambiguous default)", http.StatusContinue, http.StatusServiceUnavailable},
{"3xx → 503 (ambiguous default)", http.StatusMultipleChoices, http.StatusServiceUnavailable},
{"400 → 402 (definitive)", http.StatusBadRequest, http.StatusPaymentRequired},
{"401 → 402 (definitive)", http.StatusUnauthorized, http.StatusPaymentRequired},
{"403 → 402 (definitive)", http.StatusForbidden, http.StatusPaymentRequired},
{"408 → 503 (retryable)", http.StatusRequestTimeout, http.StatusServiceUnavailable},
{"409 → 402 (idempotency-key conflict, definitive)", http.StatusConflict, http.StatusPaymentRequired},
{"425 → 503 (retryable)", http.StatusTooEarly, http.StatusServiceUnavailable},
{"429 → 503 (retryable)", http.StatusTooManyRequests, http.StatusServiceUnavailable},
{"500 → 503", http.StatusInternalServerError, http.StatusServiceUnavailable},
{"503 → 503", http.StatusServiceUnavailable, http.StatusServiceUnavailable},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var err error
if tt.status == 0 {
err = errors.New("mock: payment declined (simulated failure)")
} else {
err = structuredSquareAPIError(t, tt.status)
}
if got := chargeFailureStatus(err); got != tt.want {
t.Errorf("chargeFailureStatus(status=%d) = %d, want %d", tt.status, got, tt.want)
}
})
}
}
// TestCreateBookingPayment_AmbiguousSquareFailure_Returns503 verifies the
// charge-failure classification end to end: the dev mock's simulated failure
// is a PLAIN error (no structured Square status), so the handler now returns
+134 -21
View File
@@ -719,7 +719,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to process saved-card payment: %v", err)
log.Printf("Failed to process saved-card payment: %v (error_code=%q)", err, square.ErrorCode(err))
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -1423,23 +1423,15 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// M1: generate a deterministic idempotency key server-side if the client
// doesn't provide one. The key is based on booking_id + payment_type +
// amount + card_id (or "new" for new cards), ensuring retries of the same
// logical charge use the same key while distinct charges get different keys.
if req.IdempotencyKey == "" {
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
req.IdempotencyKey = fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, req.PaymentType, req.Amount, cardPart)
if len(req.IdempotencyKey) > 45 {
// Hash long keys to fit Square's 45-char limit
hash := sha256.Sum256([]byte(req.IdempotencyKey))
req.IdempotencyKey = fmt.Sprintf("pay-%x", hash[:16])
}
}
// M1: when the client sends NO idempotency key, a DETERMINISTIC fallback
// is derived (booking_id + payment_type + amount + card_id) so a
// lost-response no-key retry reuses the same key instead of minting a
// second charge. The derivation deliberately runs INSIDE the transaction
// under the per-booking advisory lock (below): the helper advances a
// sequence past "spent" key slots, and that scan must not race a
// concurrent same-booking charge. See deriveBookingPaymentIdempotencyKey
// for how the fallback distinguishes "same live operation retried" (dedup)
// from "new operation that happens to have equal amount" (new charge).
if req.PaymentType == "partial" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
if err != nil {
@@ -1507,6 +1499,27 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
}
}()
// M1: derive the deterministic no-client-key fallback INSIDE the
// transaction under the advisory lock so the spent-slot scan below races
// no concurrent charge (two equal partials must get distinct keys even
// when they arrive back-to-back). The scan itself does the idempotency
// re-validation: a completed row that has been refunded never blocks a new
// equal-amount charge, while an un-refunded completed row keeps its key so
// the dedup lookup below returns it (double-charge protection).
if req.IdempotencyKey == "" {
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
key, keyErr := deriveBookingPaymentIdempotencyKey(r.Context(), tx, bookingID, req.PaymentType, req.Amount, cardPart)
if keyErr != nil {
log.Printf("Failed to derive deterministic idempotency key for booking %s: %v", bookingID, keyErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
req.IdempotencyKey = key
}
var status string
if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -1604,7 +1617,23 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
reusePendingRecord := false
switch {
case err == nil && existingStatus.String == "completed":
// Idempotent dedup — return the already-completed payment.
// Idempotent dedup — return the already-completed payment. First
// RE-VALIDATE the matched row's state: a refunded completed payment's
// money is no longer live, so returning it as "success" would silently
// swallow a new equal-amount charge (the booking shows paid with no
// money collected). The no-client-key deterministic path already
// rotates the key past refunded rows (deriveBookingPaymentIdempotencyKey),
// so this guard primarily covers client-keyed retries and is
// defense-in-depth for the deterministic path.
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
@@ -1790,7 +1819,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create payment: %v", err)
log.Printf("Failed to create payment: %v (error_code=%q)", err, square.ErrorCode(err))
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
}
@@ -3661,7 +3690,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
if err != nil {
log.Printf("Failed to create tip payment: %v", err)
log.Printf("Failed to create tip payment: %v (error_code=%q)", err, square.ErrorCode(err))
// Payment record intentionally left as 'pending' for manual retry.
http.Error(w, "Payment failed", chargeFailureStatus(err))
return
@@ -4004,6 +4033,90 @@ func uniqueChargeKey(prefix string) string {
return prefix + rand.Text()
}
// deriveBookingPaymentIdempotencyKey returns the deterministic fallback
// idempotency key for a no-client-key booking payment:
// "pay-<bookingID>-<paymentType>-<amount>-<cardID>", sha256-truncated when the
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic,
// so a same-key retry still dedups).
//
// The key must distinguish "same live operation retried" (dedup) from "new
// operation that happens to have equal amount" (new charge). The candidate is
// the base key (seq 0) and then the base key with a "-<seq>" suffix (seq ≥ 1)
// until a slot without a COMPLETED payment is found; what makes a completed
// slot "spent" depends on the type:
//
// - 'partial' (repeatable type): a completed row ALWAYS advances the
// sequence — two genuine equal-amount partial payments are distinct
// operations and must diverge onto distinct keys (the dedup lookup would
// otherwise return the first as success and silently swallow the second).
// - non-repeatable types (deposit/full/balance): a completed row advances
// only when its money is no longer live (it has a completed/pending
// refund). A refunded payment must not be returned as "success" for a new
// equal-amount charge — the booking would show paid with no money
// collected (refund-then-repay). An UN-refunded completed row is the SAME
// live operation retried, so its key is reused and the dedup lookup
// returns it (paying the same 50% deposit twice on an un-refunded booking
// MUST still dedup — the double-charge protection).
//
// A PENDING row never occupies a slot (the scan only matches 'completed'), so
// a lost-response retry of an in-flight charge re-derives the same key and
// reuses the pending record. seq 0 is the historical un-sequenced key, so
// legacy deterministic-key rows are still matched.
//
// Must be called inside the transaction holding the per-booking advisory lock
// so the spent-slot scan races no concurrent charge (mirrors the tip flow's
// no-client-key fallback, which counts completed tips under the tip lock).
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
for seq := 0; ; seq++ {
candidate := baseKey
if seq > 0 {
candidate = fmt.Sprintf("%s-%d", baseKey, seq)
}
if len(candidate) > 45 {
hash := sha256.Sum256([]byte(candidate))
candidate = fmt.Sprintf("pay-%x", hash[:16])
}
var completedID string
err := q.QueryRow(ctx, `
SELECT id FROM payments
WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'
`, bookingID, candidate).Scan(&completedID)
if errors.Is(err, pgx.ErrNoRows) {
return candidate, nil
}
if err != nil {
return "", err
}
refunded, rErr := paymentHasLiveRefund(ctx, q, completedID)
if rErr != nil {
return "", rErr
}
if paymentType == "partial" || refunded {
continue
}
return candidate, nil
}
}
// paymentHasLiveRefund reports whether the payment has a refund in a state
// meaning its money is no longer fully live: a completed refund (money
// returned) or a pending refund (money in flight). Failed refunds never moved
// money and are excluded. Used to re-validate a dedup hit — a refunded payment
// must never be returned as "success" for a new equal-amount charge.
func paymentHasLiveRefund(ctx context.Context, q db.Querier, paymentID string) (bool, error) {
var exists bool
err := q.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM refunds WHERE payment_id = $1 AND status IN ('completed', 'pending')
)
`, paymentID).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
// randomHexSuffix returns n random bytes hex-encoded (2n hex chars) from
// crypto/rand, used to disambiguate idempotency fallback keys that would
// otherwise collide on deterministic inputs (e.g. the no-client-key refund
+233 -2
View File
@@ -1583,6 +1583,162 @@ func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) {
}
}
// TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge is
// the money-safety regression for the deterministic no-client-key fallback key
// (finding a-i): pay £50, refund it, then pay £50 again with NO client
// idempotency key. The second payment MUST be a NEW charge — the dedup lookup
// must not return the refunded (but still status='completed') payment as
// success, which would silently swallow the second payment while the booking
// shows paid with no money collected. The deterministic derivation rotates the
// key past the refunded payment's spent slot instead.
func TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// A past booking keeps the payment unsplit (single record) and 'partial'
// is the repeatable payment type, so the same-type duplicate guard does
// not interfere with the re-pay. A SECOND £50 service is linked so the
// booking total is £100: the first £50 payment then does not auto-complete
// the booking, and a £50 re-pay after the refund stays within the
// remaining balance (refunds re-open booking capacity).
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
secondServiceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, secondServiceID)
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
handler := CreateBookingPayment
cardToken := "cnon:refund-repay-card"
// NO IdempotencyKey — exercises the deterministic booking+type+amount+card fallback.
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "partial",
NewCardToken: &cardToken,
}
// 1. Pay £50 — completes.
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "first payment: %s", w1.Body.String())
var resp1 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w1, &resp1))
// 2. Refund the full £50 via the admin refund handler (the dev mock
// completes the Square refund synchronously).
refundReq := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "refund-repay-" + bookingID}
wRefund := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+resp1.ID+"/refund", refundReq, adminToken, ctx)
require.Equal(t, http.StatusOK, wRefund.Code, "refund: %s", wRefund.Body.String())
// 3. Pay £50 again — same no-key derivation. Must be a NEW charge, not a
// dedup to the refunded first payment.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "repay: %s", w2.Body.String())
var resp2 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w2, &resp2))
require.NotEqual(t, resp1.ID, resp2.ID, "refund-then-repay must create a NEW payment, not return the refunded payment as success")
// Exactly two completed real payments with distinct idempotency keys and
// distinct Square charges.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&payCount))
require.Equal(t, 2, payCount, "refund-then-repay must record two distinct payments")
var keys, sqIDs []string
rows, err := tx.Query(ctx, `SELECT idempotency_key, square_payment_id FROM payments WHERE booking_id = $1 AND status = 'completed' ORDER BY created_at ASC`, bookingID)
require.NoError(t, err)
for rows.Next() {
var k, s string
require.NoError(t, rows.Scan(&k, &s))
keys = append(keys, k)
sqIDs = append(sqIDs, s)
}
rows.Close()
require.Equal(t, 2, len(keys))
require.NotEqual(t, keys[0], keys[1], "the two payments must use distinct idempotency keys")
require.NotEqual(t, sqIDs[0], sqIDs[1], "the second payment must be a new Square charge, not a dedup of the refunded one")
}
// TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse is the
// money-safety regression for the deterministic no-client-key fallback key
// (finding a-ii): two genuine equal-amount partial payments on the same card
// must both be recorded. The deterministic key derivation includes a sequence
// that advances past the first completed equal partial, so the second derives
// a DISTINCT key instead of silently collapsing onto the first.
func TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
handler := CreateBookingPayment
cardToken := "cnon:equal-partial-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "partial",
NewCardToken: &cardToken,
}
for i := 0; i < 2; i++ {
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "partial %d: %s", i, w.Body.String())
}
var keys []string
rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC`, bookingID)
require.NoError(t, err)
for rows.Next() {
var k string
require.NoError(t, rows.Scan(&k))
keys = append(keys, k)
}
rows.Close()
require.Equal(t, 2, len(keys), "two genuine equal-amount partials must both be recorded")
require.NotEqual(t, keys[0], keys[1], "equal-amount partials must not collapse onto one idempotency key")
}
// TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups pins the
// double-charge protection that MUST survive the sequence fix: paying the same
// 50% deposit twice on an UN-refunded booking with no client key must dedup to
// the existing completed payment — never a second Square charge. The
// deterministic derivation advances its sequence only past refunded/partial
// slots; an un-refunded 'deposit' slot keeps its key so the dedup lookup
// returns the original payment.
func TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
handler := CreateBookingPayment
cardToken := "cnon:same-deposit-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
}
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "first deposit: %s", w1.Body.String())
var resp1 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w1, &resp1))
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "second deposit: %s", w2.Body.String())
var resp2 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w2, &resp2))
require.Equal(t, resp1.ID, resp2.ID, "the same 50%% deposit charged twice on an un-refunded booking must DEDUP, not double-charge")
var count int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&count))
require.Equal(t, 1, count, "only ONE deposit payment may be recorded")
}
// ============================================================
// User Booking Payment Tests — deposit, full, partial, balance
// ============================================================
@@ -1809,10 +1965,14 @@ func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Should be exactly 2 payment records.
// Should be exactly 2 payment records. Order by payment_type (the enum
// defines 'deposit' before 'balance', so the deposit row is first) plus
// created_at as a tiebreaker — ORDER BY amount was nondeterministic here
// because both split amounts are equal (£25.00), which flaked ~1-in-10
// runs asserting records[0] is the deposit.
rows, err := tx.Query(ctx,
`SELECT payment_type, amount, square_payment_id
FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, bookingID)
FROM payments WHERE booking_id = $1 ORDER BY payment_type, created_at ASC`, bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
@@ -2252,6 +2412,77 @@ func TestGetBookingPaymentInfo_ExcludesDiscountPayments(t *testing.T) {
}
}
// TestBuildSplitRecords_DepositCarve_PenceExact pins the 50% deposit carve to
// exact pence outcomes (money-safety audit): the carve rounds ONCE at the
// pence boundary and the parts always partition the charged amount exactly, so
// the recorded pence can never exceed what Square actually charged. The
// tip-percentage math (tip% × subtotal) lives in the frontend — the backend
// receives the charged total — so this test pins the backend's deposit carve,
// the only percentage-derived math in the charge-splitting path.
func TestBuildSplitRecords_DepositCarve_PenceExact(t *testing.T) {
cases := []struct {
name string
total float64 // booking total, pounds
paid float64 // already paid, pounds
chargedPence int64 // the charge, int64 pence as Square reports it
wantDeposit int64 // expected deposit carve, pence
wantBalance int64 // expected balance portion, pence
wantTip int64 // expected tip portion, pence
}{
{"£50 charge on £50 booking → 25.00 + 25.00", 50, 0, 5000, 2500, 2500, 0},
{"£60 charge on £50 booking → deposit + balance + tip overflow", 50, 0, 6000, 2500, 2500, 1000},
{"£25 charge on £100 booking (under the 50% cap) → all deposit", 100, 0, 2500, 2500, 0, 0},
{"£60 charge on £100 booking → 50.00 deposit + 10.00 balance", 100, 0, 6000, 5000, 1000, 0},
{"£100 charge on £100 booking → 50.00 + 50.00", 100, 0, 10000, 5000, 5000, 0},
{"£50 charge on £100 booking with £30 already paid → deposit fills to cap", 100, 30, 5000, 2000, 3000, 0},
{"£12.34 charge on £25.00 booking → all deposit", 25, 0, 1234, 1234, 0, 0},
{"£25 charge on £25.50 booking → 12.75 deposit + 12.25 balance (half-penny carve)", 25.50, 0, 2500, 1275, 1225, 0},
{"£45.67 charge on £50 booking → 25.00 + 20.67", 50, 0, 4567, 2500, 2067, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
chargedPounds := float64(tc.chargedPence) / 100.0
record := makeTestRecord("pence-booking", "full", chargedPounds)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records := buildSplitRecords(record, "full", info, chargedPounds)
var depositPence, balancePence, tipPence, partitionPence int64
for _, r := range records {
pence := int64(math.Round(r.Amount * 100))
partitionPence += pence
switch r.PaymentType {
case "deposit":
depositPence = pence
case "tip":
tipPence = pence
default:
// The booking-portion remainder after the deposit carve.
// Its TYPE is dynamic ('balance' when the payment reaches
// the booking total, 'partial' when it does not); the
// pence are what this table pins.
balancePence = pence
}
}
if depositPence != tc.wantDeposit {
t.Errorf("deposit carve = %d pence, want %d", depositPence, tc.wantDeposit)
}
if balancePence != tc.wantBalance {
t.Errorf("balance portion = %d pence, want %d", balancePence, tc.wantBalance)
}
if tipPence != tc.wantTip {
t.Errorf("tip portion = %d pence, want %d", tipPence, tc.wantTip)
}
if partitionPence != tc.chargedPence {
t.Errorf("split records partition to %d pence, want the charged %d pence", partitionPence, tc.chargedPence)
}
})
}
}
// ---------------------------------------------------------------------------
// Handler-level atomicity — verify the full handler succeeds with split.
// ---------------------------------------------------------------------------
+5 -4
View File
@@ -102,10 +102,11 @@ func CalculateRefundForCancellation(
// lockCancellationPayments serializes a cancellation refund against the manual
// RefundPayment handler and the sweep. Both hold
// `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level)
// on the payment ids they touch; a cancellation that computes residuals
// without the same locks can over-refund against a manual refund in flight (the
// manual guard read precedes the cancellation's commit). Locks are acquired in
// `pg_advisory_xact_lock(hashtext('crussell:refund:' || payment_id))`
// (transaction-level, acquired via acquireAdvisoryXactLockBlocking) on the
// payment ids they touch; a cancellation that computes residuals without the
// same locks can over-refund against a manual refund in flight (the manual
// guard read precedes the cancellation's commit). Locks are acquired in
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks.
// EVERY payment row the cancellation may refund is locked — giftcard and cash
// rows included, not just card methods — so two concurrent refunds of the same
+14 -5
View File
@@ -518,8 +518,12 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
// charge and its replay, the replayed wire body differs and a RETAINED key
// returns IDEMPOTENCY_KEY_REUSED — stranding every retained-key row pending
// (safe) until the 24h blind-fail. This is a single-location deployment; the
// contract is enforced by ops (same env for the sweeper and the API), NOT by a
// runtime equality check — deliberately comment-only.
// contract is enforced by ops (same env for the sweeper and the API). The
// environment and location are read through square.SquareEnvironment() and
// square.SquareLocationID() — the SAME code path the charge-time HTTP client
// uses (newHTTPClient resolves its base URL and location from those helpers) —
// so the sweep can never drift to a second, independent env read; the
// reconcile below logs the resolved values at each replay as a tripwire.
func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (staleReconcileResult, string) {
snapshot := r.SquareRequestSnapshot
// fallbackBody is true when the row has NO stored square_request_snapshot
@@ -552,9 +556,14 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
snapshot = fallback
}
// The replay repeats the stored request snapshot verbatim so Square's
// idempotency dedup returns the original payment for a retained key — which
// requires the sweep to share SQUARE_ENVIRONMENT/SQUARE_LOCATION_ID with the
// charge process (see the ENV CONTRACT above).
// idempotency dedup returns the original payment for a retained key. The
// sweep resolves its Square environment and location through the same
// helpers the charge-time HTTP client uses (square.SquareEnvironment /
// square.SquareLocationID), so the replay and the charge can never read
// two different env sources (see the ENV CONTRACT above). The values are
// logged on each replay as a tripwire for env/location drift between the
// sweeper and the API.
log.Printf("[SWEEP] replay-by-key reconcile for %s row %s: SQUARE_ENVIRONMENT=%q SQUARE_LOCATION_ID=%q (must match the charge-time env/location for identical-body idempotency)", table, r.ID, square.SquareEnvironment(), square.SquareLocationID())
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
if err != nil {
if errors.Is(err, square.ErrReplayKeyNotRetained) {
+190 -51
View File
@@ -1,17 +1,21 @@
package user
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"crussell/clock"
@@ -40,16 +44,71 @@ func generateTwoFACode() (string, error) {
return fmt.Sprintf("%06d", n.Int64()), nil
}
// hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB
// stores only the digest; the plaintext code is delivered by logging it with a
// [2FA] prefix (see deliverTwoFACode). The digest is unsalted SHA-256 —
// peppering it via HMAC-SHA256 with a server-side 2FA_PEPPER secret is a future
// hardening step once such a secret is provisioned.
// twoFAPepperEnv is the environment variable carrying the server-side pepper
// that keys the HMAC of stored 2FA codes (documented in .env.example). When it
// is absent the code falls back to the legacy plain SHA-256 digest with a
// one-time warning — see hashTwoFACode.
const twoFAPepperEnv = "TWO_FACTOR_PEPPER"
// twoFAPepperWarnOnce guards the one-time warning when TWO_FACTOR_PEPPER is
// unset, so a misconfigured deployment is loudly flagged once rather than on
// every code operation.
var twoFAPepperWarnOnce sync.Once
// twoFAPepper returns the configured HMAC pepper, or "" when unset. Read per
// call (the rest of the backend reads env vars per call too) so a value
// provisioned at runtime is picked up; only the warning is gated on sync.Once.
func twoFAPepper() string {
pepper := os.Getenv(twoFAPepperEnv)
if pepper == "" {
twoFAPepperWarnOnce.Do(func() {
log.Printf("WARNING: TWO_FACTOR_PEPPER unset — 2FA codes hashed without an HMAC pepper (falling back to unsalted SHA-256); set TWO_FACTOR_PEPPER in production so a leaked digest cannot be brute-forced offline")
})
}
return pepper
}
// hashTwoFACode returns the hex digest of a verification code as stored in the
// DB. With TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper,
// so a leaked digest cannot be brute-forced offline (the key stays server-side).
// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest
// and logs a one-time warning. The plaintext code is never stored — only
// delivered via the [2FA] log line (see deliverTwoFACode).
func hashTwoFACode(code string) string {
if pepper := twoFAPepper(); pepper != "" {
mac := hmac.New(sha256.New, []byte(pepper))
mac.Write([]byte(code))
return hex.EncodeToString(mac.Sum(nil))
}
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// legacyHashTwoFACode returns the pre-pepper plain SHA-256 digest, used to
// verify rows written before TWO_FACTOR_PEPPER was provisioned during the
// migration window (see verifyTwoFACodeHash).
func legacyHashTwoFACode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// verifyTwoFACodeHash reports whether reqCode matches a stored pending-code
// digest, always in constant time (subtle.ConstantTimeCompare). The first
// comparison uses the current pepper'd digest; when that fails the stored hash
// may be a legacy pre-pepper plain SHA-256 (rows written before TWO_FACTOR_PEPPER
// was provisioned), so the legacy digest is tried too. When a legacy row
// matches, legacy is true and the caller should re-hash with the pepper on the
// next successful verify, retiring the plain digest.
func verifyTwoFACodeHash(reqCode, storedHash string) (match, legacy bool) {
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(reqCode)), []byte(storedHash)) == 1 {
return true, false
}
if subtle.ConstantTimeCompare([]byte(legacyHashTwoFACode(reqCode)), []byte(storedHash)) == 1 {
return true, true
}
return false, false
}
// 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 = 5
@@ -62,15 +121,29 @@ const twoFAAttemptWindow = 10 * time.Minute
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
// schema is locked — there is no attempt column), so they reset on process
// restart; the 10-minute pending-code expiry bounds the practical impact.
const twoFAMaxTrackedAttempts = 10_000
// Declared as a var so the eviction policy is unit-testable at a small cap.
var twoFAMaxTrackedAttempts = 10_000
// twoFAAttemptState tracks consecutive failed verify attempts for one user. The
// per-user mutex serializes the whole verify critical section so concurrent
// attempts from the same user cannot race the limit check.
// attempts from the same user cannot race the limit check. count is atomic so
// the map eviction path can read it without taking the per-user mutex (lock
// ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then takes mapMu).
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown).
type twoFAAttemptState struct {
mu sync.Mutex
count int
lastAt time.Time
mu sync.Mutex
count atomic.Int32
lastAt time.Time
lastMintAt time.Time
}
// lockedOut reports whether the state is inside its lockout window: the attempt
// counter has reached the cap and the window has not yet elapsed. Such a record
// is the rate limit's source of truth for its user and must never be evicted
// while in-window — evicting it would silently reset the counter and grant a
// fresh guessing budget.
func (st *twoFAAttemptState) lockedOut(now time.Time) bool {
return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastAt) <= twoFAAttemptWindow
}
var (
@@ -79,8 +152,13 @@ var (
)
// twoFAAttemptStateFor returns the per-user attempt state, creating it if
// needed. The map is bounded: stale entries are evicted opportunistically and,
// when at capacity, the least-recently-active entry is dropped.
// needed. The map is bounded: stale (window-expired) entries are evicted
// opportunistically and, when at capacity, the least-recently-active
// non-locked-out entry is dropped. A record still inside its lockout window is
// NEVER evicted — evicting it would reset the victim's attempt counter and
// bypass the rate limit under a hostile flood of new keys. When the map is
// full of in-window locked-out records (a pathological flood), a transient,
// untracked state is returned instead of growing the map past the cap.
func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
twoFAAttemptMapMu.Lock()
defer twoFAAttemptMapMu.Unlock()
@@ -91,9 +169,15 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
var oldestAt time.Time
for id, st := range twoFAAttemptMap {
if now.Sub(st.lastAt) > twoFAAttemptWindow {
// Idle/expired — its counter has already lapsed; safe to evict.
delete(twoFAAttemptMap, id)
continue
}
if st.lockedOut(now) {
// Inside its lockout window — the rate limit's source of truth
// for this user. Never evict (finding-e fix).
continue
}
if oldestID == "" || st.lastAt.Before(oldestAt) {
oldestID, oldestAt = id, st.lastAt
}
@@ -101,6 +185,13 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
delete(twoFAAttemptMap, oldestID)
}
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts {
// Every entry is a locked-out in-window record. Do not evict one
// (that would reset its rate limit) and do not grow past the cap:
// return a transient, untracked state so THIS request still
// proceeds under a fresh budget.
return &twoFAAttemptState{lastAt: now}
}
}
st := twoFAAttemptMap[userID]
@@ -111,12 +202,21 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
return st
}
// twoFAResetAttempts clears a user's attempt counter. Called on successful
// verify and when a fresh code is generated via setup.
// twoFAResetAttempts resets a user's attempt counter in place (count only)
// WITHOUT deleting the entry, preserving lastMintAt so the disable-flow mint
// cooldown survives a fresh-code delivery. Called on successful verify and when
// a fresh code is generated via setup or disable. lastAt is deliberately not
// touched here: it is re-stamped by checkTwoFACode on real activity, and
// writing it under mapMu would race with checkTwoFACode's st.mu-guarded write
// (the setup path holds no st.mu). The lock ordering is st.mu→mapMu at call
// sites, never the reverse (twoFAAttemptStateFor takes mapMu only and never
// takes st.mu).
func twoFAResetAttempts(userID string) {
twoFAAttemptMapMu.Lock()
delete(twoFAAttemptMap, userID)
twoFAAttemptMapMu.Unlock()
defer twoFAAttemptMapMu.Unlock()
if st := twoFAAttemptMap[userID]; st != nil {
st.count.Store(0)
}
}
// deliverTwoFACode generates a fresh verification code, persists only its
@@ -295,10 +395,10 @@ const (
// here and still reported as a lockout.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
st.count = 0
st.count.Store(0)
st.lastAt = now
}
if st.count >= twoFAMaxAttempts {
if st.count.Load() >= twoFAMaxAttempts {
return twoFACodeLockedOut, nil
}
@@ -316,11 +416,14 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
return twoFACodeMissingOrExpired, nil
}
// Constant-time compare (subtle) so a wrong code's match position cannot be
// inferred from response timing. Both digests are fixed-length hex.
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(reqCode)), []byte(pendingHash.String)) != 1 {
st.count++
// inferred from response timing. Both digests are fixed-length hex. Legacy
// pre-pepper rows (plain SHA-256, hashed before TWO_FACTOR_PEPPER existed)
// still verify during the transition window.
match, legacy := verifyTwoFACodeHash(reqCode, pendingHash.String)
if !match {
st.count.Add(1)
st.lastAt = clock.Now()
if st.count >= twoFAMaxAttempts {
if st.count.Load() >= twoFAMaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(r.Context(), `
@@ -336,9 +439,22 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
return twoFACodeIncorrect, nil
}
// Success: clear the attempt counter before the caller performs its action.
st.count = 0
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the
// pepper so the plain digest is retired on the next successful verify.
if legacy {
if _, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = $2
WHERE id = $1
`, userID, hashTwoFACode(reqCode)); err != nil {
log.Printf("failed to upgrade legacy 2FA pending code hash for user %s: %v", userID, err)
}
}
// Success: clear the attempt counter (and any disable-flow mint cooldown)
// before the caller performs its action.
st.count.Store(0)
st.lastAt = clock.Now()
st.lastMintAt = time.Time{}
twoFAResetAttempts(userID)
return twoFACodeOK, nil
}
@@ -425,6 +541,19 @@ func writeTwoFAEnabled(w http.ResponseWriter) {
}
}
// twoFAMintCooldown bounds how often a fresh 2FA code may be minted for one
// user during the disable flow. Without it, a password-only attacker could loop
// disable → fresh code (which resets the 5-attempt counter) → 5 wrong guesses →
// fresh code again, for ~100 guesses/min unbounded. The cooldown caps guessing
// at 5 per window (~5/min) while still letting a legitimate code-lost user
// recover after a short wait.
const twoFAMintCooldown = 1 * time.Minute
// errTwoFAMintThrottled is returned by ensurePendingTwoFACode when the user's
// last disable-flow mint is inside twoFAMintCooldown, so the caller returns 429
// instead of minting another fresh code.
var errTwoFAMintThrottled = errors.New("2FA code mint throttled")
type TwoFADisableRequest struct {
Code string `json:"code"`
}
@@ -437,9 +566,10 @@ type TwoFADisableRequest struct {
// attacker must not be able to disable the protection. A fresh code is generated
// and delivered via the [2FA] log channel when no valid pending code exists, and
// the submitted code is checked under the shared 5-attempt lockout (wrong code →
// 400, lockout → 429); only a correct code clears the flag. In unenforced (dev)
// environments the loose behavior is kept: no code required, so local dev is not
// blocked.
// 400, lockout → 429); only a correct code clears the flag. Fresh-code mints are
// throttled per-user (twoFAMintCooldown) so the loop above cannot reset the
// lockout faster than once per cooldown. In unenforced (dev) environments the
// loose behavior is kept: no code required, so local dev is not blocked.
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
@@ -471,24 +601,20 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
defer st.mu.Unlock()
// Reuse a valid pending code when one exists; otherwise generate + deliver
// a fresh one via the same [2FA] log channel as setup.
freshDelivered, err := ensurePendingTwoFACode(r, userID)
if err != nil {
// a fresh one via the same [2FA] log channel as setup. A fresh code gets its
// own independent 5-attempt budget (the mint resets the counter), so the
// per-user mint cooldown is what stops the unlimited-guess loop — an
// attacker can mint at most one fresh code per twoFAMintCooldown.
if err := ensurePendingTwoFACode(r, userID, st); err != nil {
if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return
}
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// The fresh delivery reset the shared attempt map entry (twoFAResetAttempts
// deletes it), but the held st still carries the pre-delivery count. Reset
// it only when a fresh code was actually delivered, so a locked-out user can
// use the code just minted in THIS request — while the reuse path keeps
// accumulating wrong attempts toward the 5-attempt lockout.
if freshDelivered {
st.count = 0
st.lastAt = clock.Now()
}
result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
@@ -517,13 +643,19 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
}
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// code to verify against, generating + delivering a fresh one via the same [2FA]
// log channel as setup when the stored code is missing or expired. The boolean
// reports whether a fresh code was delivered (false = an existing valid code
// was reused), which the caller uses to decide whether to reset the held
// attempt counter. A fresh code also resets any prior lockout, matching setup's
// recovery behavior. The caller must hold the user's attempt-state mutex.
func ensurePendingTwoFACode(r *http.Request, userID string) (bool, error) {
// code to verify against, generating + delivering a fresh one via the same
// [2FA] log channel as setup when the stored code is missing or expired. The
// caller must hold the user's attempt-state mutex.
//
// A fresh code gets its own independent 5-attempt budget (deliverTwoFACode
// resets the counter via twoFAResetAttempts), so the per-user mint cooldown is
// what prevents a password-only attacker from looping mint → burn 5 guesses →
// mint forever: only one fresh code per twoFAMintCooldown per user. A locked-out
// user can still use the code minted in THIS request; a user who exhausts it
// must wait out the cooldown for the next mint — the documented disable-flow
// residual. A failed delivery does not start the cooldown (the stamp is written
// only after the UPDATE persisted).
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState) error {
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
@@ -532,13 +664,20 @@ func ensurePendingTwoFACode(r *http.Request, userID string) (bool, error) {
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return false, err
return err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return false, nil
return nil
}
_, err = deliverTwoFACode(r, userID, "", "disable 2FA")
return true, err
now := clock.Now()
if !st.lastMintAt.IsZero() && now.Sub(st.lastMintAt) < twoFAMintCooldown {
return errTwoFAMintThrottled
}
if _, err := deliverTwoFACode(r, userID, "", "disable 2FA"); err != nil {
return err
}
st.lastMintAt = now
return nil
}
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
+298 -1
View File
@@ -12,16 +12,20 @@ package user
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
@@ -136,6 +140,9 @@ func TestTwoFASetup_InvalidMethod(t *testing.T) {
func TestTwoFASetup_Valid_StoresHash(t *testing.T) {
twofaEnvUnenforced(t)
// Pin the pepper off so the stored hash assertion below is deterministic
// regardless of the ambient test environment.
t.Setenv("TWO_FACTOR_PEPPER", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
@@ -151,7 +158,8 @@ func TestTwoFASetup_Valid_StoresHash(t *testing.T) {
require.Equal(t, "Code sent", resp.Message)
require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code")
// The DB must hold the SHA-256 digest of exactly the returned code.
// The DB must hold the digest of exactly the returned code. With the pepper
// pinned off, that is the plain SHA-256 (the legacy fallback).
var pendingHash, method sql.NullString
var expires sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `
@@ -606,3 +614,292 @@ func TestTwoFAVerify_WrongCodesAnyLengthRejected(t *testing.T) {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID2)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
// =============================================================================
// Pepper hashing (finding c)
// =============================================================================
// TestTwoFAPepper_HashUsesHMAC verifies that with TWO_FACTOR_PEPPER set the
// stored digest is HMAC-SHA256 keyed by the pepper, NOT the legacy unsalted
// SHA-256 — so a leaked digest cannot be brute-forced offline.
func TestTwoFAPepper_HashUsesHMAC(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
const code = "123456"
got := hashTwoFACode(code)
mac := hmac.New(sha256.New, []byte("test-pepper-secret"))
mac.Write([]byte(code))
want := hex.EncodeToString(mac.Sum(nil))
require.Equal(t, want, got, "stored hash must be HMAC-SHA256 keyed by TWO_FACTOR_PEPPER")
require.NotEqual(t, legacyHashTwoFACode(code), got, "pepper'd hash must differ from the legacy plain SHA-256")
}
// TestTwoFAPepper_UnsetFallback_PlainSHA256 verifies the graceful no-pepper
// fallback keeps the legacy unsalted SHA-256 digest when TWO_FACTOR_PEPPER is
// unset.
func TestTwoFAPepper_UnsetFallback_PlainSHA256(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "")
const code = "654321"
got := hashTwoFACode(code)
sum := sha256.Sum256([]byte(code))
require.Equal(t, hex.EncodeToString(sum[:]), got, "unset pepper must fall back to legacy plain SHA-256")
require.Equal(t, legacyHashTwoFACode(code), got)
}
// TestTwoFAPepper_LegacyHashDetected verifies verifyTwoFACodeHash accepts both
// the pepper'd and the legacy plain forms (the transition window) and flags
// legacy rows for upgrade.
func TestTwoFAPepper_LegacyHashDetected(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
const code = "123456"
match, legacy := verifyTwoFACodeHash(code, hashTwoFACode(code))
require.True(t, match)
require.False(t, legacy, "pepper'd stored hash must not be flagged for upgrade")
match, legacy = verifyTwoFACodeHash(code, legacyHashTwoFACode(code))
require.True(t, match)
require.True(t, legacy, "legacy stored hash must verify and flag the upgrade")
match, legacy = verifyTwoFACodeHash("999999", legacyHashTwoFACode(code))
require.False(t, match)
require.False(t, legacy)
}
// TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify verifies that a legacy
// pre-pepper row still verifies during the migration window AND that the stored
// hash is upgraded to the pepper'd form on the next successful verify (the
// plain digest is retired).
func TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify(t *testing.T) {
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// Seed a legacy row exactly as the pre-pepper code wrote it: plain SHA-256.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
// checkTwoFACode (the shared verify path) must accept the legacy hash.
st := &twoFAAttemptState{lastAt: clock.Now()}
req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx)
result, err := checkTwoFACode(req, userID, st, "123456")
require.NoError(t, err)
require.Equal(t, twoFACodeOK, result)
// The stored hash must now be the pepper'd form.
var stored sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&stored))
require.True(t, stored.Valid, "checkTwoFACode alone must not clear the pending hash")
require.Equal(t, hashTwoFACode("123456"), stored.String, "legacy hash must be upgraded to the pepper'd form on successful verify")
}
// TestTwoFAVerify_LegacyHash_StillVerifies pins the end-to-end migration
// window: an enforced env with the pepper set must still accept a user whose
// pending code was hashed the old (pre-pepper) way.
func TestTwoFAVerify_LegacyHash_StillVerifies(t *testing.T) {
twofaEnvEnforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled)
}
// TestTwoFAVerify_LegacyHash_WrongCodeRejected verifies the legacy path still
// enforces the correct code: a wrong code against a legacy-hashed row is
// rejected and 2FA stays off.
func TestTwoFAVerify_LegacyHash_WrongCodeRejected(t *testing.T) {
twofaEnvEnforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "test-pepper-secret")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_method = 'email',
two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1`, userID, legacyHashTwoFACode("123456"), clock.Now().Add(twoFAPendingExpiry))
require.NoError(t, err)
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled)
}
// =============================================================================
// Disable-flow mint throttle (finding d)
// =============================================================================
// TestTwoFADisable_MintThrottled_BoundsGuessing verifies the unlimited-guess
// loop is closed: a password-only attacker who burns the 5-attempt budget on a
// freshly minted code cannot mint ANOTHER fresh code (which would reset the
// counter) inside the per-user mint cooldown. Exactly one fresh code is minted
// across the whole loop and the throttled request returns 429.
func TestTwoFADisable_MintThrottled_BoundsGuessing(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Request 1 mints a fresh code (the first mint is allowed) and rejects the
// wrong submission; requests 2-5 reuse that code, reaching the lockout.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
// Request 6 is inside the cooldown: no fresh code may be minted, so the loop
// stops with 429 instead of minting an unlimited series of fresh codes.
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Wait before requesting a new code.")
// Exactly ONE fresh code was minted across all six requests — the loop can
// no longer reset the attempt budget.
mints := strings.Count(buf.String(), "disable 2FA")
require.Equal(t, 1, mints, "expected exactly 1 fresh-code mint; log:\n%s", buf.String())
}
// TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery verifies the documented
// residual is not a permanent lockout: once the mint cooldown elapses, a
// legitimate code-lost user can mint and verify a fresh code again.
func TestTwoFADisable_MintThrottle_ExpiresAllowsRecovery(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// Burn the budget: 4 wrong 400s, the 5th locks out (429).
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, "mint must be throttled inside the cooldown")
// Simulate the cooldown elapsing (the test cannot wait a real minute).
st := twoFAAttemptStateFor(userID)
st.lastMintAt = clock.Now().Add(-twoFAMintCooldown - time.Second)
// A fresh disable request now mints a new code via the [2FA] log channel and
// rejects the wrong submission with 400 — recovery is possible again.
buf.Reset()
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "000000"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "cooldown expiry must allow a fresh mint")
}
// =============================================================================
// Attempt-map eviction (finding e)
// =============================================================================
// TestTwoFAAttemptMap_InLockoutRecordNotEvicted verifies the eviction fix: a
// record still inside its lockout window is NEVER evicted by LRU pressure — a
// hostile flood of new keys cannot reset the victim's attempt counter. Only an
// idle/expired record is dropped to make room.
func TestTwoFAAttemptMap_InLockoutRecordNotEvicted(t *testing.T) {
twoFAAttemptMapMu.Lock()
origMap := twoFAAttemptMap
origCap := twoFAMaxTrackedAttempts
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
twoFAMaxTrackedAttempts = 4
twoFAAttemptMapMu.Unlock()
t.Cleanup(func() {
twoFAAttemptMapMu.Lock()
twoFAAttemptMap = origMap
twoFAMaxTrackedAttempts = origCap
twoFAAttemptMapMu.Unlock()
})
now := clock.Now()
for _, id := range []string{"idle_a", "idle_b", "idle_c"} {
twoFAAttemptMap[id] = &twoFAAttemptState{lastAt: now.Add(-time.Minute)}
}
victim := &twoFAAttemptState{lastAt: now.Add(-time.Second)}
victim.count.Store(5)
twoFAAttemptMap["victim"] = victim
// A new user hits the cap: the eviction must drop an idle record, never the
// in-lockout victim.
st := twoFAAttemptStateFor("new_user")
require.NotNil(t, st)
if _, ok := twoFAAttemptMap["victim"]; !ok {
t.Error("in-lockout record must never be evicted by LRU pressure")
}
if got := twoFAAttemptMap["victim"].count.Load(); got != 5 {
t.Errorf("victim attempt count must survive eviction pressure, got %d", got)
}
if len(twoFAAttemptMap) > 4 {
t.Errorf("map must stay within the cap, got %d entries", len(twoFAAttemptMap))
}
if _, ok := twoFAAttemptMap["new_user"]; !ok {
t.Error("new user must be tracked in the map")
}
}
// TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient verifies the pathological
// case: when every entry is a locked-out in-window record (a flood), the map
// does NOT evict one and does NOT grow past the cap — the new user gets a
// transient, untracked state for this request instead.
func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) {
twoFAAttemptMapMu.Lock()
origMap := twoFAAttemptMap
origCap := twoFAMaxTrackedAttempts
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
twoFAMaxTrackedAttempts = 3
twoFAAttemptMapMu.Unlock()
t.Cleanup(func() {
twoFAAttemptMapMu.Lock()
twoFAAttemptMap = origMap
twoFAMaxTrackedAttempts = origCap
twoFAAttemptMapMu.Unlock()
})
now := clock.Now()
for i := 0; i < 3; i++ {
st := &twoFAAttemptState{lastAt: now.Add(-time.Second)}
st.count.Store(5)
twoFAAttemptMap[fmt.Sprintf("locked_%d", i)] = st
}
st := twoFAAttemptStateFor("new_user")
require.NotNil(t, st)
if _, ok := twoFAAttemptMap["new_user"]; ok {
t.Error("expected the transient state NOT to be stored when the map is full of in-lockout records")
}
if len(twoFAAttemptMap) != 3 {
t.Errorf("expected all 3 locked-out records to survive, got %d", len(twoFAAttemptMap))
}
}
+127 -32
View File
@@ -13,7 +13,9 @@ import (
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"crussell/db"
"crussell/handlers/payments"
@@ -82,6 +84,56 @@ func (d *squareWebhookDedup) register(id string) bool {
// (square_webhook_events) is the unbounded, restart-safe source of truth.
var squareWebhookEventsSeen = newSquareWebhookDedup(500)
// webhookDBTimeout bounds the DB work performed while dispatching a webhook.
// The work runs on a Background-derived context — so a client disconnect cannot
// cancel it (the at-least-once delivery contract must survive) — but is
// timeout-bound so a hung DB call cannot hold a pgx pool connection forever;
// repeated hangs would otherwise exhaust the pool. 30s is the same
// post-request DB budget used elsewhere in the backend (handlers/user).
const webhookDBTimeout = 30 * time.Second
// webhookDBContext returns a timeout-bound, Background-derived context for
// webhook dispatch DB work.
func webhookDBContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), webhookDBTimeout)
}
// squareEnvironmentMismatch reports whether the webhook's square-environment
// header conflicts with the deployment's configured SQUARE_ENVIRONMENT.
//
// The check is enforced ONLY when the configured environment is a known real
// Square environment (production/sandbox): those are the deployments where a
// mis-pointed subscription (e.g. a sandbox subscription posting to the
// production URL + signing key) would process events against the wrong state.
// In dev/mock deployments — or an empty/unknown SQUARE_ENVIRONMENT, which the
// rest of the backend treats as fail-closed production but is not a specific
// real environment to compare against — the header is informational and a
// mismatch is not rejectable, mirroring IsExplicitDevOrMockEnv
// (handlers/payments/twofa.go) so the interpretation cannot diverge.
//
// An absent header is allowed through: real Square deliveries always send it,
// so a missing header in an enforced deployment is a non-Square client (which
// already failed signature verification) or a local mock/dev poster — both
// safely handled downstream. Rejection is 403 (non-retryable for Square):
// a square-environment mismatch is a PERMANENT configuration error that a
// retry could never resolve, so a 5xx would make Square retry forever; a 4xx
// stops the retry loop and forces the operator to fix the subscription or the
// environment setting.
func squareEnvironmentMismatch(headerEnv string) bool {
headerEnv = strings.ToLower(strings.TrimSpace(headerEnv))
if headerEnv == "" {
return false
}
switch configured := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT"))); configured {
case "production", "sandbox":
return headerEnv != configured
default:
// Empty/unknown/dev/mock configured environment — no specific real
// environment to enforce against.
return false
}
}
// HandleSquareWebhook verifies and dispatches Square webhook events.
//
// Fail-closed chain: 503 when the signing key is unset, 403 on a missing/bad
@@ -130,6 +182,19 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Fail-closed environment check: a sandbox subscription mis-pointed at the
// production URL + key would otherwise process sandbox events against
// production state. 403 (non-retryable for Square) is correct because a
// square-environment mismatch is a permanent config error — a 5xx would
// make Square retry a condition no retry can fix. See
// squareEnvironmentMismatch for the exact enforcement conditions.
if squareEnvironmentMismatch(r.Header.Get("square-environment")) {
log.Printf("[SQUARE-WEBHOOK] Rejecting event: square-environment header %q does not match configured SQUARE_ENVIRONMENT %q (403)",
r.Header.Get("square-environment"), os.Getenv("SQUARE_ENVIRONMENT"))
http.Error(w, "square environment mismatch", http.StatusForbidden)
return
}
var event SquareWebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
log.Printf("Failed to parse webhook event: %v", err)
@@ -181,7 +246,7 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
var dispatchErr error
switch event.Type {
case "payment.updated", "payment.created":
dispatchErr = handlePaymentUpdated(r.Context(), event.Data)
dispatchErr = handlePaymentUpdated(event.Data)
case "refund.updated", "refund.created":
dispatchErr = handleRefundUpdated(event.Data)
case "dispute.created":
@@ -205,7 +270,15 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
// error: without a persisted row we cannot prove the event was handled, so
// reject and let Square retry (the retry re-dispatches idempotently and
// retries the insert). event_id is not PII, so logging it is safe.
tag, err := db.Conn.Exec(r.Context(),
// Commit the dedup row AFTER successful dispatch. Fail closed on a write
// error: without a persisted row we cannot prove the event was handled, so
// reject and let Square retry (the retry re-dispatches idempotently and
// retries the insert). event_id is not PII, so logging it is safe. A
// bounded Background context keeps this post-dispatch write alive across a
// client disconnect without letting a hung insert hold the pool forever.
dedupCtx, dedupCancel := webhookDBContext()
defer dedupCancel()
tag, err := db.Conn.Exec(dedupCtx,
"INSERT INTO square_webhook_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING", event.EventID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to record event_id %s (dedup write failed): %v", event.EventID, err)
@@ -355,14 +428,16 @@ func squareDisputeStateToLocal(state string) string {
// findPaymentBySquareID resolves the local payment id and booking id for a
// Square payment id. Multiple local rows can share one Square charge id (e.g.
// a deposit + balance split); the most recent is used.
func findPaymentBySquareID(squarePaymentID string) (paymentID, bookingID string, ok bool) {
// a deposit + balance split); the most recent is used. The caller supplies a
// bounded context (webhookDBContext) so this post-dispatch DB work survives a
// client disconnect without holding a pool connection forever.
func findPaymentBySquareID(ctx context.Context, squarePaymentID string) (paymentID, bookingID string, ok bool) {
if squarePaymentID == "" {
return "", "", false
}
var pid string
var bid *string
err := db.Conn.QueryRow(context.Background(), `
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id FROM payments
WHERE square_payment_id = $1
ORDER BY created_at DESC, id DESC
@@ -379,11 +454,12 @@ func findPaymentBySquareID(squarePaymentID string) (paymentID, bookingID string,
// findPaymentByDisputeID resolves the local payment (and its booking) recorded
// for a dispute row. Used by dispute.state.updated when the dispute row already
// exists but the webhook payload carries no resolvable Square payment id.
func findPaymentByDisputeID(squareDisputeID string) (paymentID, bookingID string) {
// exists but the webhook payload carries no resolvable Square payment id. The
// caller supplies a bounded context (webhookDBContext).
func findPaymentByDisputeID(ctx context.Context, squareDisputeID string) (paymentID, bookingID string) {
var pid string
var bid *string
err := db.Conn.QueryRow(context.Background(), `
err := db.Conn.QueryRow(ctx, `
SELECT d.payment_id, p.booking_id
FROM disputes d
JOIN payments p ON p.id = d.payment_id
@@ -423,10 +499,11 @@ func disputeNotificationID(squareDisputeID string) string {
// of the same dispute is a no-op (ON CONFLICT (id) DO NOTHING). The
// booking-scoped NOT EXISTS guard does NOT apply to this path: it would
// collapse every untracked dispute onto one unacknowledged NULL-booking row.
func insertCriticalPaymentNotification(bookingID, disputeID string) {
// The caller supplies a bounded context (webhookDBContext).
func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID string) {
if disputeID != "" {
id := disputeNotificationID(disputeID)
tag, err := db.Conn.Exec(context.Background(), `
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
ON CONFLICT (id) DO NOTHING
@@ -444,7 +521,7 @@ func insertCriticalPaymentNotification(bookingID, disputeID string) {
if bookingID != "" {
bid = bookingID
}
tag, err := db.Conn.Exec(context.Background(), `
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, created_at)
SELECT 'critical_payment_log'::admin_notification_reason, $1, NOW()
WHERE NOT EXISTS (
@@ -466,11 +543,12 @@ func insertCriticalPaymentNotification(bookingID, disputeID string) {
// markPaymentFailed flips a payment to 'failed' after a lost dispute — the
// money was charged back, so the row must not read as collected. 'refunded'
// rows are left alone (the money was returned by refund, not charged back).
func markPaymentFailed(paymentID string) error {
// The caller supplies a bounded context (webhookDBContext).
func markPaymentFailed(ctx context.Context, paymentID string) error {
if paymentID == "" {
return nil
}
_, err := db.Conn.Exec(context.Background(),
_, err := db.Conn.Exec(ctx,
"UPDATE payments SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status IN ('pending', 'completed')",
paymentID)
if err != nil {
@@ -485,8 +563,13 @@ func markPaymentFailed(paymentID string) error {
// Square id is logged, never the payload (PII). Idempotent: the UPDATE is a
// no-op when the local status already matches, and event_id dedup prevents
// re-entry at the handler level. A non-nil error means dispatch failed and the
// caller must NOT commit the dedup row (Square retries).
func handlePaymentUpdated(ctx context.Context, data json.RawMessage) error {
// caller must NOT commit the dedup row (Square retries). All DB work runs on a
// bounded Background context (webhookDBContext): a client disconnect must not
// cancel the state mutation, and a hung DB call must not hold the pool forever.
func handlePaymentUpdated(data json.RawMessage) error {
ctx, cancel := webhookDBContext()
defer cancel()
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data))
@@ -511,7 +594,7 @@ func handlePaymentUpdated(ctx context.Context, data json.RawMessage) error {
// settled row (Square fires payment.updated for ANY field change, e.g. fee
// recalculation on a fully-refunded charge) must never revert a terminal
// status like 'refunded' back to 'completed'.
tag, err := db.Conn.Exec(context.Background(),
tag, err := db.Conn.Exec(ctx,
`UPDATE payments SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`,
localStatus, payment.ID)
if err != nil {
@@ -530,7 +613,7 @@ func handlePaymentUpdated(ctx context.Context, data json.RawMessage) error {
if localStatus == "failed" {
return clawbackFailedTillSales(ctx, payment.ID)
}
tsTag, err := db.Conn.Exec(context.Background(),
tsTag, err := db.Conn.Exec(ctx,
`UPDATE till_sales SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`,
localStatus, payment.ID)
if err != nil {
@@ -553,7 +636,7 @@ func handlePaymentUpdated(ctx context.Context, data json.RawMessage) error {
// sale's funding unreverted — the caller rejects the webhook so Square retries
// the clawback (the sweep is the eventual backstop).
func clawbackFailedTillSales(ctx context.Context, squarePaymentID string) error {
rows, err := db.Conn.Query(context.Background(), `
rows, err := db.Conn.Query(ctx, `
SELECT ts.id, ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(ts.created_at = gc.created_at) AS is_create
FROM till_sales ts
@@ -593,7 +676,7 @@ func clawbackOneTillSale(ctx context.Context, saleID, itemType string, itemID sq
if itemType != "gift_card" || !itemID.Valid || itemID.String == "" || isCreate == nil {
// No gift card to claw back — mark the sale failed without touching
// any card (mirrors the sweep's non-gift-card branch).
tag, err := db.Conn.Exec(context.Background(), `
tag, err := db.Conn.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, saleID)
@@ -639,8 +722,12 @@ func revertTillSaleGiftCardFunding(ctx context.Context, action, giftCardID strin
// handleRefundUpdated reconciles a Square Refund state change against the local
// refunds row. Idempotent (status-guarded UPDATE + event_id dedup). A non-nil
// error means dispatch failed and the caller must NOT commit the dedup row.
// error means dispatch failed and the caller must NOT commit the dedup row. DB
// work runs on a bounded Background context (webhookDBContext).
func handleRefundUpdated(data json.RawMessage) error {
ctx, cancel := webhookDBContext()
defer cancel()
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data))
@@ -672,7 +759,7 @@ func handleRefundUpdated(data json.RawMessage) error {
case "failed":
upd = `UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending'`
}
tag, err := db.Conn.Exec(context.Background(), upd, refund.ID)
tag, err := db.Conn.Exec(ctx, upd, refund.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err)
return err
@@ -709,8 +796,12 @@ func truncateDisputeReason(reason string) string {
// sweep fallback for disputes and a chargeback the app cannot see is a silent
// money-loss path the owner must always be told about. Idempotent via
// ON CONFLICT (square_dispute_id) DO NOTHING plus the event_id dedup. A non-nil
// error means dispatch failed (no dedup row committed — Square retries).
// error means dispatch failed (no dedup row committed — Square retries). DB
// work runs on a bounded Background context (webhookDBContext).
func handleDisputeCreated(data json.RawMessage) error {
ctx, cancel := webhookDBContext()
defer cancel()
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] dispute.created received (payload length=%d)", len(data))
@@ -725,7 +816,7 @@ func handleDisputeCreated(data json.RawMessage) error {
if dispute.DisputedPayment != nil {
squarePaymentID = dispute.DisputedPayment.PaymentID
}
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
paymentID, bookingID, paymentFound := findPaymentBySquareID(ctx, squarePaymentID)
if !paymentFound {
// Untracked chargeback: no local payments row for this Square charge
// (Dashboard-initiated, mismatched Square payment id, or a deleted/erased
@@ -737,11 +828,11 @@ func handleDisputeCreated(data json.RawMessage) error {
// Still return nil so the dedup row commits and Square's retry is
// acknowledged.
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created for square payment %q with NO local payment row — chargeback cannot be reconciled in-app — admin notified (booking_id NULL)", dispute.ID, squarePaymentID)
insertCriticalPaymentNotification("", dispute.ID)
insertCriticalPaymentNotification(ctx, "", dispute.ID)
return nil
}
amount := squareMoneyToAmount(dispute.AmountMoney)
tag, err := db.Conn.Exec(context.Background(), `
tag, err := db.Conn.Exec(ctx, `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason, created_at, updated_at)
VALUES ($1, $2, 'open', $3, NULLIF($4, ''), NOW(), NOW())
ON CONFLICT (square_dispute_id) DO NOTHING
@@ -751,7 +842,7 @@ func handleDisputeCreated(data json.RawMessage) error {
return err
}
_ = tag
insertCriticalPaymentNotification(bookingID, "")
insertCriticalPaymentNotification(ctx, bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID)
return nil
}
@@ -760,8 +851,12 @@ func handleDisputeCreated(data json.RawMessage) error {
// disputes row (upsert — a state.updated may arrive before the created event),
// and on a terminal loss marks the payment failed + raises CRITICAL. Won is
// logged only. Idempotent: the upsert converges to the same row. A non-nil
// error means dispatch failed (no dedup row committed — Square retries).
// error means dispatch failed (no dedup row committed — Square retries). DB
// work runs on a bounded Background context (webhookDBContext).
func handleDisputeStateUpdated(data json.RawMessage) error {
ctx, cancel := webhookDBContext()
defer cancel()
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (payload length=%d)", len(data))
@@ -779,17 +874,17 @@ func handleDisputeStateUpdated(data json.RawMessage) error {
if dispute.DisputedPayment != nil {
squarePaymentID = dispute.DisputedPayment.PaymentID
}
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
paymentID, bookingID, paymentFound := findPaymentBySquareID(ctx, squarePaymentID)
if !paymentFound {
// Row may already exist from dispute.created — recover its payment.
paymentID, bookingID = findPaymentByDisputeID(dispute.ID)
paymentID, bookingID = findPaymentByDisputeID(ctx, dispute.ID)
if paymentID == "" {
log.Printf("[SQUARE-WEBHOOK] dispute.state.updated: no local payment for dispute %s (square payment %q) — cannot record state %s", dispute.ID, squarePaymentID, dispute.State)
return nil
}
}
_, err := db.Conn.Exec(context.Background(), `
_, err := db.Conn.Exec(ctx, `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NOW(), NOW())
ON CONFLICT (square_dispute_id) DO UPDATE
@@ -803,10 +898,10 @@ func handleDisputeStateUpdated(data json.RawMessage) error {
switch localStatus {
case "lost":
if err := markPaymentFailed(paymentID); err != nil {
if err := markPaymentFailed(ctx, paymentID); err != nil {
return err
}
insertCriticalPaymentNotification(bookingID, "")
insertCriticalPaymentNotification(ctx, bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s LOST — payment %s marked failed; admin notified", dispute.ID, paymentID)
case "won":
log.Printf("[SQUARE-WEBHOOK] dispute %s WON — resolved in seller's favour; no action", dispute.ID)
+134
View File
@@ -16,6 +16,7 @@ import (
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"crussell/db"
@@ -149,6 +150,23 @@ func makeWebhookRequest(body []byte, signature string, ctx context.Context) *htt
return w
}
// makeWebhookRequestWithEnv is makeWebhookRequest plus an explicit
// square-environment header (Square sends one on every real delivery).
func makeWebhookRequestWithEnv(body []byte, signature, env string, ctx context.Context) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/webhooks/square", bytes.NewReader(body))
req = req.WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
if signature != "" {
req.Header.Set("x-square-hmacsha256-signature", signature)
}
if env != "" {
req.Header.Set("square-environment", env)
}
HandleSquareWebhook(w, req)
return w
}
// webhookTestEnv sets a signing key and returns a valid signature for the body
// (the fail-closed handler requires a verifiable signature on every request).
func webhookTestEnv(t *testing.T, body []byte) (signature string) {
@@ -621,3 +639,119 @@ func TestHandleSquareWebhook_DedupNilConn_FailsClosed(t *testing.T) {
t.Fatalf("expected 503 when DB is not wired, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// square-environment header validation (finding a)
// =============================================================================
// TestHandleSquareWebhook_EnvMismatch_Rejected verifies fail-closed behavior: a
// correctly-signed event carrying a square-environment header that contradicts
// the configured SQUARE_ENVIRONMENT (e.g. a sandbox subscription mis-pointed at
// the production URL + key) is rejected with 403. 403 is correct here because
// the mismatch is a permanent configuration error — Square treats 4xx as
// non-retryable, so the retry loop stops instead of hammering a condition no
// retry can fix. No dispatch and no dedup row.
func TestHandleSquareWebhook_EnvMismatch_Rejected(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_mismatch_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"id":"payment_env_mismatch_1"}`),
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
w := makeWebhookRequestWithEnv(body, sig, "sandbox", context.Background())
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 on environment mismatch, got %d. body: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, event.EventID); n != 0 {
t.Errorf("expected no dedup row for a rejected event, got %d", n)
}
}
// TestHandleSquareWebhook_EnvMatch_Accepted verifies a matching environment
// header passes the check and dispatches normally.
func TestHandleSquareWebhook_EnvMatch_Accepted(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_match_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"id":"payment_env_match_1"}`),
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
w := makeWebhookRequestWithEnv(body, sig, "production", context.Background())
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on matching environment, got %d. body: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row after successful dispatch, got %d", n)
}
}
// TestHandleSquareWebhook_EnvHeaderAbsent_Allowed verifies an absent header is
// allowed through (local mock/dev posting), even in an enforced deployment —
// the signature check remains the authentication gate.
func TestHandleSquareWebhook_EnvHeaderAbsent_Allowed(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_absent_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"id":"payment_env_absent_1"}`),
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
w := makeWebhookRequestWithEnv(body, sig, "", context.Background())
if w.Code != http.StatusOK {
t.Fatalf("expected 200 when the environment header is absent, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestHandleSquareWebhook_EnvMismatch_DevNotEnforced verifies the header check
// is NOT enforced when the configured SQUARE_ENVIRONMENT is a dev/mock value
// (the same interpretation IsExplicitDevOrMockEnv uses elsewhere), so a header
// carrying "sandbox" against an explicit "mock" config still dispatches.
func TestHandleSquareWebhook_EnvMismatch_DevNotEnforced(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock")
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_dev_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"id":"payment_env_dev_1"}`),
}
body, _ := json.Marshal(event)
sig := webhookTestEnv(t, body)
w := makeWebhookRequestWithEnv(body, sig, "sandbox", context.Background())
if w.Code != http.StatusOK {
t.Fatalf("expected 200 in dev mode (check not enforced), got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Bounded post-dispatch DB contexts (finding b)
// =============================================================================
// TestWebhookDBContext_HasTimeout verifies webhookDBContext returns a
// Background-derived context with a deadline, so a hung DB call cannot hold a
// pgx pool connection forever while still surviving a client disconnect.
func TestWebhookDBContext_HasTimeout(t *testing.T) {
ctx, cancel := webhookDBContext()
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected webhookDBContext to carry a deadline")
}
if remaining := time.Until(deadline); remaining <= 0 || remaining > webhookDBTimeout {
t.Errorf("expected remaining budget within (0, %v], got %v", webhookDBTimeout, remaining)
}
if got := ctx.Err(); got != nil {
t.Errorf("expected a fresh context to be active, got %v", got)
}
}
+133 -16
View File
@@ -19,6 +19,28 @@ package square
// test as evidence of how prod treats a retained key after a restart. If a
// test needs retained-key behaviour, it must re-seed the payment under the key
// into the same mock instance (see TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued).
//
// 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
// 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.
//
// 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
// leftover SQUARE_ENVIRONMENT=production in a dev shell would otherwise create
// REAL charges from test bookings. NewDevClient therefore HARD-FAILS (panics
// with errDevRealAPIRequiresOverride) when SQUARE_ENVIRONMENT=production
// unless the explicit override SQUARE_ALLOW_REAL_API=1 is set, and logs a loud
// banner before routing a dev build to the SANDBOX. The non-dev build
// (square.go, `//go:build !dev`) is untouched: NewProdClient always uses the
// real client path selected by the normal non-dev wiring.
import (
"context"
@@ -81,6 +103,26 @@ type MockClient struct {
// post-insert CreateCheckout-failure path (marking the provisional
// terminal_checkouts row failed) can be exercised in dev/tests.
FailCreateCheckout bool
// FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge internally
// (retaining the key + source in paymentByKey/paymentSource 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 — 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
}
type devProdClient struct{}
@@ -130,24 +172,47 @@ func NewClient() SquareClient {
return NewDevClient()
}
// errDevRealAPIRequiresOverride is the hard-fail error NewDevClient panics
// with when a dev build is asked to route to the real PRODUCTION Square API
// without the explicit SQUARE_ALLOW_REAL_API=1 override. A dev build must
// never silently charge real money on an env-string match alone.
var errDevRealAPIRequiresOverride = errors.New("square: dev build refuses SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 (would route to the REAL Square API)")
func NewDevClient() SquareClient {
env := os.Getenv("SQUARE_ENVIRONMENT")
if env == "sandbox" || env == "production" {
log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=%s — making real API calls to %s", env, realBaseURL(env))
env := SquareEnvironment()
switch env {
case "production":
// A `//go:build dev` build routing to the real production API is an
// explicit safety boundary, not a string-match convenience. Without
// the override, a typo'd or leftover SQUARE_ENVIRONMENT=production in
// a dev shell would make test bookings create REAL charges and payouts.
// Fail fast so the misconfiguration is impossible to miss.
if os.Getenv("SQUARE_ALLOW_REAL_API") != "1" {
log.Printf("[SQUARE-PROD] REFUSING to construct the real production Square client in a dev build: SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1 — set SQUARE_ALLOW_REAL_API=1 to override, or SQUARE_ENVIRONMENT=sandbox/mock for safe dev traffic")
panic(errDevRealAPIRequiresOverride)
}
log.Printf("[SQUARE-PROD] SQUARE_ENVIRONMENT=production WITH SQUARE_ALLOW_REAL_API=1 — dev build making REAL API calls to %s (explicit override, real money)", realBaseURL(env))
return &devProdClient{}
}
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
cardByToken: make(map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
paymentSource: make(map[string]string),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
case "sandbox":
// Sandbox never moves real money, so a dev build may route there — but
// loudly, so no-one mistakes a sandbox for the mock.
log.Printf("[SQUARE-PROD] *** DEV BUILD ROUTING TO SQUARE SANDBOX %s — test credentials only, NO real charges — this is NOT the mock client ***", realBaseURL(env))
return &devProdClient{}
default:
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
cardByToken: make(map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
paymentSource: make(map[string]string),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedCardTokens: make(map[string]bool),
}
}
}
@@ -205,6 +270,20 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
err: errors.New("square: customer_id required for card-on-file source"),
}
}
// Square's idempotency-key limit for POST /v2/payments is 45 characters
// (64 only for /v2/terminals/checkouts). Real Square rejects an oversized
// key with a 400 VALUE_TOO_LONG; the mock mirrors the rejection with the
// same structured error so dev parity catches over-length keys (the real
// client always derives ≤45-char keys, so this only fires on a caller bug).
if len(req.IdempotencyKey) > 45 {
return nil, &squareAPIError{
Code: "VALUE_TOO_LONG",
Detail: "idempotency_key must be 45 characters or fewer",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: idempotency_key %s is %d chars, exceeds Square's 45-char limit", tokenPrefix(req.IdempotencyKey), len(req.IdempotencyKey)),
}
}
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
// card reference (ccof:) that could be replayed. Log only its prefix and
// length for debugging (S-2).
@@ -309,6 +388,15 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
m.paymentSource[req.IdempotencyKey] = req.SourceID
}
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
if m.FailAfterCommit {
// The charge is already committed above (payment + key + source are in
// the ledgers exactly like a successful charge) — now simulate the lost
// response: the caller sees a 5xx-style error while Square holds the
// payment under the key. A same-key + same-source retry dedups to the
// committed payment instead of charging twice, exactly like prod.
log.Printf("[SQUARE-MOCK] FailAfterCommit: payment %s committed under key=%s but returning simulated 503 (response lost)", paymentID, req.IdempotencyKey)
return nil, fmt.Errorf("square: charge %s committed but response lost (simulated HTTP 503) — retry with the same idempotency key to receive the committed payment", paymentID)
}
return result, nil
}
@@ -609,6 +697,19 @@ 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 {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, 0, len(m.usedCardTokens))
for tok := range m.usedCardTokens {
out = append(out, tok)
}
return out
}
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
@@ -637,6 +738,19 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
m.mu.Lock()
defer m.mu.Unlock()
if m.SimulateCardTokenUsed && m.usedCardTokens[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).
return nil, &squareAPIError{
Code: "CARD_TOKEN_USED",
Detail: "The card token has already been used.",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)),
}
}
if m.cards[userID] == nil {
m.cards[userID] = make(map[string]*CardOnFile)
}
@@ -665,6 +779,9 @@ 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
}
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil
}
+247
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
@@ -1479,3 +1480,249 @@ func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {
require.NoError(t, err)
assert.Len(t, results, 8)
}
// TestDevClient_ProductionEnvWithoutOverride_HardFails locks the dev-safety
// boundary: a `//go:build dev` build must NEVER silently route to the real
// PRODUCTION Square API on an env-string match alone (a typo'd/leftover
// SQUARE_ENVIRONMENT=production in a dev shell would create REAL charges from
// test bookings). NewDevClient hard-fails unless the explicit
// SQUARE_ALLOW_REAL_API=1 override is set; with the override it proceeds.
func TestDevClient_ProductionEnvWithoutOverride_HardFails(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "production")
t.Setenv("SQUARE_ALLOW_REAL_API", "")
require.PanicsWithError(t, errDevRealAPIRequiresOverride.Error(), func() {
NewDevClient()
}, "a dev build must refuse SQUARE_ENVIRONMENT=production without SQUARE_ALLOW_REAL_API=1")
// The explicit override is the opt-in that lets a dev build route to the
// real production API.
t.Setenv("SQUARE_ALLOW_REAL_API", "1")
client := NewDevClient()
require.IsType(t, &devProdClient{}, client, "SQUARE_ALLOW_REAL_API=1 must allow the dev build to route to the real production API")
}
// TestDevClient_SandboxEnv_RoutesToRealClient locks the sandbox routing
// banner: a dev build may route to the Square SANDBOX (no real money), but
// only with a loud banner so the sandbox is never mistaken for the mock.
func TestDevClient_SandboxEnv_RoutesToRealClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
t.Setenv("SQUARE_ALLOW_REAL_API", "")
var buf bytes.Buffer
log.SetOutput(&buf)
defer log.SetOutput(os.Stderr)
client := NewDevClient()
require.IsType(t, &devProdClient{}, client, "a dev build may route to the Square sandbox (no real money)")
logs := buf.String()
assert.Contains(t, logs, "SANDBOX", "routing a dev build to the sandbox must log a loud banner")
assert.Contains(t, logs, squareSandboxURL, "the banner must name the sandbox endpoint, not a mock")
}
// TestDevClient_CreatePayment_FailAfterCommit locks the FailAfterCommit
// fault-injection: CreatePayment COMMITS the charge (retaining key + source in
// the ledgers exactly like a successful charge) and THEN returns a 5xx-style
// error — the "charged but response lost" prod scenario. A same-key +
// same-source retry must dedup to the committed payment, never issue a second
// charge.
func TestDevClient_CreatePayment_FailAfterCommit_ErrorThenSameKeyRetryDedups(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
req := CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "fail-after-commit-key",
ReferenceID: "booking-lost-response",
}
client.FailAfterCommit = true
got, err := client.CreatePayment(ctx, req)
require.Error(t, err, "FailAfterCommit must return an error to the caller (the response was lost)")
assert.Nil(t, got)
assert.Contains(t, err.Error(), "503", "the lost-response error must read as a 5xx for ambiguous classification")
// The charge was committed: the key + source are retained exactly like a
// successful charge, and the payment resolves by SquarePayID.
client.mu.RLock()
committed := client.paymentByKey["fail-after-commit-key"]
storedSource := client.paymentSource["fail-after-commit-key"]
client.mu.RUnlock()
require.NotNil(t, committed, "FailAfterCommit must COMMIT the charge under the idempotency key")
assert.Equal(t, "cnon:test-card", storedSource, "FailAfterCommit must retain the source under the key")
byID, err := client.GetPayment(ctx, committed.SquarePayID)
require.NoError(t, err)
assert.Equal(t, committed.ID, byID.ID, "the committed payment must be resolvable by SquarePayID")
// A same-key + same-source retry dedups to the committed payment — the
// exact prod 503-retry semantics (no double charge).
client.FailAfterCommit = false
retry, err := client.CreatePayment(ctx, req)
require.NoError(t, err)
assert.Equal(t, committed.ID, retry.ID, "same-key retry must return the committed payment, not a second charge")
client.mu.RLock()
payCount := len(client.payments)
client.mu.RUnlock()
assert.Equal(t, 1, payCount, "FailAfterCommit + same-key retry must store exactly ONE charge")
}
// TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey locks the mock's
// 45-char idempotency-key cap: real Square rejects an over-length key for
// POST /v2/payments with a 400 VALUE_TOO_LONG, and the mock must mirror that
// structured rejection so dev parity catches a caller bug.
func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
longKey := strings.Repeat("k", 46)
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: longKey,
})
require.Error(t, err, "an idempotency key over Square's 45-char limit must be rejected")
assert.Nil(t, result)
assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
// A 45-char key is the boundary and must be accepted.
ok, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: strings.Repeat("k", 45),
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", ok.Status)
}
// TestDevClient_CreateCardOnFile_SimulateCardTokenUsed locks the CARD_TOKEN_USED
// simulation: when SimulateCardTokenUsed is enabled, a card token (cnon: nonce)
// reused after a previous save is rejected with Square's structured 400
// CARD_TOKEN_USED error. Off by default (dev/test flows reuse plain test
// tokens across requests), so the toggle must not reject reuse when disabled.
func TestDevClient_CreateCardOnFile_SimulateCardTokenUsed(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateCardTokenUsed = true
ctx := context.Background()
card, err := client.CreateCardOnFile(ctx, "user-token-used", "cnon:single-use-nonce", "cus_test123")
require.NoError(t, err)
assert.NotEmpty(t, card.ID)
// Reusing the same token → Square's CARD_TOKEN_USED rejection.
_, err = client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:single-use-nonce", "cus_test123")
require.Error(t, err)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedCardTokens())
// A fresh token still works.
fresh, err := client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:fresh-nonce", "cus_test123")
require.NoError(t, err)
assert.NotEmpty(t, fresh.ID)
// With the toggle OFF (default), reusing a token is allowed — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests.
client.SimulateCardTokenUsed = false
_, err = client.CreateCardOnFile(ctx, "user-token-reuse", "cnon:reused-token", "cus_test123")
require.NoError(t, err)
_, err = client.CreateCardOnFile(ctx, "user-token-reuse-2", "cnon:reused-token", "cus_test123")
require.NoError(t, err, "with SimulateCardTokenUsed off, token reuse must be allowed")
}
// TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and
// the real HTTP client AGREE on the over-length idempotency key rejection:
// both surface the same structured code (VALUE_TOO_LONG) and HTTP status (400).
func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
longKey := strings.Repeat("k", 46)
// Real client: Square's 400 VALUE_TOO_LONG response surfaces as a
// structured squareAPIError (the doJSON error-parsing path).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"VALUE_TOO_LONG","detail":"idempotency_key too long"}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createPaymentHTTPWithClient(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey,
}, hc)
require.Error(t, realErr)
// Mock: rejects the same key client-side with the identical structured
// error (code + status), so dev parity holds.
mock := NewDevClient().(*MockClient)
_, mockErr := mock.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: longKey,
})
require.Error(t, mockErr)
assert.Equal(t, "VALUE_TOO_LONG", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for an over-length key")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for an over-length key")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
}
// TestCardTokenUsed_Parity_MockAndRealClientAgree asserts the mock and the
// real HTTP client AGREE on the reused-card-token rejection: both surface the
// same structured code (CARD_TOKEN_USED) and HTTP status (400).
func TestCardTokenUsed_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
token := "cnon:reused-nonce"
// Real client: Square's 400 CARD_TOKEN_USED response surfaces as a
// structured squareAPIError.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"CARD_TOKEN_USED","detail":"The card token has already been used."}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", token, "cus_1", hc)
require.Error(t, realErr)
// Mock: with the simulation enabled, reusing a consumed token surfaces the
// identical structured error.
mock := NewDevClient().(*MockClient)
mock.SimulateCardTokenUsed = true
_, err := mock.CreateCardOnFile(ctx, "user_1", token, "cus_1")
require.NoError(t, err)
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", token, "cus_1")
require.Error(t, mockErr)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card token")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card token")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
}
// TestEnvResolution_HelperMatchesHTTPClient locks the shared env-resolution
// contract (finding 4): the sweep and the charge path read SQUARE_ENVIRONMENT /
// SQUARE_LOCATION_ID through the SAME helpers the HTTP client uses, so the two
// deployables can never drift to independent env reads.
func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
t.Setenv("SQUARE_LOCATION_ID", "L_TEST_ENV")
assert.Equal(t, "sandbox", SquareEnvironment())
assert.Equal(t, "L_TEST_ENV", SquareLocationID())
// newHTTPClient derives base URL + location from the SAME helpers.
hc := newHTTPClient()
assert.Equal(t, squareSandboxURL, hc.baseURL, "sandbox env must resolve the sandbox base URL")
assert.Equal(t, "L_TEST_ENV", hc.locationID, "the HTTP client must read the location through SquareLocationID")
// Production resolves the production base URL; anything else resolves the
// sandbox base URL — never the production URL.
t.Setenv("SQUARE_ENVIRONMENT", "production")
assert.Equal(t, squareProductionURL, newHTTPClient().baseURL, "production env must resolve the production base URL")
t.Setenv("SQUARE_ENVIRONMENT", "mock")
assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)")
}
+38 -11
View File
@@ -60,8 +60,28 @@ type httpClient struct {
http *http.Client
}
// SquareEnvironment returns the resolved SQUARE_ENVIRONMENT value. It is the
// SINGLE code path by which this package reads which Square environment it
// talks to: newHTTPClient derives its base URL from it and the dev build's
// NewDevClient routes on it, so a dev mock vs real API decision is never a
// second, drifting env read. The payments sweep reads the same value through
// this helper so the sweep and the charge process share one environment source
// (the sweep env contract).
func SquareEnvironment() string {
return os.Getenv("SQUARE_ENVIRONMENT")
}
// SquareLocationID returns the SQUARE_LOCATION_ID value newHTTPClient embeds
// in payment requests. Exported so the payments sweep resolves the location
// through the same code path as the charge process: a location drift between a
// charge and its replay would change the replay body and break Square's
// identical-body idempotency dedup (the sweep env contract).
func SquareLocationID() string {
return os.Getenv("SQUARE_LOCATION_ID")
}
func newHTTPClient() *httpClient {
env := os.Getenv("SQUARE_ENVIRONMENT")
env := SquareEnvironment()
baseURL := squareSandboxURL
if env == "production" {
baseURL = squareProductionURL
@@ -69,7 +89,7 @@ func newHTTPClient() *httpClient {
return &httpClient{
baseURL: baseURL,
token: os.Getenv("SQUARE_ACCESS_TOKEN"),
locationID: os.Getenv("SQUARE_LOCATION_ID"),
locationID: SquareLocationID(),
deviceID: os.Getenv("SQUARE_TERMINAL_DEVICE_ID"),
http: &http.Client{Timeout: defaultHTTPTimeout},
}
@@ -797,11 +817,14 @@ func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime
}
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
}
// 20 pages fetched and a cursor is still present — return what we
// collected rather than discarding partial results (the previous
// infinite-loop guard dropped everything and returned an error).
log.Printf("[SQUARE] list refunds exceeded 20 pages (infinite-loop guard) — returning partial results: %d refunds for %s", len(results), paymentID)
return results, nil
// 20 pages fetched and a cursor is still present — the infinite-loop
// guard. Returning the partial results would be silently wrong for the
// money-sensitive reconcile caller: a refund sitting in the truncated tail
// would look like "no COMPLETED refund exists", letting the sweep mark the
// rows failed and over-refund. Error instead — reconcileRefundAtSquare
// treats any error as "leave the rows pending, retry later", so no money
// decision is made on partial data.
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite-loop guard) — refusing partial results for payment %s", paymentID)
}
func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
@@ -874,10 +897,14 @@ func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpCl
path = "/v2/cards?reference_id=" + url.QueryEscape(userID) + "&cursor=" + url.QueryEscape(resp.Cursor)
}
if truncated {
// 20 pages fetched and a cursor is still present — return what we
// collected rather than discarding partial results (mirrors the
// listRefunds 20-page guard's behavior).
log.Printf("[SQUARE] list cards for %s exceeded 20 pages (infinite-loop guard) — returning partial results: %d cards", userID, len(cards))
// 20 pages fetched and a cursor is still present — the infinite-loop
// guard. Unlike listRefunds (where partial data can drive an over-refund
// decision and therefore ERRORS), cards are deliberately returned as
// partial: GetCardsOnFile has no money-sensitive caller, and erroring
// would break a "show my cards" feature for a user with >500 saved
// cards. The correctness gap (oldest card silently missing) is accepted
// and surfaced loudly in the log so it is not a silent truncation.
log.Printf("[SQUARE] list cards for %s exceeded 20 pages (infinite-loop guard) — TRUNCATED: returning partial results: %d of 500+ cards", userID, len(cards))
}
if cards == nil {
cards = []CardOnFile{}
@@ -736,10 +736,11 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
}
})
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
// The 20-page guard must not discard what was already collected: it
// logs a truncation warning and returns the partial results instead
// of failing the reconcile with an error.
t.Run("page_guard_errors_instead_of_partial", func(t *testing.T) {
// The 20-page guard must ERROR rather than return partial results: a
// refund in the truncated tail would otherwise look like "no COMPLETED
// refund exists", letting the reconcile mark rows failed and over-refund.
// The reconcile caller treats any error as "leave rows pending, retry".
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
@@ -750,14 +751,17 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
if err != nil {
t.Fatalf("expected partial results (nil error), got %v", err)
if err == nil {
t.Fatalf("expected a truncation error after 20 pages, got %d partial refunds with nil error", len(refunds))
}
if !strings.Contains(err.Error(), "exceeded 20 pages") {
t.Errorf("expected error to name the 20-page guard, got %v", err)
}
if calls != 20 {
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
}
if len(refunds) != 20 {
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
if len(refunds) != 0 {
t.Errorf("expected no partial results on truncation error, got %d", len(refunds))
}
})
}
@@ -909,6 +913,33 @@ func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
}
}
// TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial verifies the 20-page guard
// keeps returning partial results (nil error) for card listing, unlike
// listRefunds which errors: GetCardsOnFile has no money-sensitive caller, and
// erroring would break a "show my cards" feature for a user with >500 saved
// cards. The truncation is surfaced in the log, not by an error.
func TestGetCardsOnFileHTTP_PageGuard_ReturnsPartial(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cards":[{"id":"ccof_t","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp_t","reference_id":"user_big","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
cards, err := getCardsOnFileHTTPWithClient(context.Background(), "user_big", hc)
if err != nil {
t.Fatalf("expected partial cards with nil error, got %v", err)
}
if calls != 20 {
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
}
if len(cards) != 20 {
t.Errorf("expected 20 cards collected across pages (one per page), got %d", len(cards))
}
}
// TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as
// checkout.device_options.tip_settings.allow_tipping (Square's wire shape for
// enabling terminal tips) and omitted entirely when not set.
+7
View File
@@ -40,6 +40,13 @@ type CreatePaymentReq struct {
IdempotencyKey string
ReferenceID string // booking ID or other reference
Note string
// Autocomplete and TipMoney are valid Square wire fields that are
// intentionally NOT populated by any current handler: online payments are
// completed immediately (Autocomplete nil = Square default true, no
// approve-then-capture) and tips are handled locally as separate tip
// payments rather than split inside Square's CreatePayment (TipMoney nil).
// They are wired through the client into the request body for completeness
// and future use — do not remove them.
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
TipMoney *int64 // optional tip amount in pence
CustomerID string // Square customer ID for card-on-file payments