Files
Crussell/backend/handlers/payments/idempotency_helpers_test.go
T
popertots 2cdbad0cea feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
2026-08-22 00:34:50 +01:00

127 lines
5.1 KiB
Go

//go:build test && dev
package payments
import (
"context"
"testing"
"crussell/internal/square"
)
// TestDeriveRefundIdempotencyKey_Deterministic locks the M1 property: the
// server-side refund key derived from (payment_id, amount pence, refund type)
// is DETERMINISTIC — a retry of the same logical refund re-derives the SAME
// key, so Square's idempotency dedup returns the original refund instead of
// minting a second one.
func TestDeriveRefundIdempotencyKey_Deterministic(t *testing.T) {
key1 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
key2 := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
if key1 != key2 {
t.Errorf("expected the derived refund key to be deterministic, got %q vs %q", key1, key2)
}
}
// TestDeriveRefundIdempotencyKey_DistinguishesInputs locks the M1 discriminator
// fields: the key MUST change when the payment, the amount, or the refund type
// changes (a distinct partial refund of the same amount must never collide with
// a cancellation refund of the same size).
func TestDeriveRefundIdempotencyKey_DistinguishesInputs(t *testing.T) {
base := deriveRefundIdempotencyKey("pay1234567890", 5000, "manual")
otherPayment := deriveRefundIdempotencyKey("pay9999999999", 5000, "manual")
if otherPayment == base {
t.Errorf("expected a different payment id to produce a different refund key")
}
otherAmount := deriveRefundIdempotencyKey("pay1234567890", 5001, "manual")
if otherAmount == base {
t.Errorf("expected a different amount to produce a different refund key")
}
otherType := deriveRefundIdempotencyKey("pay1234567890", 5000, "cancellation")
if otherType == base {
t.Errorf("expected a different refund type to produce a different refund key")
}
}
// TestDeriveRefundIdempotencyKey_RespectsSquareLimit locks the
// truncateIdempotencyKey semantics: every derived key stays within Square's
// 45-char /v2/refunds idempotency-key limit, even for an over-length candidate
// (a long payment id / large amount), and the truncation stays deterministic.
func TestDeriveRefundIdempotencyKey_RespectsSquareLimit(t *testing.T) {
cases := []struct {
paymentID string
amount int64
refundType string
}{
{"pay1234567890", 5000, "manual"},
{"pay1234567890", 5000, "cancellation"},
{"a-very-long-payment-id-that-exceeds-the-45-char-limit-when-combined", 999999999, "manual"},
{"pay1234567890", 999999999, "a-refund-type-that-is-itself-quite-long"},
}
for _, c := range cases {
key := deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType)
if len(key) > maxIdempotencyKeyLength {
t.Errorf("derived refund key %q (%d chars) exceeds Square's %d-char limit", key, len(key), maxIdempotencyKeyLength)
}
if key != deriveRefundIdempotencyKey(c.paymentID, c.amount, c.refundType) {
t.Errorf("derived refund key %q is not deterministic across calls", key)
}
}
}
// TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund locks
// the M1 end-to-end dedup: a refund issued with the deterministic server-side
// key, resolved by a sweep pass, and then RETRIED maps to the SAME Square refund
// (the mock's refundByKey dedup returns the original) — a second Square refund
// is never minted. This is the mechanism the refund issuance uses so a
// no-client-key retry after sweep resolution dedups onto the first refund.
func TestDeriveRefundIdempotencyKey_RetryAfterSweepResolution_NoSecondRefund(t *testing.T) {
mock := square.NewDevClient().(*square.MockClient)
payment, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-refund-dedup-payment",
})
if err != nil {
t.Fatalf("failed to seed the payment at the mock: %v", err)
}
paymentID := "payrefund000001"
amountPence := int64(5000)
// The no-client-key refund issues with the deterministic server-side key.
key := deriveRefundIdempotencyKey(paymentID, amountPence, "manual")
first, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: payment.SquarePayID,
Amount: amountPence,
IdempotencyKey: key,
Reason: "test",
})
if err != nil {
t.Fatalf("first refund failed: %v", err)
}
// After the sweep resolves the first attempt, the client retries the SAME
// logical refund. The retry re-derives the SAME deterministic key, so the
// mock's Square-style dedup returns the ORIGINAL refund — RefundKeyCount
// stays 1 and no second Square refund is minted.
retry, retryErr := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: payment.SquarePayID,
Amount: amountPence,
IdempotencyKey: deriveRefundIdempotencyKey(paymentID, amountPence, "manual"),
Reason: "test",
})
if retryErr != nil {
t.Fatalf("retry after sweep resolution failed: %v", retryErr)
}
if first.ID != retry.ID {
t.Errorf("expected the retry to dedup onto the original refund %s, got a different refund %s", first.ID, retry.ID)
}
if got := mock.RefundKeyCount(); got != 1 {
t.Errorf("expected exactly ONE Square refund after the retry, got %d distinct refund keys", got)
}
}