fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
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.
This commit is contained in:
@@ -64,51 +64,57 @@ func TestLoopA_PreStartFullWithPendingDiscount_RequiresConfirmation(t *testing.T
|
||||
assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record")
|
||||
}
|
||||
|
||||
// TestLoopA_PreStartDepositWithDiscount_NeverMintsUnintendedTip locks the HIGH-1
|
||||
// deposit side: a deposit charge is net of the campaign credit, so a pre-start
|
||||
// deposit can never exceed the real remaining and never mints an unconfirmed
|
||||
// tip. A £60 deposit on the £50 booking with a 20% campaign (£10 credit)
|
||||
// charges exactly the £50 remaining — no tip record. A separate booking with a
|
||||
// deposit that WOULD overflow (discounted charge > remaining) still requires
|
||||
// confirmation.
|
||||
func TestLoopA_PreStartDepositWithDiscount_NeverMintsUnintendedTip(t *testing.T) {
|
||||
// TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation locks the
|
||||
// Loop-B A6 finding on the HIGH-1 deposit side: a deposit-with-discount charge
|
||||
// is clamped DOWN to the discounted obligation (remaining − discount), and the
|
||||
// overflow guard compares the RAW request against that obligation. A £60
|
||||
// deposit on the £50 booking with a 20% campaign (£10 credit) requests £60
|
||||
// against a £40 discounted obligation — it MUST require confirmation (the old
|
||||
// guard compared the discounted charge against the real £50 remaining, accepted
|
||||
// it and silently truncated the discount). On confirmation the full £60 is
|
||||
// charged and the £10 excess (beyond the real remaining) is carved out as a tip
|
||||
// record — never absorbed as service revenue.
|
||||
func TestLoopA_PreStartDepositWithDiscount_OverflowRequiresConfirmation(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedActiveCampaign(t, ctx, tx, 20)
|
||||
|
||||
cardToken := "cnon:loop-a-deposit-no-tip"
|
||||
cardToken := "cnon:loop-a-deposit-overflow"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 6000, // £60 deposit; chargeAmount = £50 (remaining)
|
||||
Amount: 6000, // £60 deposit; discounted obligation is £40
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "loop-a-deposit-no-tip-" + bookingID,
|
||||
IdempotencyKey: "loop-a-deposit-overflow-" + bookingID,
|
||||
}
|
||||
|
||||
handler := CreateBookingPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "a deposit whose discounted charge equals the remaining obligation must be accepted, body: %s", w.Body.String())
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "a deposit beyond the discounted obligation must require confirmation, body: %s", w.Body.String())
|
||||
assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required")
|
||||
|
||||
// No payment record may be written for the unconfirmed overflow.
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount))
|
||||
assert.Zero(t, payCount, "the unconfirmed overflow must not create any payment record")
|
||||
|
||||
// Confirmed: the full £60 is charged and the £10 excess (60 − 50 real
|
||||
// remaining) is carved out as a tip record, never absorbed as service
|
||||
// revenue.
|
||||
req.ConfirmOverflowTip = true
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "a confirmed deposit overflow must proceed, body: %s", w2.Body.String())
|
||||
|
||||
var bookingPortion float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip'`, bookingID).Scan(&bookingPortion))
|
||||
assert.InDelta(t, 50.0, bookingPortion, 0.001, "the booking portion must total the £50 obligation")
|
||||
|
||||
// No tip may be carved: the discounted charge (£50) is fully booking money.
|
||||
var tipCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount))
|
||||
assert.Zero(t, tipCount, "a pre-start deposit-with-discount must never mint a tip")
|
||||
|
||||
// A deposit that WOULD overflow into a tip requires confirmation: £61
|
||||
// deposit → chargeAmount £51 > remaining £50 → confirmation needed.
|
||||
userID2, bookingID2, _ := setupTestData(t, ctx, tx)
|
||||
userToken2 := jwt.GenerateUserToken(userID2)
|
||||
seedActiveCampaign(t, ctx, tx, 20)
|
||||
req2 := CreateBookingPaymentRequest{
|
||||
Amount: 6100,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "loop-a-deposit-overflow-" + bookingID2,
|
||||
}
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID2+"/payment", req2, userToken2, ctx)
|
||||
require.Equal(t, http.StatusBadRequest, w2.Code, "a deposit whose discounted charge exceeds the remaining must require confirmation, body: %s", w2.Body.String())
|
||||
assert.Contains(t, w2.Body.String(), "overflow_tip_confirmation_required")
|
||||
var tipAmount float64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount))
|
||||
assert.Equal(t, 1, tipCount, "the £10 excess must be carved out as a tip record")
|
||||
assert.InDelta(t, 10.0, tipAmount, 0.001, "the tip must equal the £10 excess")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user