fix: fresh-review round — 2FA deliverability, disable re-verification, GDPR batch scrub, dispute alerting, docs accuracy
Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
This commit is contained in:
@@ -4187,7 +4187,12 @@ func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
|
||||
PaymentType: "full",
|
||||
PaymentMethod: strPtr("saved_card"),
|
||||
UserSavedCardID: &cardID,
|
||||
IdempotencyKey: "saved-card-uuid-0001",
|
||||
// Distinct from TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup's
|
||||
// keys: both tests share the package-level singleton mock (SquareClient
|
||||
// is set once in TestMain), and the mock now mirrors Square's body-aware
|
||||
// dedup — reusing a retained key with a different source_id returns
|
||||
// IDEMPOTENCY_KEY_REUSED (same as real Square).
|
||||
IdempotencyKey: "saved-card-uuid-retry-0001",
|
||||
}
|
||||
|
||||
// First charge.
|
||||
|
||||
@@ -511,9 +511,30 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
|
||||
// 5xx / ambiguous) leaves the row pending — the charge may still have completed
|
||||
// at Square. The second return value is the Square payment id of the completed
|
||||
// payment ("" otherwise), written back on a rescue.
|
||||
//
|
||||
// ENV CONTRACT (finding 4): the sweep and the charge process MUST run with the
|
||||
// SAME SQUARE_ENVIRONMENT and SQUARE_LOCATION_ID. The stored snapshot embeds
|
||||
// the location_id used at charge time; if the env/location changes between a
|
||||
// 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.
|
||||
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
|
||||
// and the minimal body (key + source + amount) is rebuilt. The rebuilt body
|
||||
// omits reference_id / customer_id / note / buyer_email_address that the
|
||||
// original charge carried, so for a RETAINED key real Square compares the
|
||||
// WHOLE body, sees the difference and returns IDEMPOTENCY_KEY_REUSED
|
||||
// (stranding the row pending) while the dev mock — which compares only the
|
||||
// source — returns the original payment (rescuing it). Both are money-safe;
|
||||
// the divergence is dev-vs-prod OBSERVABILITY only, and pre-launch there
|
||||
// are no prod legacy rows (every charge now stores its snapshot), so the
|
||||
// fallback semantics are deliberately left unchanged.
|
||||
fallbackBody := false
|
||||
if len(bytes.TrimSpace(snapshot)) == 0 {
|
||||
fallbackBody = true
|
||||
// Legacy row without a stored request snapshot — rebuild the minimal
|
||||
// identical body (key + source + amount) exactly as the pre-snapshot
|
||||
// replay did. Such rows can still be reconciled as long as the stored
|
||||
@@ -530,6 +551,10 @@ 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).
|
||||
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
|
||||
if err != nil {
|
||||
if errors.Is(err, square.ErrReplayKeyNotRetained) {
|
||||
@@ -537,7 +562,16 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
return staleReconcileDefinitivelyFailed, ""
|
||||
}
|
||||
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
if fallbackBody {
|
||||
// Snapshot-less fallback row: the minimal rebuilt body cannot be
|
||||
// identical to the original charge, so real Square rejects the
|
||||
// retained-key replay while the dev mock (source-aware only)
|
||||
// rescues it. The stranded row would otherwise be invisible
|
||||
// until the 24h blind-fail — surface it now (finding 2).
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
|
||||
} else {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
}
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err)
|
||||
@@ -731,7 +765,11 @@ const staleTerminalCheckoutAge = 1 * time.Hour
|
||||
// payment_status enum has no 'cancelled' value, and 'failed' is the same
|
||||
// terminal state the stale-pending sweep uses, blocking the till
|
||||
// pending-retry path); a checkout that completed during the cancel window
|
||||
// leaves the sale pending for the poll handler to record.
|
||||
// (or was already COMPLETED when first checked) is RECORDED by the sweep —
|
||||
// the sale is marked 'completed' with the returned square_payment_id, the
|
||||
// till mirror of the booking path's recordUntrackedTerminalPayment (a
|
||||
// never-polled sale would otherwise stay invisible in till reporting until
|
||||
// the 24h blind-fail).
|
||||
//
|
||||
// Each row is checked at Square FIRST and only cancelled when the checkout is
|
||||
// provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never
|
||||
@@ -822,13 +860,15 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
case rErr == nil && recheck.Status == "COMPLETED":
|
||||
// The customer completed the payment during the cancel window.
|
||||
// Record it if it was never polled/recorded — a COMPLETED
|
||||
// checkout must not stay an untracked charge (H4).
|
||||
// checkout must not stay an untracked charge (H4). Booking
|
||||
// checkouts record full payments rows; till-sale checkouts
|
||||
// record the sale row (finding 3).
|
||||
if r.Kind == "terminal_checkout" {
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, recheck) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
|
||||
} else if recordUntrackedTillSalePayment(ctx, r.RowID, recheck) {
|
||||
resolved++
|
||||
}
|
||||
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
|
||||
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
|
||||
@@ -860,9 +900,11 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
// The till poll handler records it — leave the sale pending.
|
||||
log.Printf("Terminal checkout %s already COMPLETED at Square — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
|
||||
} else if recordUntrackedTillSalePayment(ctx, r.RowID, pr) {
|
||||
// A never-polled COMPLETED till-sale checkout must not stay
|
||||
// pending until the 24h blind-fail (finding 3) — the charge is
|
||||
// real, so the sale is recorded completed with the payment id.
|
||||
resolved++
|
||||
}
|
||||
case isTerminalCheckoutError(gErr):
|
||||
// The checkout is cancelled / cancel-requested / expired at Square.
|
||||
@@ -1088,6 +1130,44 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s
|
||||
return true
|
||||
}
|
||||
|
||||
// recordUntrackedTillSalePayment records a stale card-machine till sale whose
|
||||
// checkout COMPLETED at Square but was never polled/recorded: the sale is
|
||||
// marked 'completed' with the returned square_payment_id written back. A
|
||||
// never-polled COMPLETED checkout would otherwise leave the sale pending until
|
||||
// the 24h blind-fail — the charge is real but invisible in till reporting
|
||||
// (money-safe: the gift card was funded pre-charge, a blind-fail never claws
|
||||
// back, and failed rows reject retries — but the till reporting would be wrong,
|
||||
// finding 3). There is no split/deposit concept for till sales — a single
|
||||
// completed row, exactly the update the till poll handler (GetTillCheckoutStatus)
|
||||
// performs. The WHERE status = 'pending' guard makes it safe against a
|
||||
// concurrent poll: only one of them wins the update, the loser sees 0 rows and
|
||||
// backs off. Returns true when the sale was updated; false when the checkout
|
||||
// carries no Square payment id (the sale is left PENDING — never blind-failed
|
||||
// — with a CRITICAL log for manual reconciliation) or the sale was already
|
||||
// resolved by someone else.
|
||||
func recordUntrackedTillSalePayment(ctx context.Context, saleID string, pr *square.PaymentResult) bool {
|
||||
if pr == nil || pr.SquarePayID == "" {
|
||||
log.Printf("CRITICAL: terminal checkout is COMPLETED at Square but carries no Square payment ID — cannot record till sale %s — leaving it PENDING — MANUAL RECONCILIATION REQUIRED", saleID)
|
||||
return false
|
||||
}
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
UPDATE till_sales SET status = 'completed', square_payment_id = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
`, pr.SquarePayID, saleID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to record untracked terminal till sale %s as completed: %v", saleID, err)
|
||||
return false
|
||||
}
|
||||
if int(tag.RowsAffected()) == 0 {
|
||||
// A concurrent poll (or a prior sweep run) already recorded the sale —
|
||||
// nothing left for this run to do.
|
||||
log.Printf("Terminal till sale %s was already resolved — skipping untracked terminal completion record", saleID)
|
||||
return false
|
||||
}
|
||||
log.Printf("CRITICAL: recorded untracked terminal till-sale charge %s (sale %s) from a stale checkout — sale marked completed (never polled by the frontend)", pr.SquarePayID, saleID)
|
||||
return true
|
||||
}
|
||||
|
||||
// isTerminalCheckoutError reports whether a GetCheckout error proves the
|
||||
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
|
||||
// for a still-live checkout and surfaces a definitively CANCELED status as a
|
||||
|
||||
@@ -237,6 +237,22 @@ func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID st
|
||||
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
||||
}
|
||||
|
||||
// noPaymentIDCompletedClient makes one checkout look COMPLETED at Square with
|
||||
// NO Square payment id — the record-impossible condition the till-sale sweep
|
||||
// must leave pending (never blind-fail) — while delegating everything else to
|
||||
// the real mock.
|
||||
type noPaymentIDCompletedClient struct {
|
||||
square.SquareClient
|
||||
checkoutID string
|
||||
}
|
||||
|
||||
func (c *noPaymentIDCompletedClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
||||
if checkoutID == c.checkoutID {
|
||||
return &square.PaymentResult{Status: "COMPLETED", Amount: 5000}, nil
|
||||
}
|
||||
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SweepStalePendingPayments — lost-response reconcile by idempotency key
|
||||
// =============================================================================
|
||||
@@ -833,11 +849,14 @@ func TestSweepStaleTerminalCheckouts_CancelsStalePending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStaleTerminalCheckouts_LeavesCompletedAlone locks the conservative
|
||||
// F4 rule: a checkout that has COMPLETED at Square is never cancelled — the
|
||||
// poll handler records it; cancelling a completed checkout would orphan the
|
||||
// charge.
|
||||
func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) {
|
||||
// TestSweepStaleTerminalCheckouts_CompletedTillSale_Recorded locks the finding-3
|
||||
// fix: a stale card-machine till sale whose checkout has COMPLETED at Square
|
||||
// (never polled by the frontend) is RECORDED by the sweep — the sale is marked
|
||||
// 'completed' with the returned square_payment_id — instead of being left
|
||||
// pending for a poll handler that never runs (the 24h blind-fail would then
|
||||
// hide the real charge from till reporting). A COMPLETED checkout is never
|
||||
// cancelled.
|
||||
func TestSweepStaleTerminalCheckouts_CompletedTillSale_Recorded(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
@@ -873,20 +892,100 @@ func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) {
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
// Drop any other stale terminal rows left by parallel tests so the count is
|
||||
// deterministic.
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
|
||||
}
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
|
||||
}
|
||||
|
||||
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected the COMPLETED till-sale checkout recorded by the sweep, got %d resolutions", n)
|
||||
}
|
||||
|
||||
var status, sqPayID string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil {
|
||||
t.Fatalf("failed to query sale: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected a COMPLETED till-sale checkout's sale marked 'completed', got %q", status)
|
||||
}
|
||||
if sqPayID != "sqp_terminal_completed" {
|
||||
t.Errorf("expected square_payment_id written back on the recorded till sale, got %q", sqPayID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending
|
||||
// locks the finding-3 safety rule: a COMPLETED till-sale checkout whose payment
|
||||
// result carries NO Square payment id cannot be recorded, so the sale is left
|
||||
// PENDING with a CRITICAL log — never blind-failed (the charge may be real and
|
||||
// the blind-fail would hide it from till reporting).
|
||||
func TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
|
||||
var saleID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', 'chk_completed_no_payment_id', $1, NOW() - INTERVAL '2 hours', NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&saleID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed stale terminal sale: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &noPaymentIDCompletedClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_no_payment_id"}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pgxTx := db.TxFromContext(ctx)
|
||||
if pgxTx == nil {
|
||||
t.Fatal("no transaction in context")
|
||||
}
|
||||
if err := pgxTx.Commit(ctx); err != nil {
|
||||
t.Fatalf("failed to commit test tx: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
|
||||
}
|
||||
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
|
||||
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
|
||||
}
|
||||
|
||||
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", n)
|
||||
t.Errorf("expected a COMPLETED till-sale checkout with no Square payment id left unresolved, got %d resolutions", n)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
|
||||
var status, sqPayID string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil {
|
||||
t.Fatalf("failed to query sale: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status)
|
||||
t.Errorf("expected the unrecordable COMPLETED till-sale checkout left 'pending' (never blind-failed), got %q", status)
|
||||
}
|
||||
if sqPayID != "" {
|
||||
t.Errorf("expected no square_payment_id written on the unrecordable till sale, got %q", sqPayID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
@@ -13,17 +14,29 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// twoFactorEnforced reports whether 2FA is required for online card payments.
|
||||
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
|
||||
// (REQUIRE_2FA=false) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
|
||||
// stack (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values
|
||||
// are treated as production-enforced, so a mistyped env var can never silently
|
||||
// disarm the gate — main.go logs a startup warning for that misconfiguration.
|
||||
func twoFactorEnforced() bool {
|
||||
if os.Getenv("REQUIRE_2FA") == "false" {
|
||||
// require2FADisabled reports whether REQUIRE_2FA explicitly disables 2FA
|
||||
// enforcement. The parse is case-insensitive and alias-tolerant (false/0/off/no),
|
||||
// so a value like "False", "OFF" or "off" never silently leaves the gate ON.
|
||||
// Any other value — including empty or unknown — keeps enforcement ON
|
||||
// (fail-closed).
|
||||
func require2FADisabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("REQUIRE_2FA"))) {
|
||||
case "false", "0", "off", "no":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return !IsExplicitDevOrMockEnv()
|
||||
}
|
||||
|
||||
// twoFactorEnforced reports whether 2FA is required for online card payments.
|
||||
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
|
||||
// (REQUIRE_2FA=false/0/off/no, case-insensitive — see require2FADisabled) or
|
||||
// SQUARE_ENVIRONMENT explicitly selects the dev/mock stack
|
||||
// (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values are
|
||||
// treated as production-enforced, so a mistyped env var can never silently
|
||||
// disarm the gate — main.go logs a startup warning for that misconfiguration.
|
||||
func twoFactorEnforced() bool {
|
||||
return !require2FADisabled() && !IsExplicitDevOrMockEnv()
|
||||
}
|
||||
|
||||
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
||||
|
||||
@@ -49,6 +49,20 @@ func TestTwoFactorEnforced(t *testing.T) {
|
||||
{"require2fa_false_disables_prod", "false", "production", false},
|
||||
{"require2fa_false_disables_sandbox", "false", "sandbox", false},
|
||||
{"require2fa_false_disables_unknown_env", "false", "staging", false},
|
||||
// REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of
|
||||
// false/0/off/no (any casing) disables, nothing else does.
|
||||
{"require2fa_capitalized_false_disables", "False", "production", false},
|
||||
{"require2fa_uppercase_false_disables", "FALSE", "production", false},
|
||||
{"require2fa_zero_disables", "0", "production", false},
|
||||
{"require2fa_off_disables", "off", "production", false},
|
||||
{"require2fa_uppercase_off_disables", "OFF", "production", false},
|
||||
{"require2fa_no_disables", "no", "production", false},
|
||||
{"require2fa_true_stays_enforced", "true", "production", true},
|
||||
{"require2fa_one_stays_enforced", "1", "production", true},
|
||||
{"require2fa_yes_stays_enforced", "yes", "production", true},
|
||||
{"require2fa_on_stays_enforced", "on", "production", true},
|
||||
{"require2fa_unknown_stays_enforced", "enable", "production", true},
|
||||
{"require2fa_off_but_dev_never_enforced", "off", "mock", false},
|
||||
{"production_enforced", "", "production", true},
|
||||
{"sandbox_enforced", "", "sandbox", true},
|
||||
{"require2fa_true_prod_enforced", "true", "production", true},
|
||||
|
||||
@@ -2948,6 +2948,79 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupIdleAccounts_Scrubs2FAAndNotes verifies the GDPR erasure gap
|
||||
// closure for the BATCH path: accounts anonymized via anonymize_user(unnest(...))
|
||||
// must also have their 2FA columns and staff notes scrubbed, exactly like the
|
||||
// user-initiated DeleteAccountHandler path.
|
||||
func TestCleanupIdleAccounts_Scrubs2FAAndNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
// Create a user idle past the 2yr threshold (no balance) with 2FA + a note
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
last_login_at = NOW() - INTERVAL '3 years',
|
||||
notes = 'Staff note with PII for idle-account cleanup',
|
||||
two_factor_enabled = TRUE,
|
||||
two_factor_method = 'email',
|
||||
two_factor_pending_code_hash = 'batch-abc123',
|
||||
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set last_login_at + 2FA + notes: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup (batch path: SELECT anonymize_user(unnest($1::text[])))
|
||||
_, err = CleanupIdleAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the user was anonymized
|
||||
var email string
|
||||
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user email: %v", err)
|
||||
}
|
||||
if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") {
|
||||
t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email)
|
||||
}
|
||||
|
||||
// Verify 2FA columns + notes were scrubbed by the batch anonymization
|
||||
var notes interface{}
|
||||
var enabled bool
|
||||
var method, pendingHash, pendingExpires interface{}
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT notes, two_factor_enabled, two_factor_method,
|
||||
two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(¬es, &enabled, &method, &pendingHash, &pendingExpires)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query user after cleanup: %v", err)
|
||||
}
|
||||
if notes != nil {
|
||||
t.Errorf("expected users.notes to be NULL after idle-account cleanup, got %v", notes)
|
||||
}
|
||||
if enabled {
|
||||
t.Error("expected two_factor_enabled to be FALSE after idle-account cleanup")
|
||||
}
|
||||
if method != nil {
|
||||
t.Errorf("expected two_factor_method to be NULL after idle-account cleanup, got %v", method)
|
||||
}
|
||||
if pendingHash != nil {
|
||||
t.Errorf("expected two_factor_pending_code_hash to be NULL after idle-account cleanup, got %v", pendingHash)
|
||||
}
|
||||
if pendingExpires != nil {
|
||||
t.Errorf("expected two_factor_pending_code_expires to be NULL after idle-account cleanup, got %v", pendingExpires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupIdleAccounts_SkipActive verifies that recently active accounts
|
||||
// are NOT anonymized.
|
||||
func TestCleanupIdleAccounts_SkipActive(t *testing.T) {
|
||||
|
||||
@@ -21,24 +21,6 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// scrubAnonymizedUser2FA nulls the 2FA columns and staff notes that the SQL
|
||||
// anonymize_user() function does not scrub: it predates the 2FA columns and
|
||||
// intentionally preserves notes. A deleted user's live 2FA credential and any
|
||||
// PII in staff notes must not survive erasure, so run this inside the same
|
||||
// transaction as anonymize_user() to keep erasure atomic.
|
||||
func scrubAnonymizedUser2FA(ctx context.Context, q db.Querier, userID string) error {
|
||||
_, err := q.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET two_factor_enabled = FALSE,
|
||||
two_factor_method = NULL,
|
||||
two_factor_pending_code_hash = NULL,
|
||||
two_factor_pending_code_expires = NULL,
|
||||
notes = NULL
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DELETE /api/user/account
|
||||
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
@@ -169,6 +151,11 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// anonymize_user() is the single source of truth for erasure: it NULLs
|
||||
// the 2FA columns and staff notes in the same statement that anonymizes
|
||||
// the rest of the row, so every call site (this handler AND the
|
||||
// idle-account batch cleanup in scheduling.CleanupIdleAccounts) is
|
||||
// GDPR-clean without a separate Go-side scrub.
|
||||
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to anonymize user %s: %v", userID, err)
|
||||
@@ -176,15 +163,6 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// GDPR erasure gap: anonymize_user() leaves the 2FA columns and staff
|
||||
// notes on the row. Scrub them here, in the same transaction, so erasure
|
||||
// is atomic with the anonymization.
|
||||
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
|
||||
log.Printf("Failed to scrub 2FA fields for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit transaction for user anonymization: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -518,9 +518,9 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAnonymizeUser_Scrubs2FAAndNotes verifies the GDPR erasure gap closure:
|
||||
// anonymize_user() alone leaves the 2FA columns and staff notes on the row, so
|
||||
// the Go-side scrub (run in the same transaction by DeleteAccountHandler) must
|
||||
// null them.
|
||||
// anonymize_user() itself NULLs the 2FA columns and staff notes on the row, so
|
||||
// every call site (user-initiated delete AND idle-account batch cleanup) is
|
||||
// covered without a separate Go-side scrub.
|
||||
func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
@@ -548,11 +548,6 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
t.Fatalf("anonymize_user failed: %v", err)
|
||||
}
|
||||
|
||||
// Mirror DeleteAccountHandler: scrub after anonymize_user in the same tx.
|
||||
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
|
||||
t.Fatalf("scrubAnonymizedUser2FA failed: %v", err)
|
||||
}
|
||||
|
||||
var notes interface{}
|
||||
var enabled bool
|
||||
var method, pendingHash, pendingExpires interface{}
|
||||
@@ -582,8 +577,9 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDeleteAccount_Scrubs2FAAndNotes runs the full DeleteAccountHandler for a
|
||||
// user with 2FA enabled and staff notes, asserting the handler's transaction
|
||||
// scrubs both. Kept sequential (no t.Parallel) because the handler reads the
|
||||
// user with 2FA enabled and staff notes, asserting the end-to-end delete path
|
||||
// scrubs both (via anonymize_user(), which is now the single source of truth).
|
||||
// Kept sequential (no t.Parallel) because the handler reads the
|
||||
// process-global payments.SquareClient.
|
||||
func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
+238
-93
@@ -41,8 +41,8 @@ func generateTwoFACode() (string, error) {
|
||||
}
|
||||
|
||||
// hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB
|
||||
// stores only the digest; the plaintext code is only ever logged in unenforced
|
||||
// (dev) environments (see SetupTwoFAHandler). The digest is unsalted SHA-256 —
|
||||
// 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.
|
||||
func hashTwoFACode(code string) string {
|
||||
@@ -119,6 +119,56 @@ func twoFAResetAttempts(userID string) {
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
}
|
||||
|
||||
// deliverTwoFACode generates a fresh verification code, persists only its
|
||||
// SHA-256 hash plus the pending expiry (updating two_factor_method when method
|
||||
// is non-empty), resets any prior lockout, and logs the plaintext code.
|
||||
//
|
||||
// The [2FA] log line is the delivery channel — the loose-fake stand-in for the
|
||||
// not-yet-wired email/SMS transport (P6). The plaintext code is ALWAYS logged,
|
||||
// enforced and unenforced alike: in enforced (production) environments the
|
||||
// server log is the only way a code can reach the user, so an operator must
|
||||
// relay it out-of-band. Do not gate this log line on the environment — without
|
||||
// it, enforced-mode 2FA has no delivery path at all and every online saved-card
|
||||
// charge stays 403. The API response still only returns the code when 2FA is
|
||||
// unenforced (dev convenience). purpose labels the log line (e.g. "setup",
|
||||
// "disable 2FA").
|
||||
func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) {
|
||||
code, err := generateTwoFACode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expires := clock.Now().Add(twoFAPendingExpiry)
|
||||
if method != "" {
|
||||
_, err = db.Conn.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET two_factor_method = $2,
|
||||
two_factor_pending_code_hash = $3,
|
||||
two_factor_pending_code_expires = $4
|
||||
WHERE id = $1
|
||||
`, userID, method, hashTwoFACode(code), expires)
|
||||
} else {
|
||||
_, err = db.Conn.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET two_factor_pending_code_hash = $2,
|
||||
two_factor_pending_code_expires = $3
|
||||
WHERE id = $1
|
||||
`, userID, hashTwoFACode(code), expires)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// A fresh code invalidates any prior lockout state.
|
||||
twoFAResetAttempts(userID)
|
||||
|
||||
label := method
|
||||
if label == "" {
|
||||
label = purpose
|
||||
}
|
||||
log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
type TwoFAStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Method *string `json:"method"`
|
||||
@@ -161,10 +211,13 @@ type TwoFASetupRequest struct {
|
||||
|
||||
// POST /api/user/2fa/setup
|
||||
// Generates a verification code and stores only its SHA-256 hash plus a
|
||||
// 10-minute expiry in the pending columns. The code itself is delivered by
|
||||
// logging it with a [2FA] prefix — a loose fake for the not-yet-wired email/SMS
|
||||
// transport. When 2FA is not enforced (dev), the code is also returned in the
|
||||
// response so the flow is testable without reading backend logs.
|
||||
// 10-minute expiry in the pending columns. The code is delivered by logging it
|
||||
// with a [2FA] prefix — the loose-fake stand-in for the not-yet-wired email/SMS
|
||||
// transport (P6). The plaintext code is ALWAYS logged, enforced and unenforced
|
||||
// alike: in enforced (production) environments the server log is the only
|
||||
// delivery channel, so an operator must relay the code to the user out-of-band.
|
||||
// When 2FA is not enforced (dev), the code is also returned in the response so
|
||||
// the flow is testable without reading backend logs.
|
||||
func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
@@ -194,39 +247,16 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateTwoFACode()
|
||||
if err != nil {
|
||||
log.Printf("failed to generate 2FA code for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
expires := clock.Now().Add(twoFAPendingExpiry)
|
||||
_, err = db.Conn.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET two_factor_method = $2,
|
||||
two_factor_pending_code_hash = $3,
|
||||
two_factor_pending_code_expires = $4
|
||||
WHERE id = $1
|
||||
`, userID, req.Method, hashTwoFACode(code), expires)
|
||||
// Deliver a fresh code via the shared setup mechanism: generate, persist
|
||||
// only the hash + expiry, reset any prior lockout, and log the plaintext
|
||||
// code (the [2FA] log channel — see deliverTwoFACode).
|
||||
code, err := deliverTwoFACode(r, userID, req.Method, "setup")
|
||||
if err != nil {
|
||||
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// A fresh code invalidates any prior lockout state.
|
||||
twoFAResetAttempts(userID)
|
||||
|
||||
if !twoFARequired() {
|
||||
// Dev-only convenience: unenforced environments log the plaintext code
|
||||
// (loose fake delivery). NEVER log it when enforced — a production
|
||||
// misconfiguration must not leak verification codes to stdout.
|
||||
log.Printf("[2FA] verification code for user %s (%s): %s", userID, req.Method, code)
|
||||
} else {
|
||||
log.Printf("[2FA] 2FA code generated for user %s (delivery channel: %s — NOT SENT, fake delivery)", userID, req.Method)
|
||||
}
|
||||
|
||||
resp := map[string]any{"message": "Code sent"}
|
||||
if !twoFARequired() {
|
||||
// Dev convenience: unenforced environments return the code so the
|
||||
@@ -242,6 +272,77 @@ type TwoFAVerifyRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// twoFACodeCheckResult classifies checkTwoFACode's outcome so callers can map
|
||||
// it to the correct HTTP status.
|
||||
type twoFACodeCheckResult int
|
||||
|
||||
const (
|
||||
twoFACodeOK twoFACodeCheckResult = iota
|
||||
twoFACodeIncorrect
|
||||
twoFACodeLockedOut
|
||||
twoFACodeMissingOrExpired
|
||||
)
|
||||
|
||||
// checkTwoFACode verifies the submitted code against the user's stored pending
|
||||
// code under the per-user brute-force lockout, shared by VerifyTwoFAHandler and
|
||||
// DisableTwoFAHandler. The caller must hold st.mu (from twoFAAttemptStateFor)
|
||||
// so concurrent attempts from the same user cannot race the limit check. A
|
||||
// correct code resets the attempt counter and returns twoFACodeOK. An incorrect
|
||||
// code increments the counter and, on the 5th consecutive failure, invalidates
|
||||
// the pending code (lockout). A missing or expired pending code returns
|
||||
// twoFACodeMissingOrExpired. The returned error is non-nil only for DB failures
|
||||
// (callers return 500); a lockout's pending-code invalidation failure is logged
|
||||
// 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.lastAt = now
|
||||
}
|
||||
if st.count >= twoFAMaxAttempts {
|
||||
return twoFACodeLockedOut, nil
|
||||
}
|
||||
|
||||
var pendingHash sql.NullString
|
||||
var pendingExpires sql.NullTime
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&pendingHash, &pendingExpires)
|
||||
if err != nil {
|
||||
return twoFACodeLockedOut, err
|
||||
}
|
||||
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
||||
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++
|
||||
st.lastAt = clock.Now()
|
||||
if st.count >= 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(), `
|
||||
UPDATE users
|
||||
SET two_factor_pending_code_hash = NULL,
|
||||
two_factor_pending_code_expires = NULL
|
||||
WHERE id = $1
|
||||
`, userID); err != nil {
|
||||
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
|
||||
}
|
||||
return twoFACodeLockedOut, nil
|
||||
}
|
||||
return twoFACodeIncorrect, nil
|
||||
}
|
||||
|
||||
// Success: clear the attempt counter before the caller performs its action.
|
||||
st.count = 0
|
||||
st.lastAt = clock.Now()
|
||||
twoFAResetAttempts(userID)
|
||||
return twoFACodeOK, nil
|
||||
}
|
||||
|
||||
// POST /api/user/2fa/verify
|
||||
// Confirms the pending code (SHA-256, timing-safe, not expired) and flips
|
||||
// two_factor_enabled on. When 2FA is not enforced (dev) any code — including an
|
||||
@@ -271,67 +372,31 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforced path — brute-force resistant. The attempt counter is per-user and
|
||||
// in-memory (no schema change); after 5 consecutive failures the pending
|
||||
// code is invalidated and further attempts get 429 until a new code is
|
||||
// requested via setup. The per-user mutex serializes the critical section so
|
||||
// concurrent attempts cannot race the limit.
|
||||
// Enforced path — brute-force resistant (see checkTwoFACode): the per-user
|
||||
// mutex serializes the critical section so concurrent attempts cannot race
|
||||
// the limit; after 5 consecutive failures the pending code is invalidated
|
||||
// and further attempts get 429 until a new code is requested via setup.
|
||||
st := twoFAAttemptStateFor(userID)
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
|
||||
st.count = 0
|
||||
st.lastAt = now
|
||||
}
|
||||
if st.count >= twoFAMaxAttempts {
|
||||
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var pendingHash sql.NullString
|
||||
var pendingExpires sql.NullTime
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&pendingHash, &pendingExpires)
|
||||
result, err := checkTwoFACode(r, userID, st, req.Code)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch 2FA pending code for user %s: %v", userID, err)
|
||||
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
|
||||
switch result {
|
||||
case twoFACodeIncorrect:
|
||||
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
||||
return
|
||||
case twoFACodeLockedOut:
|
||||
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
case twoFACodeMissingOrExpired:
|
||||
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 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(req.Code)), []byte(pendingHash.String)) != 1 {
|
||||
st.count++
|
||||
st.lastAt = clock.Now()
|
||||
if st.count >= 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(), `
|
||||
UPDATE users
|
||||
SET two_factor_pending_code_hash = NULL,
|
||||
two_factor_pending_code_expires = NULL
|
||||
WHERE id = $1
|
||||
`, userID); err != nil {
|
||||
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
|
||||
}
|
||||
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Success: clear the attempt counter before enabling 2FA.
|
||||
st.count = 0
|
||||
st.lastAt = clock.Now()
|
||||
twoFAResetAttempts(userID)
|
||||
|
||||
if err := enableTwoFA(r, userID); err != nil {
|
||||
log.Printf("failed to enable 2FA for user %s: %v", userID, err)
|
||||
@@ -366,8 +431,15 @@ type TwoFADisableRequest struct {
|
||||
|
||||
// POST /api/user/2fa/disable
|
||||
// Turns 2FA off and clears method + pending fields for the authenticated user.
|
||||
// The code field is accepted but ignored — a documented loose-fake simplification
|
||||
// until the real SCA flow requires re-authentication to disable.
|
||||
//
|
||||
// Disabling 2FA lifts the SCA stand-in gate on saved-card charges, so in
|
||||
// enforced environments a verification code is required — a password-only
|
||||
// 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.
|
||||
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
@@ -375,10 +447,89 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Body is optional; decode leniently so an empty body disables cleanly.
|
||||
// Body is optional; decode leniently so an empty body still works in
|
||||
// unenforced (dev) environments.
|
||||
var req TwoFADisableRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if !twoFARequired() {
|
||||
// Dev bypass: no re-verification in unenforced environments.
|
||||
if err := disableTwoFA(r, userID); err != nil {
|
||||
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Enforced path. The per-user mutex serializes the whole critical section
|
||||
// (fresh-code generation + code check) so concurrent requests cannot race
|
||||
// the lockout counter.
|
||||
st := twoFAAttemptStateFor(userID)
|
||||
st.mu.Lock()
|
||||
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.
|
||||
if err := ensurePendingTwoFACode(r, userID); err != nil {
|
||||
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case twoFACodeIncorrect:
|
||||
http.Error(w, "incorrect verification code", http.StatusBadRequest)
|
||||
return
|
||||
case twoFACodeLockedOut:
|
||||
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
|
||||
return
|
||||
case twoFACodeMissingOrExpired:
|
||||
// ensurePendingTwoFACode just guaranteed a valid pending code; defensive.
|
||||
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := disableTwoFA(r, userID); err != nil {
|
||||
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 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. 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) error {
|
||||
var pendingHash sql.NullString
|
||||
var pendingExpires sql.NullTime
|
||||
err := db.Conn.QueryRow(r.Context(), `
|
||||
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&pendingHash, &pendingExpires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
|
||||
return nil
|
||||
}
|
||||
_, err = deliverTwoFACode(r, userID, "", "disable 2FA")
|
||||
return err
|
||||
}
|
||||
|
||||
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
|
||||
func disableTwoFA(r *http.Request, userID string) error {
|
||||
_, err := db.Conn.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET two_factor_enabled = false,
|
||||
@@ -387,11 +538,5 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
||||
two_factor_pending_code_expires = NULL
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -241,22 +241,125 @@ func TestTwoFAVerify_Unenforced_AnyCodeSucceeds(t *testing.T) {
|
||||
require.True(t, enabled)
|
||||
}
|
||||
|
||||
func TestTwoFADisable(t *testing.T) {
|
||||
// TestTwoFADisable_CorrectCode verifies that disabling in an enforced env
|
||||
// requires the pending code: the correct code clears the flag, method and
|
||||
// pending fields.
|
||||
func TestTwoFADisable_CorrectCode(t *testing.T) {
|
||||
twofaEnvEnforced(t)
|
||||
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)
|
||||
seedPendingTwoFA(t, ctx, tx, userID, "123456")
|
||||
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", nil, userID)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
var enabled bool
|
||||
var method sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method FROM users WHERE id = $1", userID).Scan(&enabled, &method))
|
||||
var pendingHash sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &method, &pendingHash))
|
||||
require.False(t, enabled, "disable must clear two_factor_enabled")
|
||||
require.False(t, method.Valid, "disable must clear the method")
|
||||
require.False(t, pendingHash.Valid, "disable must clear the pending code")
|
||||
}
|
||||
|
||||
// TestTwoFADisable_WrongCode verifies that a wrong code leaves 2FA enabled:
|
||||
// the gate cannot be lifted with the password alone.
|
||||
func TestTwoFADisable_WrongCode(t *testing.T) {
|
||||
twofaEnvEnforced(t)
|
||||
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)
|
||||
seedPendingTwoFA(t, ctx, tx, userID, "123456")
|
||||
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{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.True(t, enabled, "wrong code must not disable 2FA")
|
||||
}
|
||||
|
||||
// TestTwoFADisable_NoPendingCode_GeneratesFreshCode verifies that disabling
|
||||
// with no valid pending code delivers a fresh one via the [2FA] log channel and
|
||||
// requires it before clearing the flag.
|
||||
func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(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)
|
||||
|
||||
// No pending code exists: the handler must generate + log a fresh code and
|
||||
// reject the (empty) submission.
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "disable must log the fresh code as the delivery channel")
|
||||
|
||||
var pendingHash sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
||||
require.True(t, pendingHash.Valid, "disable must persist a fresh pending code when none existed")
|
||||
|
||||
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, "fresh code must be verified before 2FA can be disabled")
|
||||
}
|
||||
|
||||
// TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares
|
||||
// the 5-attempt lockout: 4 wrong codes 400, the 5th 429s and invalidates the
|
||||
// pending code.
|
||||
func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
|
||||
twofaEnvEnforced(t)
|
||||
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)
|
||||
seedPendingTwoFA(t, ctx, tx, userID, "123456")
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, 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: "999999"}, userID)
|
||||
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
|
||||
require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.")
|
||||
|
||||
var pendingHash sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
||||
require.False(t, pendingHash.Valid, "lockout must invalidate the pending code")
|
||||
|
||||
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, "locked-out user must still have 2FA enabled")
|
||||
}
|
||||
|
||||
// TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an
|
||||
// unenforced env disabling works with no code at all.
|
||||
func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) {
|
||||
twofaEnvUnenforced(t)
|
||||
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)
|
||||
|
||||
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, 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.False(t, enabled, "dev bypass must disable without a code")
|
||||
}
|
||||
|
||||
func TestTwoFA_Unauthenticated(t *testing.T) {
|
||||
@@ -335,25 +438,25 @@ func TestTwoFASetup_Enforced_NoCodeInResponse(t *testing.T) {
|
||||
require.False(t, hasCode, "enforced setup must NOT return the code in the response")
|
||||
}
|
||||
|
||||
func TestTwoFASetup_CodeLoggedOnlyWhenUnenforced(t *testing.T) {
|
||||
func TestTwoFASetup_CodeAlwaysLoggedAsDeliveryChannel(t *testing.T) {
|
||||
// Capture the standard logger so we can assert on what setup logs.
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
t.Cleanup(func() { log.SetOutput(os.Stderr) })
|
||||
|
||||
// Enforced: the [2FA] log line must carry the delivery note but never the
|
||||
// plaintext code — a production misconfig must not leak codes to stdout.
|
||||
// Enforced (production): the plaintext code MUST be logged — the [2FA] log
|
||||
// line is the only delivery channel until email/SMS lands, and an operator
|
||||
// relays it to the user out-of-band. Without it, enforced-mode 2FA is a
|
||||
// dead-end (every online saved-card charge stays 403).
|
||||
twofaEnvEnforced(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
out := buf.String()
|
||||
require.Contains(t, out, "NOT SENT, fake delivery")
|
||||
require.NotContains(t, out, "verification code for user", "enforced setup must NOT log the plaintext code")
|
||||
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "enforced setup must log the plaintext code as the delivery channel")
|
||||
|
||||
// Unenforced (dev): the plaintext code IS logged for the loose-fake flow.
|
||||
// Unenforced (dev): the plaintext code is also logged for the loose-fake flow.
|
||||
buf.Reset()
|
||||
twofaEnvUnenforced(t)
|
||||
ctx2, tx2 := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -32,7 +32,9 @@ type SquareWebhookEvent struct {
|
||||
// event IDs. It is a FAST-PATH cache only: the persistent source of truth is
|
||||
// the square_webhook_events table (see HandleSquareWebhook). It lets a replayed
|
||||
// delivery be dropped without a DB round-trip, but a restart clears it — the DB
|
||||
// row is what guarantees at-most-once processing across restarts.
|
||||
// row is what keeps delivery at-least-once across restarts (a replay after a
|
||||
// crash is re-dispatched and absorbed by the idempotent handlers, per the
|
||||
// dispatch-first ordering in HandleSquareWebhook).
|
||||
type squareWebhookDedup struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
@@ -598,8 +600,13 @@ func clawbackOneTillSale(saleID, itemType string, itemID sql.NullString, totalAm
|
||||
// whose charge definitively failed, in the SAME transaction as the failed mark
|
||||
// (claim-first): a created card is deleted (with its purchase transaction) and
|
||||
// any immediate redeem-to-account credit reversed; a topped-up card has the
|
||||
// amount subtracted back out and its top-up transaction removed. SQL mirrors
|
||||
// handlers/payments/till.go revertGiftCardFunding.
|
||||
// amount subtracted back out and its top-up transaction removed.
|
||||
//
|
||||
// This is a byte-for-byte copy of revertGiftCardFunding in
|
||||
// handlers/payments/till.go (the webhook path cannot reuse the till handler's
|
||||
// signature), and the two MUST be kept in sync: a fix or schema change applied
|
||||
// to only one silently diverges the sweep's clawback from the webhook's. Keep
|
||||
// the SQL and the CRITICAL log lines identical in both.
|
||||
func revertTillSaleGiftCardFunding(action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
|
||||
tx, err := db.Conn.Begin(context.Background())
|
||||
if err != nil {
|
||||
@@ -732,18 +739,29 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
// VARCHAR(192) column width. An over-long reason would fail the disputes
|
||||
// INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so
|
||||
// Square would retry forever — truncating lets the event succeed instead.
|
||||
// VARCHAR(192) counts CHARACTERS, not bytes, so truncation must slice on a rune
|
||||
// boundary: byte-slicing (reason[:192]) can split a multi-byte UTF-8 rune and
|
||||
// store invalid UTF-8 (which Postgres rejects), failing the INSERT the same way
|
||||
// an over-long reason would. Slicing the []rune form keeps the stored reason a
|
||||
// valid, at-most-192-character UTF-8 string.
|
||||
func truncateDisputeReason(reason string) string {
|
||||
if len(reason) > 192 {
|
||||
return reason[:192]
|
||||
runes := []rune(reason)
|
||||
if len(runes) > 192 {
|
||||
return string(runes[:192])
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
// handleDisputeCreated records a newly opened dispute: inserts the disputes row
|
||||
// and surfaces a critical_payment_log admin notification so the owner sees the
|
||||
// chargeback in-app. 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).
|
||||
// chargeback in-app. A disputed Square payment with NO local payments row (a
|
||||
// Dashboard-initiated charge, a mismatched Square id, or a deleted/erased row)
|
||||
// cannot be reconciled to a booking — no disputes row is written — but the
|
||||
// admin notification is STILL raised with booking_id NULL, because there is no
|
||||
// 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).
|
||||
func handleDisputeCreated(data json.RawMessage) error {
|
||||
var env squareWebhookData
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
@@ -761,7 +779,15 @@ func handleDisputeCreated(data json.RawMessage) error {
|
||||
}
|
||||
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
|
||||
if !paymentFound {
|
||||
log.Printf("[SQUARE-WEBHOOK] dispute.created: no local payment for square payment %q — dispute %s not recorded", squarePaymentID, dispute.ID)
|
||||
// Untracked chargeback: no local payments row for this Square charge
|
||||
// (Dashboard-initiated, mismatched Square payment id, or a deleted/erased
|
||||
// row). There is NO sweep fallback for disputes — this notification is
|
||||
// the only in-app trace the owner gets that Square is clawing back funds,
|
||||
// so it must never be skipped. booking_id stays NULL; the helper's dedup
|
||||
// guard keeps ONE unacknowledged row until the owner acts on it. 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("")
|
||||
return nil
|
||||
}
|
||||
amount := squareMoneyToAmount(dispute.AmountMoney)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils/fixtures"
|
||||
@@ -315,6 +316,15 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
|
||||
// insertCriticalPaymentNotification dedups on unacknowledged rows per
|
||||
// (reason, booking_id), so a NULL-booking notification left unacknowledged
|
||||
// by an earlier test would mask this test's assertion. Acknowledge any
|
||||
// stragglers first.
|
||||
if _, err := db.Conn.Exec(context.Background(),
|
||||
"UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil {
|
||||
t.Fatalf("failed to acknowledge prior critical notifications: %v", err)
|
||||
}
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "dispute.created",
|
||||
EventID: "evt_dispute_orphan_1",
|
||||
@@ -336,6 +346,7 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// No local payment to reconcile against — no disputes row can be written.
|
||||
var n int
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_orphan_1'").Scan(&n); err != nil {
|
||||
@@ -344,6 +355,23 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
|
||||
if n != 0 {
|
||||
t.Errorf("expected no disputes row for an unknown square payment, got %d", n)
|
||||
}
|
||||
// ...but the chargeback MUST still surface in-app: a dispute on a payment
|
||||
// with no local row is exactly the silent money-loss path this guards (no
|
||||
// sweep fallback, no reconciliable booking). One unacknowledged
|
||||
// NULL-booking critical notification must exist.
|
||||
var unack int
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND acknowledged_at IS NULL").Scan(&unack); err != nil {
|
||||
t.Fatalf("failed to count unacknowledged critical notifications: %v", err)
|
||||
}
|
||||
if unack != 1 {
|
||||
t.Errorf("expected exactly 1 unacknowledged NULL-booking critical_payment_log notification, got %d", unack)
|
||||
}
|
||||
// The dedup row still commits: the handler returned nil, so Square's retry
|
||||
// is acknowledged 200 rather than re-dispatched forever.
|
||||
if got := countWebhookEvents(t, event.EventID); got != 1 {
|
||||
t.Errorf("expected 1 dedup row for the untracked dispute, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
|
||||
@@ -390,6 +418,52 @@ func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_DisputeCreated_Utf8Reason_StoredValid delivers a dispute whose
|
||||
// reason is long enough that the old byte-truncation (reason[:192]) would have
|
||||
// split a 3-byte rune and stored invalid UTF-8 — which Postgres rejects,
|
||||
// failing the INSERT and making Square retry forever. Rune-safe truncation must
|
||||
// store a valid, at-most-192-character string.
|
||||
func TestWebhook_DisputeCreated_Utf8Reason_StoredValid(t *testing.T) {
|
||||
const squarePaymentID = "sqp_dispute_utf8"
|
||||
_ = createWebhookTestPayment(t, squarePaymentID, "completed")
|
||||
|
||||
// 300 three-byte runes = 900 bytes, far past VARCHAR(192).
|
||||
reason := strings.Repeat("界", 300)
|
||||
event := SquareWebhookEvent{
|
||||
Type: "dispute.created",
|
||||
EventID: "evt_dispute_utf8_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "dispute",
|
||||
"id": "dts_utf8_1",
|
||||
"object": {
|
||||
"dispute": {
|
||||
"id": "dts_utf8_1",
|
||||
"state": "UNDER_REVIEW",
|
||||
"amount_money": {"amount": 1234, "currency": "GBP"},
|
||||
"reason": "` + reason + `",
|
||||
"disputed_payment": {"payment_id": "` + squarePaymentID + `"}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
w := deliverWebhook(t, event)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var stored string
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
"SELECT reason FROM disputes WHERE square_dispute_id = 'dts_utf8_1'").Scan(&stored); err != nil {
|
||||
t.Fatalf("expected a disputes row to be inserted, got: %v", err)
|
||||
}
|
||||
if !utf8.ValidString(stored) {
|
||||
t.Errorf("expected stored reason to be valid UTF-8, got %q", stored)
|
||||
}
|
||||
if r := []rune(stored); len(r) != 192 {
|
||||
t.Errorf("expected stored reason to be exactly 192 characters, got %d", len(r))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dispute handling — dispute.state.updated
|
||||
// =============================================================================
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"crussell/db"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -101,6 +102,37 @@ func TestVerifySquareSignature_TamperedBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateDisputeReason_RuneSafe(t *testing.T) {
|
||||
// 191 ASCII bytes + a 4-byte emoji: byte-slicing at 192 would split the
|
||||
// emoji into an incomplete rune (invalid UTF-8). Rune-safe truncation must
|
||||
// keep the whole emoji and stay valid UTF-8.
|
||||
edge := strings.Repeat("a", 191) + "\U0001F600"
|
||||
got := truncateDisputeReason(edge)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("expected valid UTF-8 after truncation, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\U0001F600") {
|
||||
t.Errorf("expected the 4-byte rune to be preserved intact, got %q", got)
|
||||
}
|
||||
if len(got) != 195 { // 191 ASCII + 4-byte emoji
|
||||
t.Errorf("expected 195 bytes (191 ASCII + 4-byte emoji), got %d", len(got))
|
||||
}
|
||||
|
||||
// An over-long multi-byte reason truncates to exactly 192 runes.
|
||||
got = truncateDisputeReason(strings.Repeat("界", 300))
|
||||
if r := []rune(got); len(r) != 192 {
|
||||
t.Errorf("expected exactly 192 runes after truncation, got %d", len(r))
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("expected valid UTF-8 after truncation, got %q", got)
|
||||
}
|
||||
|
||||
// Short and ASCII reasons pass through untouched.
|
||||
if got := truncateDisputeReason("NO_KNOWLEDGE"); got != "NO_KNOWLEDGE" {
|
||||
t.Errorf("expected short reason unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Integration tests — HandleSquareWebhook
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user