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.
127 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|