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

243 lines
11 KiB
Go

//go:build test && dev
package payments
import (
"database/sql"
"net/http"
"testing"
"crussell/clock"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// ROUND 10 — CancelGiftCard resume vs an in-flight / differently-amounted
// Square refund (adversarial double-refund probe)
//
// The resume path must inspect EVERY Square refund for the payment (pending
// AND completed, any amount), not just an exact-amount COMPLETED one:
// - a still-PENDING Square refund means money may still land — re-issuing
// under a fresh amount-derived key would mint a SECOND refund that lands
// on top of the first (double refund);
// - a COMPLETED refund at a DIFFERENT amount than the pending row claims
// still counts toward the entitlement — the exact-amount reconcile misses
// it and would re-issue money that has already moved.
// =============================================================================
// TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue pins
// the in-flight guard: the prior attempt is still PENDING at Square and the
// entitlement has dropped (a £5 till spend between attempts). The resume must
// NOT re-issue a second Square refund — the row stays 'pending' for the sweep
// and the card stays live. Exploit before the fix: the exact-amount reconcile
// sees no COMPLETED £50 refund, re-issues £45 under a fresh key while the £50
// is still in flight, and the customer is refunded £95 total.
func TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending")
// The prior attempt is STILL in flight at Square (PENDING). Seed it via
// the raw mock so it bypasses the counting wrapper's refund-call log.
mock.ForceRefundPending = true
_, err = counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: squarePaymentID,
Amount: 5000,
IdempotencyKey: "pending-inflight-" + paymentID,
Reason: giftCardCancelRefundReason,
})
require.NoError(t, err)
mock.ForceRefundPending = false
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String())
// No second Square refund may be issued while a prior one is in flight:
// the handler minted none, and the mock ledger still holds exactly the one
// pre-seeded refund.
require.Empty(t, counting.refundCalls(), "no re-issue while a prior Square refund is still PENDING")
require.Equal(t, 1, mock.RefundKeyCount(), "exactly one Square refund minted for the payment — the pending one")
// The refund row stays 'pending' (this request made no writes) and the
// card stays live at its spend-verified balance.
var status string
var amount float64
require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &amount))
assert.Equal(t, "pending", status, "the row must remain pending for sweep reconciliation")
assert.Equal(t, 50.00, amount, "the row must not be rewritten with a new amount or key")
var rem float64
var expiry sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
assert.Equal(t, 45.00, rem, "the card must stay live with its spend-verified remaining balance")
require.True(t, expiry.Valid)
assert.True(t, expiry.Time.After(clock.Now()), "the card must not be expired")
}
// TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes
// pins the sum-based terminal reconcile: the prior attempt landed at Square at
// a DIFFERENT amount than the pending row claims (£45 of a £50 card after a £5
// till spend — the pending row still says £50). The exact-amount reconcile
// (expecting £50) would miss the landed £45 and re-issue it. The resume must
// instead recognise the entitlement is already covered, resolve the row
// completed with the landed Square refund id, neutralise the card, and issue no
// new refund.
func TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending")
prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: squarePaymentID,
Amount: 4500,
IdempotencyKey: "prelanded-diff-amount-" + paymentID,
Reason: giftCardCancelRefundReason,
})
require.NoError(t, err)
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "already been refunded")
require.Empty(t, counting.refundCalls(), "no new Square refund when the full entitlement already completed")
var status string
var sqRefundID sql.NullString
require.NoError(t, tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
assert.Equal(t, "completed", status, "the pending row must resolve to completed")
assert.Equal(t, prelanded.ID, sqRefundID.String, "the row records the landed Square refund id")
var rem float64
var expiry sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
assert.Equal(t, 0.00, rem, "the card must be neutralised")
require.True(t, expiry.Valid)
assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired")
}
// TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued pins
// the difference-only re-issue: £30 of the £50 card already completed at
// Square, so the resume must issue ONLY the £20 remainder under a fresh
// deterministic key for that difference — never the full £50.
func TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 0, "pending")
prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: squarePaymentID,
Amount: 3000,
IdempotencyKey: "prelanded-partial-" + paymentID,
Reason: giftCardCancelRefundReason,
})
require.NoError(t, err)
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
calls := counting.refundCalls()
require.Len(t, calls, 1, "exactly one Square refund: the outstanding difference")
assert.Equal(t, int64(2000), calls[0].Amount, "only the £20 difference may be re-issued")
assert.Equal(t, paymentID+"-gccancel-diff-2000", calls[0].IdempotencyKey, "a fresh deterministic key for the difference")
var amount float64
var status, key string
var sqRefundID sql.NullString
require.NoError(t, tx.QueryRow(ctx, `
SELECT amount, status, idempotency_key, square_refund_id FROM refunds WHERE payment_id = $1
`, paymentID).Scan(&amount, &status, &key, &sqRefundID))
assert.Equal(t, 20.00, amount, "the refund row records the re-issued £20 difference")
assert.Equal(t, "completed", status)
assert.Equal(t, paymentID+"-gccancel-diff-2000", key, "the row carries the fresh difference key")
require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the new Square refund id must be recorded")
assert.NotEqual(t, prelanded.ID, sqRefundID.String, "the row must carry the NEW refund id, not the pre-landed one")
var rem float64
var expiry sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
assert.Equal(t, 0.00, rem, "the card must be zeroed once the balance of the entitlement is refunded")
require.True(t, expiry.Valid)
assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired")
}
// TestRound10_CancelGiftCard_HappyPath_NoRegression pins the fresh-cancellation
// path (no prior refund row): the resume rewrite must not disturb the happy
// path — one Square refund for the full value, the row completed, the card
// neutralised.
func TestRound10_CancelGiftCard_HappyPath_NoRegression(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000)
require.Equal(t, http.StatusCreated, code, "buy must succeed")
require.NotEmpty(t, cardID)
var payID string
require.NoError(t, tx.QueryRow(ctx, `
SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL
`, userID).Scan(&payID))
w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "success")
calls := counting.refundCalls()
require.Len(t, calls, 1, "exactly one Square refund for the fresh cancellation")
assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence")
var status string
var amount float64
require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, payID).Scan(&status, &amount))
assert.Equal(t, "completed", status)
assert.Equal(t, 50.00, amount)
var rem float64
var expiry sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
assert.Equal(t, 0.00, rem, "the card must be zeroed after cancellation")
require.True(t, expiry.Valid)
assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired")
}