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:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+332
View File
@@ -438,6 +438,47 @@ func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) {
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending")
}
func TestDevClient_PaymentWasRefunded_StatusSet(t *testing.T) {
// Locks the mock's PaymentWasRefunded status set against the real client's
// reconciliation source (COMPLETED/APPROVED/PENDING → true; FAILED/REJECTED
// → false): handler-side REFUND_AMOUNT_INVALID reconciliation must behave
// identically in dev/mock and production.
client := NewDevClient().(*MockClient)
ctx := context.Background()
now := time.Now().UTC()
for _, tc := range []struct {
status string
want bool
}{
{"COMPLETED", true},
{"APPROVED", true},
{"PENDING", true},
{"FAILED", false},
{"REJECTED", false},
} {
client.mu.Lock()
id := fmt.Sprintf("ref_mock_%d", now.UnixNano())
client.refunds[id] = &RefundResult{
ID: id,
Status: tc.status,
Amount: 5000,
PaymentID: "pay_mock_was_refunded",
CreatedAt: now.Format(time.RFC3339),
}
client.mu.Unlock()
got, err := client.PaymentWasRefunded(ctx, "pay_mock_was_refunded")
require.NoError(t, err)
assert.Equal(t, tc.want, got, "status %s", tc.status)
}
// A different payment with no refunds reports false.
got, err := client.PaymentWasRefunded(ctx, "pay_mock_no_refunds")
require.NoError(t, err)
assert.False(t, got)
}
func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) {
// Any other code configured via FailRefundCode preserves the prior
// ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod).
@@ -1890,3 +1931,294 @@ func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) {
assert.Equal(t, completed.ID, got.ID)
assert.Equal(t, "COMPLETED", got.Status)
}
// TestDevClient_DeleteCardOnFile_CcofResolution locks the DeleteCardOnFile
// ccof: resolution fix: production callers pass the DB-stored ccof: card
// reference (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must
// resolve through cardByToken so the deletion actually finds and disables the
// card — previously the mock keyed only by its mock-local ID (mock_card_...)
// and silently missed every ccof: call.
func TestDevClient_DeleteCardOnFile_CcofResolution(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
userID := "user-ccof-delete"
t.Run("ccof_card_id_disables_and_hides", func(t *testing.T) {
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-ccof", "cus_test123")
require.NoError(t, err)
require.True(t, strings.HasPrefix(card.CardID, "ccof:"), "mock CardID must be ccof:-prefixed to exercise the cardByToken path")
err = client.DeleteCardOnFile(ctx, card.CardID)
require.NoError(t, err, "deleting by the DB-stored ccof: CardID must resolve the card through cardByToken")
// The card object itself must be disabled, not just hidden.
client.mu.RLock()
deleted := client.cardByToken[card.CardID]
client.mu.RUnlock()
require.NotNil(t, deleted, "the ccof: token must remain resolvable after deletion")
assert.False(t, deleted.Enabled, "the ccof: resolved card must be disabled")
// Square's List Cards API excludes disabled cards by default — the
// deleted card disappears from GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
assert.Empty(t, cards, "a card deleted by its ccof: CardID must disappear from GetCardsOnFile")
})
t.Run("mock_local_id_still_works", func(t *testing.T) {
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-local", "cus_test123")
require.NoError(t, err)
err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err, "deleting by the mock-local ID (mock_card_...) must keep working via the per-user fallback")
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
assert.Empty(t, cards, "a card deleted by its mock-local ID must also disappear from GetCardsOnFile")
})
t.Run("unknown_id_errors_not_silent", func(t *testing.T) {
err := client.DeleteCardOnFile(ctx, "ccof:never-created")
require.Error(t, err, "an unknown card ID must return an error, never silent success")
assert.Contains(t, err.Error(), "card not found")
})
}
// TestDevClient_RefundPayment_UnknownPayMockID_NotFound locks the mock's
// NOT_FOUND strictness: a refund targeting a "pay_mock_*" ID that was never
// created is a provable bug (that charge never went through this mock) and real
// Square answers 404 NOT_FOUND — the mock must surface the structured error,
// never silently proceed.
func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: "pay_mock_never_created",
Amount: 5000,
IdempotencyKey: "refund-unknown-pay-mock",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "NOT_FOUND", ErrorCode(err))
assert.Equal(t, http.StatusNotFound, ErrorStatusCode(err))
client.mu.RLock()
defer client.mu.RUnlock()
assert.Len(t, client.refunds, 0, "no refund must be stored for an unknown pay_mock_* payment")
assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored for an unknown pay_mock_* payment")
}
// TestDevClient_RefundPayment_OverRefund locks the mock's real Square
// over-refund rejection: refunding more than the remaining balance answers 400
// REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded
// payment, so — exactly like the real client — the mock reconciles: no refund
// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an
// existing refund (money already moved) → ErrRefundAlreadyProcessed. The
// exact-remaining boundary refund succeeds.
func TestDevClient_RefundPayment_OverRefund(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
t.Run("over_refund_no_prior_refunds_is_declined", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-fresh",
})
require.NoError(t, err)
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 12000, IdempotencyKey: "refund-overrefund-fresh",
})
require.Error(t, err)
assert.Nil(t, result)
// The mock builds a squareAPIError with Code REFUND_AMOUNT_INVALID but
// wraps it with %v (exactly like the real client's sentinel wrap), so
// the code is not reachable via ErrorCode(err) — the observable
// contract is the ErrRefundDeclined sentinel.
assert.True(t, errors.Is(err, ErrRefundDeclined), "a genuine over-refund with no prior refunds must be ErrRefundDeclined, got %v", err)
assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed))
})
t.Run("exact_remaining_refund_succeeds", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-exact-remaining",
})
require.NoError(t, err)
// A partial refund leaves 7000 remaining.
first, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-partial",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
// Refunding exactly the remaining balance is NOT an over-refund.
second, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 7000, IdempotencyKey: "refund-exact-remaining",
})
require.NoError(t, err)
assert.Equal(t, int64(7000), second.Amount)
assert.Equal(t, "COMPLETED", second.Status)
})
t.Run("over_refund_with_existing_refund_is_already_processed", func(t *testing.T) {
payment, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing",
})
require.NoError(t, err)
// A COMPLETED refund moves money; the remaining balance drops to 7000.
first, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-first-move",
})
require.NoError(t, err)
require.Equal(t, "COMPLETED", first.Status)
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing",
})
require.Error(t, err)
assert.Nil(t, result)
// The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v
// sentinel wrap (ErrorCode returns ""), so the observable contract is
// the ErrRefundAlreadyProcessed sentinel.
assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds must reconcile to ErrRefundAlreadyProcessed, got %v", err)
assert.False(t, errors.Is(err, ErrRefundDeclined))
})
}
// TestDevClient_RefundPayment_LenientPathForNonMockIDs locks the lenient refund
// path: payment IDs that are NOT "pay_mock_*" (e.g. the DB-fixture
// square_payment_id values like "sqp_..." that sweep tests seed refunds
// against) exist outside the mock's ledger — exactly as they would at real
// Square — so RefundPayment processes them without the NOT_FOUND rejection.
func TestDevClient_RefundPayment_LenientPathForNonMockIDs(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
result, err := client.RefundPayment(ctx, RefundPaymentReq{
PaymentID: "sqp_fixture_123",
Amount: 5000,
IdempotencyKey: "refund-lenient-sqp",
})
require.NoError(t, err, "a non-mock fixture payment ID must take the lenient path, not NOT_FOUND")
assert.Equal(t, "COMPLETED", result.Status)
assert.Equal(t, int64(5000), result.Amount)
assert.Equal(t, "sqp_fixture_123", result.PaymentID)
}
// TestDevClient_CreatePayment_SimulateVerificationRequired locks the mock's SCA
// enforcement: with SimulateVerificationRequired=true, a new-card (cnon:) charge
// without a 3DS/SCA verification token is rejected with a structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error the buyer must
// resolve by re-verifying — never retried as-is); a present verification token
// (verify_mock_...) satisfies the gate; and with the toggle off (default) no
// verification is required.
func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateVerificationRequired = true
ctx := context.Background()
t.Run("cnon_without_verification_token_is_rejected", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-no-token",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
})
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-with-token",
VerificationToken: "verify_mock_ok",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status)
})
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
client.SimulateVerificationRequired = false
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-default-off",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", result.Status, "with SimulateVerificationRequired off (default), no verification token is required")
})
}
// TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption locks the
// mock's single-use cnon: nonce simulation on CreatePayment: with
// SimulateSourceUsed enabled, a cnon used in one CreatePayment is rejected with
// CARD_TOKEN_USED on a second CreatePayment under a DIFFERENT idempotency key;
// ccof: (card-on-file) sources are NEVER consumed (they are stored references,
// not single-use nonces); and with the toggle off (default) the same cnon can
// be reused freely.
func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T) {
client := NewDevClient().(*MockClient)
client.SimulateSourceUsed = true
ctx := context.Background()
t.Run("cnon_reuse_rejected_with_card_token_used", func(t *testing.T) {
source := "cnon:single-use-pay"
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
// Second CreatePayment with the SAME cnon under a DIFFERENT key →
// Square's CARD_TOKEN_USED rejection (the CreatePayment code for a used
// source).
result, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-2",
})
require.Error(t, err)
assert.Nil(t, result)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err))
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.Contains(t, client.UsedSources(), source, "the consumed cnon must be reported by UsedSources")
})
t.Run("ccof_sources_are_never_consumed", func(t *testing.T) {
// Create the card with the toggle off so the underlying cnon is not
// consumed; CreatePayment charges the ccof: CardID, which is a stored
// reference rather than a single-use nonce.
client.SimulateSourceUsed = false
card, err := client.CreateCardOnFile(ctx, "user-ccof-never-consumed", "cnon:card-src", "cus_test123")
require.NoError(t, err)
client.SimulateSourceUsed = true
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
second, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-2",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", second.Status, "a ccof: card must be chargeable again under a different key")
assert.NotContains(t, client.UsedSources(), card.CardID, "ccof: sources must never be consumed")
})
t.Run("toggle_off_allows_reuse", func(t *testing.T) {
client.SimulateSourceUsed = false
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-1",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", first.Status)
second, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-2",
})
require.NoError(t, err)
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
})
}