Files
Crussell/backend/handlers/payments/till_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

215 lines
9.3 KiB
Go

//go:build test && dev
package payments
// =============================================================================
// ROUND 10 ADVERSARIAL — the till-sale FINAL-key lock vs the sweep's clawback
// =============================================================================
//
// The sweep (sweep.go acquireTillSaleSweepLock) fails a stale pending
// till_sale + claws back its funded gift card under the advisory lock
// "crussell:till:<STORED idempotency_key>". CreateTillSale's base lock is keyed
// on the REQUEST key, but a keyless sale's slot scan can advance the FINAL
// stored key past the base (a COMPLETED/FAILED sale occupying the base slot
// forces "base-1", "base-2", ...). Without the FIX 2 re-acquisition the retry
// would hold "crussell:till:<base>" while the sweep holds
// "crussell:till:<base-1>" — two locks that do not serialize — so the sweep can
// fail the sale + claw back the funded card while the retry's Square charge is
// mid-flight: customer charged AND funding clawed back.
//
// This test pins the retry side of the invariant: the retry must hold
// "crussell:till:<suffixedKey>" from before the Square charge until it
// completes. It seeds a COMPLETED sale on the deterministic base key so the
// keyless sale's slot scan advances to the suffixed key, then proves a second
// connection attempting the SAME advisory lock is blocked (try-lock returns
// false) the whole time the charge is in flight and only acquires after the
// retry finishes.
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
)
// TestCreateTillSale_KeylessSuffixedSlot_HoldsFinalKeyLockAcrossCharge locks
// the FIX 2 retry-side invariant for a keyless sale whose slot scan resolves a
// SUFFIXED final idempotency key:
//
// 1. seed a COMPLETED till_sale occupying the deterministic base key (so the
// keyless request's slot scan advances to "<base>-1");
// 2. run the keyless sale with a SLOW Square client (the charge stays
// in-flight ~500ms after the pending row commits);
// 3. once the pending row with the suffixed key is visible (the tx committed
// immediately before the charge), a second connection's try-lock on
// "crussell:till:<suffixedKey>" must return FALSE (the retry holds it) and
// stay FALSE while the charge is mid-flight;
// 4. after the handler returns, the same try-lock must return TRUE (released)
// and the sale must be completed on the suffixed key.
//
// The lock being held exactly across the charge round-trip is what serializes
// against the sweep's "crussell:till:<stored key>" fail/claw-back.
func TestCreateTillSale_KeylessSuffixedSlot_HoldsFinalKeyLockAcrossCharge(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)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
pool := context.Background()
// The keyless request the test replays. CardToken is deliberately excluded
// from deriveTillIdempotencyKey (it changes between retries), so the base
// key below is exactly what the handler derives for this request.
keylessReq := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:final-lock-adversarial",
}
baseKey := deriveTillIdempotencyKey(keylessReq, adminID)
suffixedKey := nextIdempotencyCandidate(baseKey, 1)
// Seed a COMPLETED sale on the base key — it occupies the base slot so the
// keyless sale's slot scan must advance to the suffixed key.
if _, err := tx.Exec(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount,
payment_method, status, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'completed',
$1, $2, NOW(), NOW())
`, baseKey, adminID); err != nil {
t.Fatalf("failed to seed the completed base-slot sale: %v", err)
}
// Cleanup the committed rows (the completed seed + the retry's sale and its
// handler-created gift card).
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE idempotency_key IN ($1, $2)`, baseKey, suffixedKey)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id IN (SELECT id FROM gift_cards WHERE created_by = $1)`, adminID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE created_by = $1`, adminID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
// Commit the setup so the handler and the lock-checking connection operate
// at pool level on independent sessions — advisory locks only serialize
// across separate sessions, and a per-test tx would mask the contention.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
// Slow the Square charge so the handler provably holds the final-key lock
// across an in-flight round-trip long enough to observe it from another
// connection.
origClient := SquareClient
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 500 * time.Millisecond}
SquareClient = slow
defer func() { SquareClient = origClient }()
// Run the keyless sale in a goroutine — the handler blocks ~500ms inside
// the Square charge and must not block this test's lock observations.
recCh := make(chan *httptest.ResponseRecorder, 1)
go func() {
recCh <- makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", keylessReq, adminToken, pool)
}()
// Wait for the pending till_sales row on the SUFFIXED key to be committed —
// the tx commit precedes the Square charge, so once it is visible the
// handler holds the final-key lock and is about to call Square.
deadline := time.Now().Add(10 * time.Second)
for {
var status string
err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE idempotency_key = $1`, suffixedKey).Scan(&status)
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the pending till_sale on suffixed key %s (err: %v)", suffixedKey, err)
}
time.Sleep(10 * time.Millisecond)
}
lockConn, err := db.Conn.Acquire(pool)
if err != nil {
t.Fatalf("failed to acquire lock-check connection: %v", err)
}
defer lockConn.Release()
tryLock := func() bool {
t.Helper()
var acquired bool
if err := lockConn.QueryRow(pool, `SELECT pg_try_advisory_lock(hashtext($1))`, "crussell:till:"+suffixedKey).Scan(&acquired); err != nil {
t.Fatalf("failed to try the final-key advisory lock: %v", err)
}
return acquired
}
// The charge is mid-flight: the retry must hold the final-key lock, so a
// second session cannot acquire it. If the lock is free the retry is NOT
// serialized against the sweep's stored-key clawback — the exact FIX 2 bug.
if tryLock() {
t.Fatalf("retry does NOT hold the final-key lock %q while the Square charge is in flight — the sweep could fail the sale and claw back the funded card mid-charge", "crussell:till:"+suffixedKey)
}
// Still mid-flight (the slow client keeps the charge in the air for ~500ms
// after the row appeared): the lock must stay held for the whole window.
time.Sleep(200 * time.Millisecond)
if tryLock() {
t.Fatalf("final-key lock %q was released mid-charge — the sweep could claw back the funded card before the charge completed", "crussell:till:"+suffixedKey)
}
// The handler must complete the sale on the suffixed key.
var w *httptest.ResponseRecorder
select {
case w = <-recCh:
case <-time.After(30 * time.Second):
t.Fatal("timed out waiting for the till-sale handler")
}
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
// After the handler released it, the final-key lock must be free again —
// proving it was held for exactly the charge round-trip.
if !tryLock() {
t.Fatalf("final-key lock %q not released after the retry completed", "crussell:till:"+suffixedKey)
}
defer func() {
_, _ = lockConn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, "crussell:till:"+suffixedKey)
}()
// The sale is stored under the SUFFIXED key and completed — the key the
// sweep will lock on, and the one this retry just held.
var storedKey, status string
if err := db.Conn.QueryRow(pool, `SELECT idempotency_key, status FROM till_sales WHERE idempotency_key = $1`, suffixedKey).Scan(&storedKey, &status); err != nil {
t.Fatalf("failed to query the suffixed-key sale: %v", err)
}
if storedKey != suffixedKey {
t.Fatalf("stored idempotency_key %q != suffixed key %q", storedKey, suffixedKey)
}
if status != "completed" {
t.Fatalf("expected the suffixed-key sale to be completed, got %q", status)
}
// The base slot stays occupied by the seeded completed sale (the keyless
// retry did not collapse onto it).
var baseCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM till_sales WHERE idempotency_key = $1 AND status = 'completed'`, baseKey).Scan(&baseCount); err != nil {
t.Fatalf("failed to count base-slot sales: %v", err)
}
if baseCount != 1 {
t.Fatalf("expected the base-slot sale to remain the only completed sale on the base key, got %d", baseCount)
}
}