Files
Crussell/backend/handlers/webhooks/webhooks_completion_asymmetry_test.go
T
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

141 lines
5.7 KiB
Go

//go:build test
package webhooks
// M15 webhook-completion idempotency, exercised through the REAL
// HandleSquareWebhook handler. The webhook's payment.updated completion path
// (handlePaymentUpdated) and the payments package's stale-pending sweep rescue
// can both try to complete the same payment; both flip status only while the
// row is still 'pending', so the first writer wins and the second applies no
// side effects. This test drives the real handler twice (distinct event ids,
// so the dedup cache does not swallow the replay) and then runs the real sweep,
// asserting the payment completes exactly once and the sweep adds nothing.
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/handlers/payments"
"crussell/testutils/fixtures"
)
// TestWebhook_AndSweep_DoNotDoubleComplete locks the webhook-first race
// ordering end to end through the real HTTP handler: the first
// payment.updated COMPLETED delivery completes the pending row; the second
// delivery is a no-op (the row is no longer pending) and the sweep rescue
// path, which runs after, finds nothing pending and applies no side effects.
func TestWebhook_AndSweep_DoNotDoubleComplete(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(db.Conn, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
const squarePaymentID = "sqp_webhook_and_sweep"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET booking_id = $1, created_at = NOW() - INTERVAL '25 hours' WHERE id = $2",
bookingID, payID); err != nil {
t.Fatalf("failed to attach booking and age the payment: %v", err)
}
// Give the booking a payable total so a (hypothetical) rescue-side
// completion check would complete it — proving the sweep does NOT run it
// after the webhook already settled the row.
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET total_amount = 10.00 WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
// The sweep reads the payments package's exported SquareClient. Under the
// `test,!dev` CI shape there is no dev mock to install, so guarantee the
// sweep is a true no-op for OUR row instead: the row is already completed
// (never fetched) and every OTHER stale pending row in this package's test
// DB is deleted up front, so no reconcile ever touches SquareClient.
if _, err := db.Conn.Exec(context.Background(),
"DELETE FROM payments WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours' AND id <> $1",
payID); err != nil {
t.Fatalf("failed to clear leftover stale pending payments: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"DELETE FROM till_sales WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours'"); err != nil {
t.Fatalf("failed to clear leftover stale pending till sales: %v", err)
}
// 1. First payment.updated COMPLETED delivery → completes the pending row.
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_webhook_and_sweep_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on the first delivery, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Fatalf("expected the first webhook delivery to complete the payment, got %q", got)
}
// 2. Second delivery (distinct event id — the dedup cache is bypassed):
// the row is already completed, so the pending-only UPDATE matches nothing.
event2 := event
event2.EventID = "evt_webhook_and_sweep_2"
w2 := deliverWebhook(t, event2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on the replay delivery, got %d: %s", w2.Code, w2.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected the replay delivery to leave the payment completed, got %q", got)
}
// 3. The sweep's rescue path runs after the webhook already completed the
// row: the row is no longer 'pending', so the sweep never fetches it and
// never applies its completion side effects.
if _, err := payments.SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected the sweep to leave the webhook-completed payment alone, got %q", got)
}
// No split records / completion side effects may have been applied by the
// sweep (the rescue is gated on the row still pending).
var recordCount int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
if recordCount != 1 {
t.Errorf("expected exactly the one webhook-completed payment row (no sweep splits), got %d", recordCount)
}
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if bookingStatus != "pending" {
t.Errorf("expected the sweep not to complete the booking after the webhook settled the row, got %q", bookingStatus)
}
}