- 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.
148 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|