Files
Crussell/backend/handlers/payments/giftcard_redeem_touctoc_test.go
T
popertots 42130865f4 fix: gift-card money fixes — partial clawback surfaces CRITICAL (M6), per-admin daily-cap lock (M7), DB-clock expiry
- M6: RevertGiftCardFunding no longer silently drops unreclaimable money. A
  partially-spent create/top-up claws back everything still on the card/balance
  (GREATEST(0, ...) clamp instead of the old guarded 0-row block), inserts a
  CRITICAL admin notification, and returns errClawbackPartiallyReversed so every
  caller (till handler, stale-pending sweep, webhook) surfaces the residual
  without forking the money logic; balance comparisons use pence (penceLess).
- M7: the £5,000/day admin gift-card value cap (create/top-up/transfer) is now
  serialized per-admin under a bounded advisory try-lock
  (acquireGiftCardDailyCapLock) so two concurrent operations cannot both read
  the day's value before either writes and over-issue value.
- M4/M3: every gift-card expiry comparison now reads the DATABASE clock
  (giftCardExpired -> SELECT NOW()), the same clock that wrote expiry_date, so
  app-clock drift can neither extend nor shorten card life; applied on redeem,
  cancellation assessment, cancel-for-user and the reversal re-verification.
- C6 consent fields carried on BuyGiftCardRequest and enforced on the
  (now unreachable) 2FA fallback audit path; fallback audit row captures the
  versioned consent.
2026-08-22 00:34:50 +01:00

148 lines
4.8 KiB
Go

//go:build test && dev
package payments
// M16 gift-card double-redeem TOCTOU test. RedeemGiftCard serializes on a
// `SELECT ... FOR UPDATE` of the gift_cards row (READ COMMITTED re-reads the
// latest committed version after the lock is granted), so two CONCURRENT
// redemptions of the same code can never both succeed: exactly one wins the
// row lock, zeroes the balance and credits its user; the loser re-reads the
// committed row, sees redeemed_by set and is rejected 400. This test drives
// two real HTTP requests in parallel goroutines and asserts exactly one
// success, one rejection, and a single balance decrement + single
// redeem_to_balance audit transaction.
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"crussell/db"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
func TestGiftCardRedeem_ConcurrentSameCode_ExactlyOneWins(t *testing.T) {
// Seed on the pool directly (no SetupTestTx): each concurrent request
// must run its own transaction on the shared pool.
ctx := context.Background()
userA, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create user A: %v", err)
}
userB, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
var code string
if err := db.Conn.QueryRow(ctx, `INSERT INTO gift_cards (total_funds_added, amount_remaining) VALUES (50.00, 50.00) RETURNING id`).Scan(&code); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, code)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_giftcard_balances WHERE user_id IN ($1, $2)`, userA, userB)
_, _ = db.Conn.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, code)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id IN ($1, $2)`, userA, userB)
})
router := chi.NewRouter()
router.Use(mw.RequireAuth)
router.Post("/api/user/giftcards/redeem", RedeemGiftCard)
// Both redemptions start at the same barrier instant so the row lock
// genuinely contends.
start := make(chan struct{})
type attempt struct {
status int
}
attempts := make([]attempt, 2)
var wg sync.WaitGroup
redeem := func(userID, token string, idx int) {
defer wg.Done()
<-start
body, _ := json.Marshal(map[string]any{"code": code})
req := httptest.NewRequest("POST", "/api/user/giftcards/redeem", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
attempts[idx].status = w.Code
}
wg.Add(2)
go redeem(userA, jwt.GenerateTestToken(userA, "verified_email"), 0)
go redeem(userB, jwt.GenerateTestToken(userB, "verified_email"), 1)
close(start)
wg.Wait()
successes, failures := 0, 0
for _, a := range attempts {
switch a.status {
case http.StatusOK:
successes++
case http.StatusBadRequest:
failures++
default:
t.Errorf("unexpected redeem status %d", a.status)
}
}
if successes != 1 {
t.Errorf("expected exactly ONE successful redemption, got %d", successes)
}
if failures != 1 {
t.Errorf("expected exactly ONE rejected redemption, got %d", failures)
}
// The balance was decremented exactly once (amount_remaining zeroed).
var amountRemaining float64
var redeemedBy *string
if err := db.Conn.QueryRow(ctx, `SELECT amount_remaining, redeemed_by FROM gift_cards WHERE id = $1`, code).Scan(&amountRemaining, &redeemedBy); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0 {
t.Errorf("expected the card balance decremented to 0 exactly once, got %.2f", amountRemaining)
}
if redeemedBy == nil {
t.Fatal("expected the card redeemed to exactly one user")
}
// Exactly one user holds the credited balance; the other has none.
var winners int
var balanceSum float64
if err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*), COALESCE(SUM(balance), 0)
FROM user_giftcard_balances
WHERE user_id IN ($1, $2)
`, userA, userB).Scan(&winners, &balanceSum); err != nil {
t.Fatalf("failed to query user balances: %v", err)
}
if winners != 1 {
t.Errorf("expected exactly one credited balance row, got %d", winners)
}
if balanceSum != 50 {
t.Errorf("expected the winner credited 50.00, got %.2f", balanceSum)
}
// Exactly one redeem_to_balance audit transaction exists.
var txCount int
if err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_card_transactions
WHERE gift_card_id = $1 AND transaction_type = 'redeem_to_balance'
`, code).Scan(&txCount); err != nil {
t.Fatalf("failed to count redeem transactions: %v", err)
}
if txCount != 1 {
t.Errorf("expected exactly one redeem_to_balance transaction, got %d", txCount)
}
}