Files
Crussell/backend/handlers/payments/handlers_round10_adversarial_test.go
T
popertotsandSisyphus 1d6d3e2f8d test: round-9/10 adversarial suites — sync-path vs webhook completion, gift-card double-refund, sweep VAT, till lock, account lockout + erasure
- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows
- giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund)
- sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge
- webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync
- account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

247 lines
12 KiB
Go

//go:build test && dev
package payments
// Round-10 adversarial tests: the synchronous charge path vs the Square
// payment.completed webhook race. The webhook can win the booking FOR UPDATE
// lock between the Square call returning and the sync path's post-charge
// completion flip, resolving the payment row to 'completed' (and running the
// booking-completion side-effects) first. These tests simulate the webhook
// landing DURING the sync CreatePayment call and verify the sync path's
// guarded flips (R10) no-op instead of re-flipping the row, overwriting the
// ledger, or re-inserting split records. They follow the existing sequential
// (non-parallel) conventions of the money-path tests.
import (
"context"
"log"
"net/http"
"net/http/httptest"
"testing"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/jwt"
"github.com/stretchr/testify/require"
)
// webhookRaceClient wraps the dev Square client to simulate the
// payment.completed webhook landing DURING the synchronous CreatePayment call
// — the exact race window the R10 guards close. On a COMPLETED charge it
// resolves the just-created pending payment row for this booking to
// 'completed' BEFORE the sync handler's own post-charge completion flip runs.
// When simulateBookingCompletion is set it also completes the booking and
// awards the loyalty stamp, mirroring the webhook's booking-completion
// side-effects.
type webhookRaceClient struct {
square.SquareClient
bookingID string
userID string
simulateBookingCompletion bool
}
func (c *webhookRaceClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
res, err := c.SquareClient.CreatePayment(ctx, req)
if err != nil || res == nil || res.Status != "COMPLETED" {
return res, err
}
// The webhook completes the pending row for this charge (same booking +
// idempotency key) before the sync handler's flip runs.
if _, uErr := db.Conn.Exec(ctx, `
UPDATE payments
SET status = 'completed', square_payment_id = $1
WHERE booking_id = $2 AND idempotency_key = $3 AND status = 'pending'
`, res.SquarePayID, c.bookingID, req.IdempotencyKey); uErr != nil {
log.Printf("webhookRaceClient: failed to simulate payment.completed webhook: %v", uErr)
}
if c.simulateBookingCompletion {
if _, uErr := db.Conn.Exec(ctx, `
UPDATE bookings SET status = 'completed', loyalty_stamp_awarded_at = NOW()
WHERE id = $1
`, c.bookingID); uErr != nil {
log.Printf("webhookRaceClient: failed to simulate booking completion: %v", uErr)
}
if _, uErr := db.Conn.Exec(ctx, `UPDATE users SET loyalty_stamps = loyalty_stamps + 1 WHERE id = $1`, c.userID); uErr != nil {
log.Printf("webhookRaceClient: failed to simulate loyalty award: %v", uErr)
}
}
return res, err
}
// TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite locks the
// R10 fix on the synchronous online booking path: when the Square
// payment.completed webhook wins the race and completes the pending payment
// row (plus the booking side-effects) before the sync completion flip, the
// sync path must NOT re-flip the already-completed row, OVERWRITE the primary
// row's amount/payment_type/VAT with buildSplitRecords' carved values, or
// re-insert split tip/balance rows. The response stays 200 (the payment IS
// completed) and the booking side-effects are not double-run.
func TestCreateBookingPayment_WebhookCompletedFirst_NoLedgerOverwrite(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// VAT-registered so a buggy split/VAT overwrite would visibly clear or
// recompute the row's VAT fields.
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
require.NoError(t, err)
// Past-start booking (total £50, per the round-9 fixture), in_progress.
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
origClient := SquareClient
SquareClient = &webhookRaceClient{
SquareClient: square.NewDevClient(),
bookingID: bookingID,
userID: userID,
simulateBookingCompletion: true,
}
defer func() { SquareClient = origClient }()
// £55 'full' on a £50 past-start booking: buildSplitRecords would carve
// [£50 booking, £5 tip]. The webhook completing the row FIRST must make
// the sync flip a no-op — no tip split inserted, primary not overwritten.
cardToken := "cnon:round10-webhook-first"
req := CreateBookingPaymentRequest{
Amount: 5500,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "round10-webhook-first-" + bookingID,
ConfirmOverflowTip: true, // B12: post-start overflow requires explicit confirmation
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved payment must still report success, body: %s", w.Body.String())
// Exactly ONE completed payment row — no duplicate split tip/balance rows.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
require.Equal(t, 1, payCount, "the webhook-completed row must NOT be re-split into duplicate tip/balance rows")
// The primary row keeps the charged (webhook) values — the sync flip
// must not overwrite amount/payment_type with the split-carved values.
var amount float64
var paymentType string
var sqPayID string
require.NoError(t, tx.QueryRow(ctx, `
SELECT amount, payment_type, COALESCE(square_payment_id, '') FROM payments WHERE booking_id = $1
`, bookingID).Scan(&amount, &paymentType, &sqPayID))
require.InDelta(t, 55.0, amount, 0.001, "the primary row must keep the charged amount (webhook's value), not the split-carved £50")
require.Equal(t, "full", paymentType, "the primary row must keep its original payment_type")
require.NotEmpty(t, sqPayID, "the webhook-completed row must carry the Square payment id")
// Booking side-effects not double-run: the webhook completed the booking
// and awarded one stamp; the sync path must not re-run completion.
var bookingStatus string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
require.Equal(t, "completed", bookingStatus, "the booking must stay completed — the sync path must not touch it")
var stamps int
require.NoError(t, tx.QueryRow(ctx, `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&stamps))
require.Equal(t, 1, stamps, "the loyalty stamp must be awarded exactly once (webhook's), not re-awarded by the sync path")
var discountCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
require.Equal(t, 0, discountCount, "no campaign discount rows may be created by a no-op completion")
}
// TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip locks the R10 fix
// on the tip path: when the webhook completes the pending tip row before the
// sync completion flip, the sync path must not re-flip it or double-record the
// tip — exactly one completed tip row exists and the response is 200.
func TestCreateTipPayment_WebhookCompletedFirst_NoDuplicateTip(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// The tip flow requires an existing completed payment on the booking.
_, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 'completed', 50.00, $2, $3, NOW(), NOW())
`, bookingID, "round10-tip-primary-"+bookingID, userID)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &webhookRaceClient{
SquareClient: square.NewDevClient(),
bookingID: bookingID,
userID: userID,
}
defer func() { SquareClient = origClient }()
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: strPtr("cnon:round10-tip-webhook-first"),
IdempotencyKey: "round10-tip-webhook-first-" + bookingID,
}
w := makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a webhook-resolved tip must still report success, body: %s", w.Body.String())
// Exactly ONE completed tip row — the sync flip must not create a second.
var tipCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
`, bookingID).Scan(&tipCount))
require.Equal(t, 1, tipCount, "the webhook-completed tip row must not be duplicated by the sync path")
var tipAmount float64
var sqPayID string
require.NoError(t, tx.QueryRow(ctx, `
SELECT amount, COALESCE(square_payment_id, '') FROM payments
WHERE booking_id = $1 AND payment_type = 'tip'
`, bookingID).Scan(&tipAmount, &sqPayID))
require.InDelta(t, 5.0, tipAmount, 0.001, "the tip row must keep the charged amount")
require.NotEmpty(t, sqPayID, "the webhook-completed tip row must carry the Square payment id")
// Total ledger: the pre-existing full payment + exactly one tip.
var totalCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&totalCount))
require.Equal(t, 2, totalCount, "the ledger must hold the full payment + exactly one tip row")
}
// TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed locks the
// R10 fix in postChargeRecheck (charge_helpers.go): a charge landing on a
// cancelled booking must not mark 'failed' a payment row the webhook already
// resolved to 'completed'. The guarded failed-mark UPDATE no-ops on the
// completed row, the 409 conflict is still returned, and the completed row
// stays completed so the cancellation refund path (which computes refunds
// from completed payments) can reverse the money.
func TestPostChargeRecheck_WebhookCompletedRow_NotClobberedWithFailed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
// The booking was cancelled between the Square call and the recheck.
_, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID)
require.NoError(t, err)
// The webhook already completed the payment row.
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 'completed', 55.00, 'pay_round10_webhook_resolved', $2, $3, NOW(), NOW())
RETURNING id
`, bookingID, "round10-clobber-key", userID).Scan(&paymentID)
require.NoError(t, err)
recheckTx, bErr := db.Conn.Begin(ctx)
require.NoError(t, bErr)
defer func() { _ = recheckTx.Rollback(ctx) }()
w := httptest.NewRecorder()
payable, pErr := postChargeRecheck(ctx, w, recheckTx, bookingID, paymentID, "COMPLETED", "pay_round10_webhook_resolved", "payment", "This booking is no longer accepting payments")
require.NoError(t, pErr)
require.False(t, payable, "a cancelled booking must not accept the completed payment")
require.Equal(t, http.StatusConflict, w.Code, "the recheck must surface the 409 conflict")
// The completed row must survive the recheck — never clobbered to 'failed'.
var status string
var sqPayID string
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1`, paymentID).Scan(&status, &sqPayID))
require.Equal(t, "completed", status, "a webhook-completed row must not be marked failed by the cancelled-booking recheck")
require.Equal(t, "pay_round10_webhook_resolved", sqPayID, "the webhook's square_payment_id must survive untouched")
var bookingStatus string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
require.Equal(t, "client_cancelled", bookingStatus, "the booking must stay cancelled")
}