fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes: - CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back - A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit) - A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds - A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows - A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs - A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point) - A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface - A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction - M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test - Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status) All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -313,6 +314,95 @@ func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.
|
||||
require.Equal(t, 2, cardCount)
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix:
|
||||
// a no-key purchase whose payment row was swept/declined to 'failed' must NOT
|
||||
// permanently block the identical repurchase. The slot scan advances past BOTH
|
||||
// COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot),
|
||||
// so the repurchase derives a FRESH key and charges again instead of
|
||||
// 409-rejecting forever on the failed row.
|
||||
func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
// The deterministic fallback key a no-key £20 self purchase would derive.
|
||||
failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW())
|
||||
`, failedKey, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The identical repurchase must SUCCEED on a fresh key — not 409 forever.
|
||||
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
|
||||
t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body)
|
||||
}
|
||||
|
||||
// Two distinct keys: the failed slot key + the fresh advance key.
|
||||
var keyCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
|
||||
require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot")
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a
|
||||
// client-supplied idempotency key that matches ANOTHER user's payment row must
|
||||
// never be returned (completed), reused (pending), or rejected on (failed) —
|
||||
// cross-user hijack. The purchase proceeds as a fresh request on a fresh key.
|
||||
func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
victimID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
attackerID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
token := jwt.GenerateTestToken(attackerID, "verified_email")
|
||||
|
||||
// The victim's COMPLETED payment under a deterministic/guessable key.
|
||||
victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW())
|
||||
`, victimKey, victimID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The attacker supplies the victim's key: must NOT get the victim's
|
||||
// completed payment back (which would be a false success leaking the
|
||||
// victim's row) — it must proceed as a fresh charge.
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": victimKey,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String())
|
||||
|
||||
// A gift card was issued to the ATTACKER, not the victim.
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount))
|
||||
require.Equal(t, 1, cardCount, "the attacker must receive their own gift card")
|
||||
// The victim's payment row is untouched and the attacker got their own row.
|
||||
var victimPayCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount))
|
||||
require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated")
|
||||
var attackerPayCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount))
|
||||
require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
|
||||
// released
|
||||
|
||||
Reference in New Issue
Block a user