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

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

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

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

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

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

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

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

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 67cf5b9a45
commit 5cc5a7f6d2
28 changed files with 3328 additions and 208 deletions
@@ -0,0 +1,397 @@
//go:build test
package webhooks
// Round 7 regression tests — webhook money paths found at 0% coverage:
//
// (a) handleDisputeStateUpdated's findPaymentByDisputeID fallback when a
// dispute.state.updated payload carries no resolvable Square payment id.
// (b) clawbackOneTillSale's non-gift-card branch (a definitively-failed charge
// marks the till sale failed WITHOUT reversing any gift-card funding).
// (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch
// paths that must complete 200 + log without mutating state.
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"strings"
"testing"
"crussell/db"
"crussell/testutils/fixtures"
)
// =============================================================================
// Helpers
// =============================================================================
// createWebhookTestGiftCard inserts a funded gift card and returns its id.
func createWebhookTestGiftCard(t *testing.T, amount float64) string {
t.Helper()
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var id string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, 'SPV')
RETURNING id
`, amount, adminID).Scan(&id); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
return id
}
// createWebhookTestTillSale inserts a pending till sale with the given item
// type and item id (nil for a NULL item_id) and returns the sale id.
func createWebhookTestTillSale(t *testing.T, squarePaymentID, itemType string, itemID any) string {
t.Helper()
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var saleID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_payment_id, created_by, created_at, updated_at)
VALUES ($1, $2, 'webhook round7 test', 1, 40.00, 40.00, 'online_square', 'pending',
$3, $4, NOW(), NOW())
RETURNING id
`, itemType, itemID, squarePaymentID, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to create pending till sale: %v", err)
}
return saleID
}
// createWebhookTestBookingPayment creates a completed payment bound to a fresh
// booking and returns the local payment id and booking id. A booking-scoped
// payment makes the critical-notification assertions below attributable to one
// booking, so they cannot race with other tests' NULL-booking rows.
func createWebhookTestBookingPayment(t *testing.T) (payID, bookingID string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err = fixtures.CreateTestBooking(db.Conn, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
payID, err = fixtures.CreateTestPayment(db.Conn, bookingID, 10.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create test payment: %v", err)
}
return payID, bookingID
}
// countUnackedCriticalNotificationsForBooking returns the number of
// unacknowledged critical_payment_log notifications for a booking.
func countUnackedCriticalNotificationsForBooking(t *testing.T, bookingID string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM admin_notifications
WHERE reason = 'critical_payment_log' AND booking_id = $1 AND acknowledged_at IS NULL
`, bookingID).Scan(&n); err != nil {
t.Fatalf("failed to count critical notifications for booking %s: %v", bookingID, err)
}
return n
}
// =============================================================================
// (a) handleDisputeStateUpdated — findPaymentByDisputeID fallback
// =============================================================================
// TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow
// covers the findPaymentByDisputeID fallback: a dispute.state.updated event
// whose disputed_payment.payment_id is empty cannot resolve the payment via
// square_payment_id, so the handler recovers it from the seeded disputes row.
// A LOST state must still mark the payment failed and raise a CRITICAL
// notification — the fallback must not silently drop the chargeback.
func TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow(t *testing.T) {
payID, bookingID := createWebhookTestBookingPayment(t)
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason)
VALUES ('dts_fallback_1', $1, 'open', 12.34, 'NO_KNOWLEDGE')
`, payID); err != nil {
t.Fatalf("failed to seed dispute row: %v", err)
}
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_fallback_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_fallback_1",
"object": {
"dispute": {
"id": "dts_fallback_1",
"state": "LOST",
"amount_money": {"amount": 1234, "currency": "GBP"},
"reason": "NO_KNOWLEDGE",
"disputed_payment": {"payment_id": ""}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getDisputeStatus(t, "dts_fallback_1"); got != "lost" {
t.Errorf("expected dispute status 'lost', got %q", got)
}
if got := getPaymentStatus(t, payID); got != "failed" {
t.Errorf("expected payment 'failed' after lost dispute recovered via dispute row, got %q", got)
}
// The lost dispute is a CRITICAL money event — the admin notification
// centre must surface it for the payment's booking.
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 1 {
t.Errorf("expected 1 unacknowledged critical_payment_log notification for booking %s, got %d", bookingID, n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation
// covers the fallback's dead end: when neither the payload's (empty) Square
// payment id nor the disputes table yields a payment, the handler returns
// success WITHOUT mutating state — payment untouched, no disputes row, no
// notification — and still commits the dedup row (Square's retry is
// acknowledged 200, not re-dispatched forever).
func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation(t *testing.T) {
payID, bookingID := createWebhookTestBookingPayment(t)
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_no_row_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_no_dispute_row_1",
"object": {
"dispute": {
"id": "dts_no_dispute_row_1",
"state": "LOST",
"amount_money": {"amount": 1234, "currency": "GBP"},
"reason": "NO_KNOWLEDGE",
"disputed_payment": {"payment_id": ""}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_no_dispute_row_1'").Scan(&n); err != nil {
t.Fatalf("failed to count disputes: %v", err)
}
if n != 0 {
t.Errorf("expected no disputes row when the fallback finds no payment, got %d", n)
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected payment untouched ('completed') when the fallback finds no dispute, got %q", got)
}
// No critical notification: the handler returns before any insert.
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 0 {
t.Errorf("expected NO critical notification when the fallback finds no dispute, got %d", n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row (the no-op dispatch still commits), got %d", n)
}
}
// =============================================================================
// (b) clawbackOneTillSale — non-gift-card branch
// =============================================================================
// TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback
// covers the non-gift-card branch of clawbackOneTillSale: a retail_product
// till sale (item_type != "gift_card") funded by a definitively-failed Square
// charge is marked failed WITHOUT reversing any gift-card funding — even when
// the sale's item_id happens to reference a real, funded gift card (the LEFT
// JOIN would find it; the item_type guard must short-circuit before any
// reversal).
func TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback(t *testing.T) {
const squarePaymentID = "sqp_clawback_retail"
giftCardID := createWebhookTestGiftCard(t, 60.00)
saleID := createWebhookTestTillSale(t, squarePaymentID, "retail_product", giftCardID)
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getTillSaleStatus(t, saleID); got != "failed" {
t.Errorf("expected till sale 'failed', got %q", got)
}
// No gift-card balance was reversed: the card referenced by the sale's
// item_id keeps its full £60.00 funding.
total, remaining := getGiftCardFunding(t, giftCardID)
if total != 60.00 || remaining != 60.00 {
t.Errorf("expected gift card funding untouched (no clawback for a non-gift-card sale), got total=%v remaining=%v", total, remaining)
}
}
// TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback
// covers the nil-isCreate variant of the same branch: a gift_card till sale
// whose item_id points at NO gift card (dangling id) makes the LEFT JOIN yield
// a NULL is_create — the branch must still mark the sale failed without
// attempting any reversal.
func TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback(t *testing.T) {
const squarePaymentID = "sqp_clawback_dangling"
saleID := createWebhookTestTillSale(t, squarePaymentID, "gift_card", "GCMISSING001")
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getTillSaleStatus(t, saleID); got != "failed" {
t.Errorf("expected till sale 'failed', got %q", got)
}
// The missing card means there is nothing to claw back — and the handler
// must not error out: the webhook acknowledges 200 with a dedup row.
if n := countWebhookEvents(t, "evt_"+squarePaymentID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// =============================================================================
// (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch
// =============================================================================
// TestWebhook_DisputeEvidence_InformationalOnly covers both
// dispute.evidence.created and dispute.evidence.deleted: the handler logs and
// acknowledges 200 without writing a disputes row or raising any notification.
func TestWebhook_DisputeEvidence_InformationalOnly(t *testing.T) {
cases := []struct {
eventType string
disputeID string
eventID string
}{
{"dispute.evidence.created", "dts_evidence_created_1", "evt_evidence_created_1"},
{"dispute.evidence.deleted", "dts_evidence_deleted_1", "evt_evidence_deleted_1"},
}
for _, tc := range cases {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + tc.disputeID + `",
"object": {
"dispute": {
"id": "` + tc.disputeID + `",
"state": "EVIDENCE_REQUIRED"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String())
}
// No state mutation: no disputes row is written for an evidence event.
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = $1", tc.disputeID).Scan(&n); err != nil {
t.Fatalf("failed to count disputes: %v", err)
}
if n != 0 {
t.Errorf("expected no disputes row from %s, got %d", tc.eventType, n)
}
if got := countWebhookEvents(t, tc.eventID); got != 1 {
t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got)
}
}
// The handler logs the evidence event (informational only).
var buf bytes.Buffer
oldOutput := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOutput)
event := SquareWebhookEvent{
Type: "dispute.evidence.created",
EventID: "evt_evidence_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_evidence_log_1",
"object": {"dispute": {"id": "dts_evidence_log_1", "state": "UNDER_REVIEW"}}
}`),
}
if w := deliverWebhook(t, event); w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if out := buf.String(); !strings.Contains(out, "dispute evidence event for dispute") {
t.Errorf("expected an informational evidence log line, got:\n%s", out)
}
}
// TestWebhook_TerminalCheckout_InformationalOnly covers both
// terminal.checkout.created and terminal.checkout.updated: the handler logs
// and acknowledges 200 without writing any terminal_checkouts row.
func TestWebhook_TerminalCheckout_InformationalOnly(t *testing.T) {
cases := []struct {
eventType string
checkoutID string
eventID string
}{
{"terminal.checkout.created", "chk_round7_created_1", "evt_terminal_created_1"},
{"terminal.checkout.updated", "chk_round7_updated_1", "evt_terminal_updated_1"},
}
for _, tc := range cases {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "` + tc.checkoutID + `"}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String())
}
// No state mutation: no terminal_checkouts row is written.
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM terminal_checkouts WHERE checkout_id = $1", tc.checkoutID).Scan(&n); err != nil {
t.Fatalf("failed to count terminal checkouts: %v", err)
}
if n != 0 {
t.Errorf("expected no terminal_checkouts row from %s, got %d", tc.eventType, n)
}
if got := countWebhookEvents(t, tc.eventID); got != 1 {
t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got)
}
}
var buf bytes.Buffer
oldOutput := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOutput)
event := SquareWebhookEvent{
Type: "terminal.checkout.updated",
EventID: "evt_terminal_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "chk_round7_log_1"}`),
}
if w := deliverWebhook(t, event); w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if out := buf.String(); !strings.Contains(out, "terminal.checkout event received") {
t.Errorf("expected an informational terminal.checkout log line, got:\n%s", out)
}
}