- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate - webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table - cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password - lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env - erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added - env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency - auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO - frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
553 lines
25 KiB
Go
553 lines
25 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// M7 — a manual refund of a gift-card purchase must never create money
|
|
// =============================================================================
|
|
|
|
// TestRefundPayment_GiftCardPurchase_Rejected locks the direct-handler guard:
|
|
// a completed gift-card purchase payment (payments row with NO booking_id)
|
|
// must be rejected with a clear message BEFORE any Square call or refund row —
|
|
// refunding it at Square would return the cash while the issued card + balance
|
|
// credit stay live (money created from nothing).
|
|
func TestRefundPayment_GiftCardPurchase_Rejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
// Seed a completed gift-card purchase payment: NO booking_id, Square
|
|
// payment id set — exactly what BuyGiftCard inserts.
|
|
var paymentID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_by, created_at, updated_at)
|
|
VALUES ('full', 'online_square', 'completed', 10.00, 'pay_gc_purchase_refund_1', $1, NOW(), NOW())
|
|
RETURNING id
|
|
`, userID).Scan(&paymentID)
|
|
require.NoError(t, err)
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
req := RefundRequest{Amount: 1000, Reason: "customer changed their mind"}
|
|
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
require.Equal(t, http.StatusBadRequest, rec.Code, "a gift-card purchase must be rejected before any Square call, body: %s", rec.Body.String())
|
|
|
|
if !strings.Contains(rec.Body.String(), "gift-card") {
|
|
t.Errorf("expected the rejection message to direct the admin to the gift-card section, got %q", rec.Body.String())
|
|
}
|
|
|
|
// No refund row may be created for the blocked refund.
|
|
var refundCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount))
|
|
require.Equal(t, 0, refundCount, "no refund row may exist for a rejected gift-card purchase refund")
|
|
}
|
|
|
|
// TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued locks the sweep
|
|
// resume path: a pending manual refund for a gift-card purchase payment must
|
|
// NEVER be re-issued at Square (the sweep is a bypass of the handler guard).
|
|
// The row is reconciled only — a COMPLETED refund at Square resolves it to
|
|
// completed; a genuine no-match fails it + admin-notifies.
|
|
func TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
const sqPayID = "pay_gc_purchase_sweep_1"
|
|
var paymentID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('full', 'online_square', 'completed', 10.00, $1, 'gc-purchase-sweep-key', $2, NOW(), NOW())
|
|
RETURNING id
|
|
`, sqPayID, userID).Scan(&paymentID)
|
|
require.NoError(t, err)
|
|
|
|
var refundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
|
|
VALUES ($1, NULL, 10.00, 'pending', 'manual refund of gift-card purchase', 'refund-key-1', $2, NOW() - INTERVAL '1 minute', 'manual')
|
|
RETURNING id
|
|
`, paymentID, adminID).Scan(&refundID)
|
|
require.NoError(t, err)
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
require.NotNil(t, pgxTx)
|
|
require.NoError(t, pgxTx.Commit(ctx))
|
|
|
|
freshCtx := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL AND created_at > NOW() - INTERVAL '1 hour'`)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
t.Run("no_completed_refund_at_square_fails_without_reissue", func(t *testing.T) {
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, n, "a blocked gift-card-purchase refund must not count as money-moved")
|
|
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Fatalf("expected NO Square refund re-issue for a gift-card purchase, got %d refund call(s)", len(calls))
|
|
}
|
|
|
|
var status string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
|
|
require.Equal(t, "failed", status, "a gift-card-purchase manual refund with no Square refund must be failed, never re-issued")
|
|
|
|
var notifCount int
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`).Scan(¬ifCount))
|
|
require.Equal(t, 1, notifCount, "the blocked refund must surface an admin notification")
|
|
})
|
|
|
|
t.Run("completed_refund_at_square_resolves_completed", func(t *testing.T) {
|
|
// Put the row back to pending (the previous sub-run failed it).
|
|
_, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', refund_attempts = 0 WHERE id = $1`, refundID)
|
|
require.NoError(t, err)
|
|
_, err = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
// Seed a COMPLETED refund at Square directly on the mock (this is the
|
|
// historical attempt's money already having moved — NOT a re-issue).
|
|
_, err = mock.RefundPayment(freshCtx, square.RefundPaymentReq{
|
|
PaymentID: sqPayID,
|
|
Amount: 1000,
|
|
IdempotencyKey: "seed-refund-gc-purchase",
|
|
Reason: "historical",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
counting := &countingRefundClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, n, "the reconciled COMPLETED refund resolves the row")
|
|
|
|
if calls := counting.refundCalls(); len(calls) != 0 {
|
|
t.Fatalf("expected NO Square refund re-issue even with a completed refund at Square, got %d refund call(s)", len(calls))
|
|
}
|
|
|
|
var status string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
|
|
require.Equal(t, "completed", status, "an exact COMPLETED refund at Square resolves the row to completed")
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// M7 — BuyGiftCard no-client-key fallback: retry dedup without collapse
|
|
// =============================================================================
|
|
|
|
// buyGiftCardNoKey issues a BuyGiftCard request without a client-supplied
|
|
// idempotency key and returns the response code + body.
|
|
func buyGiftCardNoKey(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int, recipientType string) (int, string) {
|
|
t.Helper()
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"amount": amount,
|
|
"recipient_type": recipientType,
|
|
"new_card_token": "cnon:card-nonce-ok",
|
|
})
|
|
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))
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/user/giftcards/buy", BuyGiftCard)
|
|
r.ServeHTTP(w, req)
|
|
return w.Code, w.Body.String()
|
|
}
|
|
|
|
// TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge locks the M7 fix:
|
|
// a no-client-key purchase whose Square response is lost (charge COMMITTED at
|
|
// Square, handler saw a 503, payment row left pending) must, when retried with
|
|
// the identical body, re-derive the SAME deterministic fallback key, reuse the
|
|
// pending row, and land ONE Square charge — the old fresh-random-suffix
|
|
// fallback generated a new key per retry and charged twice.
|
|
func TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge(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)
|
|
mock.FailAfterCommit = true
|
|
counting := &countingPaymentClient{SquareClient: mock}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
// Attempt 1: Square commits the charge under the derived key but the
|
|
// response is lost — the payment row stays pending.
|
|
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code == http.StatusCreated {
|
|
t.Fatalf("expected the lost-response attempt to fail (charge committed, response lost), got 201: %s", body)
|
|
}
|
|
|
|
mock.FailAfterCommit = false
|
|
|
|
// Retry: deterministic key reuse + Square dedup → the retry succeeds.
|
|
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
|
|
t.Fatalf("expected the retry to succeed, got %d: %s", code, body)
|
|
}
|
|
|
|
// Exactly ONE payment row, ONE idempotency key, ONE issued card.
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "exactly one payment row for a lost-response retry")
|
|
|
|
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, 1, keyCount, "both attempts must share ONE deterministic fallback key")
|
|
|
|
var cardCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
|
require.Equal(t, 1, cardCount, "exactly one gift card issued")
|
|
|
|
// Both Square calls reused the SAME key; only ONE successful payment exists.
|
|
keys := counting.keys
|
|
if len(keys) != 2 {
|
|
t.Fatalf("expected 2 CreatePayment calls (attempt + retry), got %d", len(keys))
|
|
}
|
|
if keys[0] != keys[1] {
|
|
t.Errorf("expected the retry to reuse the fallback key %q, got %q — a fresh key per retry is the double-charge bug", keys[0], keys[1])
|
|
}
|
|
if len(counting.payments) != 1 {
|
|
t.Errorf("expected exactly ONE successful Square charge, got %d", len(counting.payments))
|
|
}
|
|
}
|
|
|
|
// TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges locks the other
|
|
// half of the tradeoff: two genuinely DISTINCT no-key purchases (different
|
|
// amounts) must diverge onto distinct deterministic keys and issue two charges.
|
|
func TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges(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")
|
|
|
|
for _, amount := range []int{2000, 5000} {
|
|
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, amount, "self"); code != http.StatusCreated {
|
|
t.Fatalf("expected 201 for £%d purchase, got %d: %s", amount/100, code, body)
|
|
}
|
|
}
|
|
|
|
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, "two different-amount no-key purchases must get distinct keys")
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
|
require.Equal(t, 2, payCount)
|
|
|
|
var cardCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
|
require.Equal(t, 2, cardCount)
|
|
}
|
|
|
|
// TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges locks the
|
|
// distinct-recipient branch of the tradeoff.
|
|
func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(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")
|
|
|
|
for _, recipient := range []string{"self", "friend"} {
|
|
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, recipient); code != http.StatusCreated {
|
|
t.Fatalf("expected 201 for %s purchase, got %d: %s", recipient, code, body)
|
|
}
|
|
}
|
|
|
|
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, "different-recipient no-key purchases must get distinct keys")
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
|
|
require.Equal(t, 2, payCount)
|
|
|
|
var cardCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
|
|
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")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Fix 1 — tip 2FA gate asymmetry: saved-card tip with SCA tokenize-result
|
|
// must skip the gate in an enforced deployment
|
|
// =============================================================================
|
|
|
|
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds locks the
|
|
// Fix 1 gate skip: a saved-card tip carrying an SCA tokenize-result token
|
|
// (new_card_token alongside card_id) must skip the 2FA gate and complete,
|
|
// matching the CreateBookingPayment scaTokenizedSavedCard pattern. Without the
|
|
// fix, the tip path gates on card_id alone and refuses 402
|
|
// verification_required because the legacy verification_token field is empty.
|
|
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_Succeeds(t *testing.T) {
|
|
helperEnvEnforce2FAStaging(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
// A completed payment is required before a tip can be added.
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_sca_ok", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
mc := square.NewDevClient().(*square.MockClient)
|
|
mc.SimulateSavedCardVerificationRequired = true
|
|
SquareClient = mc
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
scaToken := "cnon:sca-4242_500_ok"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
CardID: &cardID,
|
|
NewCardToken: &scaToken,
|
|
IdempotencyKey: "enforced-tip-scatokenized",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip must skip the enforced gate and complete, body: %s", w.Body.String())
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "the SCA-tokenized tip must record exactly one completed tip payment")
|
|
}
|
|
|
|
// TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds
|
|
// locks the Fix 1 SAVE gate skip: a save-card tip carrying an SCA tokenize-result
|
|
// token must skip the SAVE gate and persist the card, matching the
|
|
// CreateBookingPayment isSCATokenizeResultShape pattern.
|
|
func TestCreateTipPayment_EnforcedSavedCard_SCATokenizeResult_SaveCard_Succeeds(t *testing.T) {
|
|
helperEnvEnforce2FAStaging(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
// A cnon:sca-... token with save_card=true and no card_id is the
|
|
// NEW-card SCA tokenize-result save shape — isSCATokenizeResultShape
|
|
// must recognise it and skip the SAVE gate. Use the regular mock
|
|
// (no SimulateSavedCardVerificationRequired) so the ccof charge from
|
|
// CreateCardOnFile succeeds.
|
|
scaToken := "cnon:sca-round9-save-tip"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &scaToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "enforced-tip-scasave",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result tip with save_card=true must skip the enforced SAVE gate and complete, body: %s", w.Body.String())
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "the SCA-tokenized save-card tip must record exactly one completed tip payment")
|
|
}
|
|
|
|
// TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the
|
|
// H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-)
|
|
// checkout COMPLETED at Square, it must RECORD the payment (mirroring the
|
|
// sweep's recordUntrackedTerminalPayment) instead of only marking the row
|
|
// COMPLETED and relying on a poll that may never come — a never-polled
|
|
// checkout would otherwise leave the charge permanently unrecorded and
|
|
// unrefundable via the app.
|
|
func TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
require.NoError(t, err)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
|
|
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
require.NoError(t, err)
|
|
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
|
|
t.Fatalf("failed to set booking in_progress: %v", err)
|
|
}
|
|
|
|
const tmpID = "tmp-completed-no-poll"
|
|
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
|
|
|
|
const sqPayID = "pay_provisional_completed_1"
|
|
origClient := SquareClient
|
|
SquareClient = &provisionalCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
checkoutID: tmpID,
|
|
result: &square.PaymentResult{
|
|
Status: "COMPLETED",
|
|
SquarePayID: sqPayID,
|
|
Amount: 5000,
|
|
Fees: 88,
|
|
CardBrand: "VISA",
|
|
CardLast4: "4242",
|
|
ReferenceID: bookingID,
|
|
},
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
require.NotNil(t, pgxTx)
|
|
require.NoError(t, pgxTx.Commit(ctx))
|
|
|
|
freshCtx := context.Background()
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
// A recorded COMPLETED provisional checkout must release the in-flight
|
|
// guard ("" returned), not wedge the booking.
|
|
got := activeTerminalCheckoutID(freshCtx, bookingID)
|
|
require.Equal(t, "", got, "a recorded COMPLETED provisional checkout must release the in-flight guard")
|
|
|
|
// The charge must be recorded as a payment row.
|
|
var payCount int
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = $2`, bookingID, sqPayID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "the never-polled COMPLETED provisional charge must be recorded as a payment")
|
|
|
|
var status string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, tmpID).Scan(&status))
|
|
require.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED")
|
|
}
|