Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
290 lines
14 KiB
Go
290 lines
14 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
// =============================================================================
|
|
// LOOP B — Round-2 money findings. Each test pins a fixed behaviour and would
|
|
// fail on the pre-fix code.
|
|
// =============================================================================
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Finding 1 — B1 re-poll race: a webhook-promoted 'completed' sweepdup refund
|
|
// whose PARENT payment row is still pending must still be resolved by the B1
|
|
// re-poll pass, and the sweep's in-flight guard must keep treating such a
|
|
// completed refund as in-flight (never re-replaying the expired key).
|
|
// =============================================================================
|
|
|
|
// seedCompletedB1RefundAndPendingParent seeds a payments-table B1 sweep
|
|
// auto-refund row with status 'completed' (as the webhook's COMPLETED
|
|
// promotion leaves it) on a still-pending parent payment — the stranded
|
|
// state the re-poll pass must resolve.
|
|
func seedCompletedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string) (paymentID, refundID string) {
|
|
t.Helper()
|
|
pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create pending parent payment: %v", err)
|
|
}
|
|
var rid string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
|
|
VALUES ($1, $2, $3, $4, 'completed', 'manual', $5, $6, $7, NOW())
|
|
RETURNING id
|
|
`, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID).Scan(&rid)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert webhook-completed B1 refund row: %v", err)
|
|
}
|
|
return pid, rid
|
|
}
|
|
|
|
// TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight locks the
|
|
// in-flight guard widening (sweep.go hasInFlightSweepDuplicateRefund): a B1
|
|
// sweepdup refund a webhook promoted to 'completed' — while the parent payment
|
|
// row is still pending — must STILL count as in-flight. Before the fix the
|
|
// guard matched only 'pending', so the next sweep re-replayed the expired key
|
|
// and minted ANOTHER charge before the re-poll pass resolved the parent. The
|
|
// failed-refund guard must stay false for a completed (not failed) refund so
|
|
// the row is never blind-failed on the replay path.
|
|
func TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight(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.CreateTestBooking(tx, userID, serviceID)
|
|
require.NoError(t, err)
|
|
|
|
paymentID, _ := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, "ref_b1_wbhk_inflight", "sweepdup-pay_dup_inflight")
|
|
|
|
assert.True(t, hasInFlightSweepDuplicateRefund(ctx, "payments", paymentID),
|
|
"a webhook-completed sweepdup refund on a pending parent must still count as in-flight")
|
|
assert.False(t, hasFailedSweepDuplicateRefund(ctx, "payments", paymentID),
|
|
"a completed (not failed) sweepdup refund must NOT trip the failed-refund guard")
|
|
}
|
|
|
|
// TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment locks
|
|
// the re-poll query widening (refunds.go sweepPendingB1Refunds): a sweepdup
|
|
// refund the webhook promoted to 'completed' — whose PARENT payment row is
|
|
// still pending — is re-polled and, when Square confirms the refund COMPLETED,
|
|
// the parent is finally marked failed. Before the fix the query matched only
|
|
// 'pending' refunds, so the completed refund was never resolved and the parent
|
|
// stayed pending forever (feeding the sweep replay loop).
|
|
func TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment(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)
|
|
|
|
const squareRefundID = "ref_b1_wbhk_completed"
|
|
const refundKey = "sweepdup-pay_dup_wbhk"
|
|
paymentID, refundID := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey)
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
require.NotNil(t, pgxTx, "no transaction in context")
|
|
require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx")
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
freshCtx := context.Background()
|
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
|
}
|
|
|
|
var refundStatus string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus))
|
|
assert.Equal(t, "completed", refundStatus, "the webhook-completed refund stays completed")
|
|
|
|
var parentStatus string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&parentStatus))
|
|
assert.Equal(t, "failed", parentStatus, "the pending parent must be resolved to failed once the refund settled")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Finding 3 — a Square APPROVED refund is NON-terminal: the call sites must
|
|
// keep the refunds row pending (with the square_refund_id recorded) so a later
|
|
// FAILED/CANCELED can demote it, instead of resolving it to 'completed' and
|
|
// stranding it.
|
|
// =============================================================================
|
|
|
|
// approvedRefundClient answers RefundPayment with Square status APPROVED —
|
|
// the ambiguous authorization-only state that must stay pending locally.
|
|
type approvedRefundClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *approvedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
|
|
return &square.RefundResult{ID: "ref_approved_test", Status: "APPROVED", Amount: req.Amount, PaymentID: req.PaymentID}, nil
|
|
}
|
|
|
|
// TestRefundPayment_ApprovedStatus_LeavesRowPending pins the MED-HIGH finding
|
|
// at the RefundPayment handler call site: a Square APPROVED refund is NOT
|
|
// terminal — resolving it to 'completed' would strand the row (the FAILED
|
|
// demotion only demotes 'pending'). The row must stay 'pending' with the
|
|
// square_refund_id recorded, and the response must report 'pending'.
|
|
func TestRefundPayment_ApprovedStatus_LeavesRowPending(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)
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
require.NoError(t, err)
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
require.NoError(t, err)
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_approved' WHERE id = $1", paymentID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &approvedRefundClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
req := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "approved-refund-" + bookingID}
|
|
w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "an APPROVED refund is non-terminal but the handler must still respond 200, body: %s", w.Body.String())
|
|
|
|
var body RefundResponse
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
assert.Equal(t, "pending", body.Status, "the response must report pending for an APPROVED refund")
|
|
|
|
var status, sqRefundID string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_refund_id, '') FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID))
|
|
assert.Equal(t, "pending", status, "an APPROVED refund must leave the row pending, never completed")
|
|
assert.Equal(t, "ref_approved_test", sqRefundID, "the square_refund_id must be recorded on the pending row so the re-poll can settle it")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Finding 6 — guest-booking cash/gift-card terminal charges must write an
|
|
// admin_audit_log row (target_user_id NULL) AFTER the money commits.
|
|
// =============================================================================
|
|
|
|
// TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget pins the MEDIUM
|
|
// finding: a CASH terminal charge on a GUEST booking (user_id NULL) previously
|
|
// wrote NO audit row (the `if customerID.Valid` guard skipped it). The audit
|
|
// must run with a NULL target_user_id — matching the till flow — after the
|
|
// money transaction commits.
|
|
func TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget(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)
|
|
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)
|
|
// Guest booking: no account behind it.
|
|
_, err = tx.Exec(ctx, `UPDATE bookings SET user_id = NULL, status = 'in_progress' WHERE id = $1`, bookingID)
|
|
require.NoError(t, err)
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
pm := "cash"
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: &pm,
|
|
IdempotencyKey: "guest-cash-audit-" + bookingID,
|
|
}
|
|
w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "a guest cash terminal charge must complete, body: %s", w.Body.String())
|
|
|
|
var auditCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM admin_audit_log
|
|
WHERE action_type = 'admin_cash_charge' AND target_user_id IS NULL
|
|
AND details->>'booking_id' = $1
|
|
`, bookingID).Scan(&auditCount))
|
|
assert.Equal(t, 1, auditCount, "a guest cash charge must audit with a NULL target_user_id")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Finding 8 — the A6 skip path (deposit fully covered by discount) must bind
|
|
// the request's idempotency key to a row so a same-key retry dedups instead of
|
|
// re-running and potentially charging the full deposit.
|
|
// =============================================================================
|
|
|
|
// TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups pins the
|
|
// idempotency fix: after a discount-covered deposit skips the Square charge,
|
|
// the discount row carries the request's idempotency key. A lost-response
|
|
// same-key retry then short-circuits on the completed row — no second skip, no
|
|
// Square call, no second discount redemption — where before it re-ran the
|
|
// handler and could charge the full deposit once the campaign had exhausted.
|
|
func TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedActiveCampaign(t, ctx, tx, 100)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:deposit-covered-dedup"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "deposit-covered-dedup-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "the discount-covered deposit must complete, body: %s", w.Body.String())
|
|
|
|
// The skip path bound the request key to the applied discount row.
|
|
var keyedDiscountCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM payments
|
|
WHERE booking_id = $1 AND payment_method = 'discount' AND idempotency_key = $2
|
|
`, bookingID, req.IdempotencyKey).Scan(&keyedDiscountCount))
|
|
assert.Equal(t, 1, keyedDiscountCount, "the request's idempotency key must be bound to the discount row")
|
|
|
|
// Same-key retry: short-circuits on the completed row — no Square charge,
|
|
// no additional discount redemption.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w2.Code, "a same-key retry must dedup to the completed result, body: %s", w2.Body.String())
|
|
|
|
var discountCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount))
|
|
assert.Equal(t, 1, discountCount, "the retry must not re-apply (double-redeem) the campaign discount")
|
|
}
|