Files
Crussell/backend/handlers/user/twofa_failclosed_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

68 lines
2.8 KiB
Go

//go:build test
package user
// M17 follow-up (ITEM 2): twofa_prod.go is excluded from the test,dev suite
// (`!dev && !test`), so its fail-closed branches were never exercised in CI —
// the old twofa_prod_test.go only runs its assertions in a genuine production
// build and skips under the test tag. The pure decision logic now lives
// build-agnostically in twofa.go (twoFAEnsureIssueAllowedStrict /
// twoFAPepperConfigured / twoFADeliveryChannelConfigured); these tests exercise
// those branches in the STANDARD test,dev run, so a regression in the prod
// fail-closed behaviour is CI-visible even though the prod file itself is only
// compiled in a genuine production build.
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestTwoFAEnsureIssueAllowedStrict_FailClosed pins the production-style
// issuance gate that twofa_prod.go's twoFAEnsureIssueAllowed delegates to:
// (a) pepper unset → issuance refused (errTwoFAPepperRequired — an unsalted
// digest in the 1M code space would be offline-brute-forceable);
// (b) delivery channel absent → issuance refused (errTwoFADeliveryUnavailable —
// the 503-style error the handlers surface as StatusServiceUnavailable);
// (c) both configured → issuance succeeds.
func TestTwoFAEnsureIssueAllowedStrict_FailClosed(t *testing.T) {
t.Run("pepper_unset_refuses_issuance", func(t *testing.T) {
t.Setenv(twoFAPepperEnv, "")
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFAPepperRequired)
})
t.Run("delivery_channel_absent_refuses_issuance", func(t *testing.T) {
t.Setenv(twoFAPepperEnv, "test-pepper")
t.Setenv(twoFAAllowLogDeliveryEnv, "")
require.ErrorIs(t, twoFAEnsureIssueAllowedStrict(), errTwoFADeliveryUnavailable)
})
t.Run("pepper_and_channel_present_allows_issuance", func(t *testing.T) {
t.Setenv(twoFAPepperEnv, "test-pepper")
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
require.NoError(t, twoFAEnsureIssueAllowedStrict())
})
}
// TestTwoFADeliveryChannelConfigured pins the pure delivery-channel predicate
// behind the 503 refusal: only the exact value "true" opens the channel.
func TestTwoFADeliveryChannelConfigured(t *testing.T) {
t.Setenv(twoFAPepperEnv, "test-pepper")
for _, v := range []string{"", "1", "yes", "on", "True", "TRUE", "false"} {
t.Setenv(twoFAAllowLogDeliveryEnv, v)
require.False(t, twoFADeliveryChannelConfigured(), "value %q must NOT open the delivery channel (exact 'true' only)", v)
}
t.Setenv(twoFAAllowLogDeliveryEnv, "true")
require.True(t, twoFADeliveryChannelConfigured())
}
// TestTwoFAPepperConfigured pins the pure pepper predicate behind the
// pepper-required refusal.
func TestTwoFAPepperConfigured(t *testing.T) {
t.Setenv(twoFAPepperEnv, "")
require.False(t, twoFAPepperConfigured())
t.Setenv(twoFAPepperEnv, "test-pepper")
require.True(t, twoFAPepperConfigured())
}