Money-safety: - Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation - Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged - CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard) - Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse - Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID GDPR / security: - Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010 - square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2 - Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel - Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit Frontend: - Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen) - Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh - Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy S3: - Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific) Tests/docs: - 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
420 lines
16 KiB
Go
420 lines
16 KiB
Go
//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
|
|
}
|
|
|
|
// countUntrackedCriticalNotification returns the number of unacknowledged
|
|
// NULL-booking critical_payment_log notifications for a dispute's deterministic
|
|
// id (disputeNotificationID).
|
|
func countUntrackedCriticalNotification(t *testing.T, disputeID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.Conn.QueryRow(context.Background(), `
|
|
SELECT COUNT(*) FROM admin_notifications
|
|
WHERE id = $1 AND reason = 'critical_payment_log'
|
|
AND booking_id IS NULL AND acknowledged_at IS NULL
|
|
`, disputeNotificationID(disputeID)).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count critical notifications for dispute %s: %v", disputeID, 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_RaisesCritical
|
|
// covers the fallback's dead end: when neither the payload's (empty) Square
|
|
// payment id nor the disputes table yields a payment, the chargeback is
|
|
// UNTRACKED — the handler raises the same critical_payment_log notification as
|
|
// dispute.created (booking_id NULL, the deterministic disputeNotificationID) so
|
|
// a state.updated arriving without a prior created event is never a silent
|
|
// money-loss path. No disputes row is written, the payment row is untouched,
|
|
// and the dedup row still commits (Square's retry is acknowledged 200, not
|
|
// re-dispatched forever).
|
|
func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical(t *testing.T) {
|
|
payID, _ := 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)
|
|
}
|
|
// The untracked chargeback MUST still surface in-app: exactly one
|
|
// unacknowledged NULL-booking critical notification under the dispute's
|
|
// deterministic id — the same contract as dispute.created's untracked
|
|
// branch.
|
|
if got := countUntrackedCriticalNotification(t, "dts_no_dispute_row_1"); got != 1 {
|
|
t.Errorf("expected 1 unacknowledged NULL-booking critical notification for the untracked dispute, got %d", got)
|
|
}
|
|
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)
|
|
}
|
|
}
|