Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed: MONEY: - CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) — a rejected auto-refund no longer re-replays the expired key every sweep run (which minted a stacking unauthorized charge each time); FAILED-webhook demotion respects the cap; never re-replay a key whose B1 refund failed - HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible campaign discount rows immediately (capped) instead of skipping with no discount recorded — no more promised-discount-not-recorded overcharge - MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed new-card+save_card charges (re-issue guard now covers req.SaveCard) - LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining now matches the authoritative tip-excluded balance) SECURITY: - MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap on critical_payment_log + refresh_token_reuse rows) - MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer clears LastMintAt on gate-verify; cleared on terminal charge success) - MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked state instead of a fresh 5-guess budget per request - MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs instead of sleeping unboundedly; login bcrypt concurrency semaphore added - LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check; email-verification per-user attempt counter DUP/MOD: - formatCurrency single source (frontend format.ts, 7 files consolidated); SquareRefundStatusToLocal single source (errors.go, all sites); admin audit-log helper dedup; SCA retry model unified (proactive on all 6 surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from backend; generateUUID at all card-form sites; magic numbers named (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card terminal charges now audited; DAV_SKIP_INIT documented in manuals Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet tags, frontend tests+build, env-docs 42/42.
876 lines
38 KiB
Go
876 lines
38 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
// Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env
|
|
// matrix, requireTwoFactorForCardAccess() gating, and the end-to-end
|
|
// enforcement of the saved-card payment paths in CreateBookingPayment /
|
|
// CreateTillSale. Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv
|
|
// must stay sequential (no t.Parallel): os.Getenv is process-global and
|
|
// t.Setenv panics under t.Parallel. Sequential tests run before this package's
|
|
// parallel batch, so the enforced env never leaks into parallel tests.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/twofa"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func helperEnvEnforce2FA(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("REQUIRE_2FA", "true")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
|
}
|
|
|
|
// seedTwoFAPendingCode stores a pending 2FA code hash + expiry for a user, the
|
|
// state the user package's deliverTwoFACode writes. The hash uses the shared
|
|
// twofa.Hash — the same single source of truth the gate verifies with.
|
|
func seedTwoFAPendingCode(t *testing.T, q db.Querier, userID, code string) {
|
|
t.Helper()
|
|
_, err := q.Exec(context.Background(), `
|
|
UPDATE users SET
|
|
two_factor_enabled = true,
|
|
two_factor_pending_code_hash = $2,
|
|
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
|
|
WHERE id = $1
|
|
`, userID, twofa.Hash(code))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestTwoFactorEnforced(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
require2FA string
|
|
squareEnv string
|
|
wantEnforced bool
|
|
}{
|
|
// Fail-closed default: empty/unknown SQUARE_ENVIRONMENT is treated as
|
|
// production-enforced, so a mistyped env var can never silently disarm
|
|
// the gate.
|
|
{"empty_env_fail_closed_enforced", "", "", true},
|
|
{"unknown_env_fail_closed_enforced", "", "staging", true},
|
|
{"require2fa_false_disables_prod", "false", "production", false},
|
|
{"require2fa_false_disables_sandbox", "false", "sandbox", false},
|
|
{"require2fa_false_disables_unknown_env", "false", "staging", false},
|
|
// REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of
|
|
// false/0/off/no (any casing) disables, nothing else does.
|
|
{"require2fa_capitalized_false_disables", "False", "production", false},
|
|
{"require2fa_uppercase_false_disables", "FALSE", "production", false},
|
|
{"require2fa_zero_disables", "0", "production", false},
|
|
{"require2fa_off_disables", "off", "production", false},
|
|
{"require2fa_uppercase_off_disables", "OFF", "production", false},
|
|
{"require2fa_no_disables", "no", "production", false},
|
|
{"require2fa_true_stays_enforced", "true", "production", true},
|
|
{"require2fa_one_stays_enforced", "1", "production", true},
|
|
{"require2fa_yes_stays_enforced", "yes", "production", true},
|
|
{"require2fa_on_stays_enforced", "on", "production", true},
|
|
{"require2fa_unknown_stays_enforced", "enable", "production", true},
|
|
{"require2fa_off_but_dev_never_enforced", "off", "mock", false},
|
|
{"production_enforced", "", "production", true},
|
|
{"sandbox_enforced", "", "sandbox", true},
|
|
{"require2fa_true_prod_enforced", "true", "production", true},
|
|
{"mock_never_enforced", "", "mock", false},
|
|
{"dev_never_enforced", "", "dev", false},
|
|
{"development_never_enforced", "", "development", false},
|
|
{"test_never_enforced", "", "test", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", tt.require2FA)
|
|
t.Setenv("SQUARE_ENVIRONMENT", tt.squareEnv)
|
|
require.Equal(t, tt.wantEnforced, twoFactorEnforced())
|
|
require.Equal(t, tt.wantEnforced, NewPaymentService().TwoFactorEnforced(), "exported wrapper must match twoFactorEnforced")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
|
|
// leg of the decision model: when the request carries a Square verification_token
|
|
// (the issuer already completed SCA), the 2FA gate is skipped entirely — even a
|
|
// user with NO 2FA setup passes, no code is demanded, and no fallback is used.
|
|
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
|
|
require.True(t, allowed, "SCA performed — the 2FA gate must be skipped")
|
|
require.False(t, fallbackUsed, "SCA is primary — the 2FA fallback was not used")
|
|
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
|
|
}
|
|
|
|
// TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured pins the
|
|
// SCA-only posture (TWO_FACTOR_FALLBACK=false): a token-less saved-card charge
|
|
// has no 2FA fallback, so the gate denies 402 with the structured
|
|
// verification_required body the frontend keys on to trigger the SCA challenge.
|
|
func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
t.Setenv("TWO_FACTOR_FALLBACK", "false")
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
|
require.False(t, allowed, "SCA-only posture: no 2FA fallback for a token-less charge")
|
|
require.False(t, fallbackUsed)
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "verification_required", body["code"])
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits pins the strict audit
|
|
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
|
|
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
|
|
// the customer actor — the operator can distinguish SCA-authorized charges from
|
|
// fallback-authorized ones — and the row's details JSON must carry the strict
|
|
// shape (sca_performed:false, fallback_reason, card_last4, reference_id, notes).
|
|
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "445566")
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_audit", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-fallback-audit",
|
|
VerificationCode: "445566",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
details := assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
|
require.Equal(t, false, details["sca_performed"])
|
|
require.Equal(t, "verification_unavailable", details["fallback_reason"])
|
|
}
|
|
|
|
// assertTwoFAFallbackAuditDetails reads the most recent '2fa_fallback_charge'
|
|
// admin_audit_log row for (target_user_id, admin_id) and asserts the strict
|
|
// insertTwoFAFallbackAudit details shape: sca_performed=false,
|
|
// fallback_reason="verification_unavailable", card_last4, reference_id, notes.
|
|
func assertTwoFAFallbackAuditDetails(t *testing.T, ctx context.Context, q db.Querier, userID, adminID, referenceID, notes, wantLast4 string) map[string]any {
|
|
t.Helper()
|
|
var detailsJSON []byte
|
|
require.NoError(t, q.QueryRow(ctx, `
|
|
SELECT details FROM admin_audit_log
|
|
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $2
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, userID, adminID).Scan(&detailsJSON))
|
|
var details map[string]any
|
|
require.NoError(t, json.Unmarshal(detailsJSON, &details))
|
|
require.Equal(t, false, details["sca_performed"], "details.sca_performed must be false")
|
|
require.Equal(t, "verification_unavailable", details["fallback_reason"], "details.fallback_reason")
|
|
require.Equal(t, wantLast4, details["card_last4"], "details.card_last4")
|
|
require.Equal(t, referenceID, details["reference_id"], "details.reference_id")
|
|
require.Equal(t, notes, details["notes"], "details.notes")
|
|
return details
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
|
|
// authorized charge (verification token present) writes NO 2fa_fallback_charge
|
|
// audit row: SCA is primary and the homegrown fallback was not used.
|
|
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_sca_audit", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
vrf := "vrf_sca_booking_audit"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-sca-audit",
|
|
VerificationToken: &vrf,
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
var auditCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'
|
|
`).Scan(&auditCount))
|
|
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_TipSavedCard_Fallback_Audit pins the strict audit
|
|
// trail on the TIP SAVE gate (CreateTipPayment with save_card=true): the
|
|
// token-less save is authorized by the 2FA fallback, and the resulting
|
|
// charge writes a 2fa_fallback_charge row for the customer actor.
|
|
func TestTwoFactorEnforced_TipSavedCard_Fallback_Audit(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "556600")
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
cardToken := "cnon:2fa-tip-save-audit"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-tip-save-audit",
|
|
VerificationCode: "556600",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit pins the strict audit
|
|
// trail on the TIP CHARGE gate (CreateTipPayment charging an existing saved
|
|
// card): the token-less charge is authorized by the 2FA fallback and writes a
|
|
// 2fa_fallback_charge row for the customer actor.
|
|
func TestTwoFactorEnforced_TipChargeSavedCard_Fallback_Audit(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "112211")
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_tip_charge_audit", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-tip-charge-audit",
|
|
VerificationCode: "112211",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateTipPayment), "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)", "4242")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit pins the strict
|
|
// audit trail on the gift-card purchase saved-card gate (giftcards.go): the
|
|
// token-less charge is authorized by the 2FA fallback and writes a
|
|
// 2fa_fallback_charge row for the customer actor with the payment id as the
|
|
// reference.
|
|
func TestTwoFactorEnforced_BuyGiftCard_SavedCard_Fallback_Audit(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "778811")
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_gc_audit", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
key := "2fa-buy-gc-fallback-audit"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 2000,
|
|
RecipientType: "self",
|
|
CardID: &cardID,
|
|
IdempotencyKey: key,
|
|
VerificationCode: "778811",
|
|
}
|
|
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx)
|
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
|
|
|
var payID string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, key).Scan(&payID))
|
|
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, payID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)", "4242")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit pins the strict audit
|
|
// trail on the add-card save gate (handlers.go CreatePaymentMethod): persisting
|
|
// a card via the 2FA fallback writes a 2fa_fallback_charge row for the customer
|
|
// actor with an empty reference id (no charge reference exists for a save).
|
|
func TestTwoFactorEnforced_PaymentMethodSave_Fallback_Audit(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
InvalidateSquareCustomerCache(userID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
|
})
|
|
token := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "998877")
|
|
|
|
req := CreatePaymentMethodRequest{
|
|
CardToken: "cnon:2fa-pm-save-audit",
|
|
VerificationCode: "998877",
|
|
}
|
|
|
|
w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
assertTwoFAFallbackAuditDetails(t, ctx, tx, userID, userID, "", "card persisted via 2FA fallback (SCA unavailable)", "4242")
|
|
}
|
|
|
|
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
|
|
// allows every request without touching the DB (no user rows are consulted).
|
|
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
|
|
// (fail-closed).
|
|
func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
|
|
t.Setenv("REQUIRE_2FA", "")
|
|
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
|
|
require.True(t, allowed, "no response must be written when not enforced")
|
|
require.False(t, fallbackUsed, "not enforced — the 2FA fallback did not authorize anything")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
t.Run("user_not_enabled_writes_403_json", func(t *testing.T) {
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
|
require.NotEmpty(t, body["error"])
|
|
})
|
|
|
|
t.Run("user_enabled_but_no_code_writes_403_json", func(t *testing.T) {
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
// B10: the enabled setup flag alone must NOT unlock the gate.
|
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
})
|
|
|
|
t.Run("user_enabled_with_valid_code_allows", func(t *testing.T) {
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
require.True(t, allowed)
|
|
require.True(t, fallbackUsed, "a code-verified token-less charge uses the 2FA fallback")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
})
|
|
|
|
t.Run("user_enabled_with_wrong_code_writes_400_json", func(t *testing.T) {
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "Invalid verification code", body["error"])
|
|
})
|
|
|
|
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
|
|
require.False(t, ok)
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
|
require.NotEmpty(t, body["error"])
|
|
})
|
|
}
|
|
|
|
// TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume pins the MEDIUM-2
|
|
// contract: the charge gate verifies the code WITHOUT consuming it (consume
|
|
// happens later, at the charge's terminal SUCCESS state via
|
|
// twofa.ConsumePendingCode), so a failed/ambiguous Square charge does NOT burn
|
|
// the operator-relayed code — a same-key retry can re-verify the SAME code.
|
|
// Only an explicit ConsumePendingCode (the completed-charge path) NULLs it,
|
|
// after which the code is dead ("expired").
|
|
func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
seedTwoFAPendingCode(t, tx, userID, "424242")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
require.True(t, allowed, "first gate pass must succeed")
|
|
require.True(t, fallbackUsed, "the code verification is the 2FA fallback")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// The code must still be present — the gate verified WITHOUT consuming.
|
|
var pendingHash sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
|
require.True(t, pendingHash.Valid, "the gate must NOT consume the code (MEDIUM-2)")
|
|
|
|
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
|
|
w = httptest.NewRecorder()
|
|
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
require.True(t, allowed, "a not-yet-consumed code must pass the gate again on retry")
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Consumption happens at the charge's terminal SUCCESS state.
|
|
require.NoError(t, twofa.ConsumePendingCode(ctx, tx, userID))
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
|
require.False(t, pendingHash.Valid, "ConsumePendingCode must NULL the pending code")
|
|
|
|
// A further attempt with the consumed code is denied as expired.
|
|
w = httptest.NewRecorder()
|
|
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
|
require.False(t, allowed, "a consumed code must not pass the gate")
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Equal(t, "Verification code expired — request a new one", body["error"])
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
|
|
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 with
|
|
// no payment row and no saved card (Square never called).
|
|
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-save-card-blocked",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Contains(t, body["error"], "Two-factor")
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
|
require.Zero(t, payCount, "blocked 2FA request must not create a payment row")
|
|
var cardCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount))
|
|
require.Zero(t, cardCount, "blocked 2FA request must not persist a card")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
|
|
// gate on charging an existing saved card.
|
|
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-saved-card-blocked",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Contains(t, body["error"], "Two-factor")
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
|
require.Zero(t, payCount, "blocked saved-card charge must not create a payment row")
|
|
}
|
|
|
|
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "112233")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-save-card-ok",
|
|
VerificationCode: "112233",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount)
|
|
}
|
|
|
|
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "334455")
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-saved-card-ok",
|
|
VerificationCode: "334455",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
}
|
|
|
|
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
|
|
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
|
|
// when enforced.
|
|
func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "2fa-new-card-not-gated",
|
|
}
|
|
|
|
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
|
|
// saved-card charge path: an admin charging a customer's saved card while the
|
|
// card's owner has no 2FA is blocked with 403 and no till_sale is created.
|
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: "2fa-till-saved-blocked",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
|
var body map[string]string
|
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
|
require.Contains(t, body["error"], "Two-factor")
|
|
|
|
var saleCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount))
|
|
require.Zero(t, saleCount, "blocked till saved-card sale must not create a till_sale row")
|
|
}
|
|
|
|
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
seedTwoFAPendingCode(t, tx, userID, "556677")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: "2fa-till-saved-ok",
|
|
VerificationCode: "556677",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted
|
|
// pins the Loop B MEDIUM gate-ordering fix: the save-card 2FA gate runs AFTER
|
|
// the idempotency dedup's completed short-circuit. A same-key lost-response
|
|
// retry re-sends the SAME single-use verification code that the original
|
|
// attempt already consumed; if the gate ran first it would 400 "Verification
|
|
// code expired". With the gate below the dedup, the retry returns the
|
|
// already-completed payment instead of re-entering the gate.
|
|
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "778899")
|
|
|
|
cardToken := "cnon:2fa-save-card-retry"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "2fa-save-card-retry",
|
|
VerificationCode: "778899",
|
|
}
|
|
|
|
handler := withNonGuest(CreateBookingPayment)
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
// Same-key retry re-sends the identical request, whose code is now
|
|
// consumed. The completed-dedup must return the payment before the gate.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed payment, not re-run the gate: %s", w2.Body.String())
|
|
|
|
var payCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
|
require.Equal(t, 1, payCount, "the retry must not create a second payment")
|
|
}
|
|
|
|
// TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted pins
|
|
// the Loop B MEDIUM gate-ordering fix on the tip endpoint: the saved-card
|
|
// charge 2FA gate runs AFTER the tip idempotency dedup's completed
|
|
// short-circuit. A same-key lost-response retry re-sends the SAME single-use
|
|
// verification code the original attempt consumed; with the gate first it would
|
|
// 400 "expired", with the gate below the dedup the retry returns the completed
|
|
// tip instead.
|
|
func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *testing.T) {
|
|
helperEnvEnforce2FA(t)
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
seedTwoFAPendingCode(t, tx, userID, "667788")
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_tip_retry", "VISA", "4321")
|
|
require.NoError(t, err)
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
CardID: &cardID,
|
|
IdempotencyKey: "2fa-tip-saved-card-retry",
|
|
VerificationCode: "667788",
|
|
}
|
|
|
|
handler := withNonGuest(CreateTipPayment)
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
// Same-key retry re-sends the identical request, whose code is now
|
|
// consumed. The completed-dedup must return the tip before the gate.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed tip, not re-run the gate: %s", w2.Body.String())
|
|
|
|
var tipCount int
|
|
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
|
|
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
|
|
}
|
|
|
|
// TestTwoFactorFallbackEnabled pins the TWO_FACTOR_FALLBACK policy-switch
|
|
// parse matrix (twofa.go:100-107): false/0/off/no are case-insensitive
|
|
// DISABLES; every other value — empty, unset, unknown, true/1/on/yes — keeps
|
|
// the fallback enabled (the shipped default). The exported
|
|
// PaymentService.TwoFactorFallbackEnabled wrapper must match the unexported
|
|
// predicate. Sequential (no t.Parallel): flips process-global env vars.
|
|
func TestTwoFactorFallbackEnabled(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
val string
|
|
want bool
|
|
}{
|
|
{"false disables", "false", false},
|
|
{"zero disables", "0", false},
|
|
{"off disables", "off", false},
|
|
{"no disables", "no", false},
|
|
{"case_insensitive_False_disables", "False", false},
|
|
{"case_insensitive_OFF_disables", "OFF", false},
|
|
{"case_insensitive_No_disables", "No", false},
|
|
{"empty_keeps_enabled", "", true},
|
|
{"unknown_keeps_enabled", "enable", true},
|
|
{"true_keeps_enabled", "true", true},
|
|
{"one_keeps_enabled", "1", true},
|
|
{"on_keeps_enabled", "on", true},
|
|
{"yes_keeps_enabled", "yes", true},
|
|
{"case_insensitive_TRUE_keeps_enabled", "TRUE", true},
|
|
{"case_insensitive_ON_keeps_enabled", "ON", true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("TWO_FACTOR_FALLBACK", tt.val)
|
|
require.Equal(t, tt.want, twoFactorFallbackEnabled())
|
|
require.Equal(t, tt.want, NewPaymentService().TwoFactorFallbackEnabled(), "exported wrapper must match twoFactorFallbackEnabled")
|
|
})
|
|
}
|
|
t.Run("unset_keeps_enabled_default", func(t *testing.T) {
|
|
prev, had := os.LookupEnv("TWO_FACTOR_FALLBACK")
|
|
os.Unsetenv("TWO_FACTOR_FALLBACK")
|
|
defer func() {
|
|
if had {
|
|
os.Setenv("TWO_FACTOR_FALLBACK", prev)
|
|
} else {
|
|
os.Unsetenv("TWO_FACTOR_FALLBACK")
|
|
}
|
|
}()
|
|
require.True(t, twoFactorFallbackEnabled())
|
|
require.True(t, NewPaymentService().TwoFactorFallbackEnabled())
|
|
})
|
|
}
|
|
|
|
// TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue documents the dev/test
|
|
// delivery predicate: twofa_delivery_dev.go (`dev || test`) always reports a
|
|
// delivery channel (the [2FA] log relay), so the 503 "2FA requires an email or
|
|
// SMS delivery channel" branch (twofa.go:193) is UNREACHABLE in this build.
|
|
// The production predicate — TWO_FACTOR_ALLOW_LOG_DELIVERY gating — is covered
|
|
// by twofa_delivery_prod_test.go in a !dev build (see its header for the
|
|
// documented limitation).
|
|
func TestTwoFADeliveryAvailable_DevBuild_TriviallyTrue(t *testing.T) {
|
|
require.True(t, twoFADeliveryAvailable())
|
|
}
|
|
|
|
// TestNotificationsCapExceeded pins finding 1: the GLOBAL cap on unacknowledged
|
|
// 'critical_payment_log' admin notifications (maxUnacknowledgedNotifications)
|
|
// suppresses new inserts once the unacknowledged queue reaches the cap, so a
|
|
// hostile flood of attacker-registered accounts cannot bury the single-operator
|
|
// notification centre. Acknowledging rows re-arms inserts. The reissue-fail
|
|
// alert site (twofa.go) checks this helper before calling the sweep's
|
|
// insertCriticalPaymentNotification; the same pattern is documented for the
|
|
// sweep.go and auth/jwt.go insert sites (see the coordination note in twofa.go).
|
|
func TestNotificationsCapExceeded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
// Empty queue → below the cap, inserts allowed.
|
|
require.False(t, notificationsCapExceeded(ctx, "critical_payment_log"))
|
|
|
|
// Fill the unacknowledged queue to the cap.
|
|
_, err = tx.Exec(ctx, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`)
|
|
require.NoError(t, err)
|
|
for i := 0; i < maxUnacknowledgedNotifications; i++ {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO admin_notifications (reason, user_id, created_at)
|
|
VALUES ('critical_payment_log', $1, NOW())
|
|
`, userID)
|
|
require.NoError(t, err)
|
|
}
|
|
require.True(t, notificationsCapExceeded(ctx, "critical_payment_log"), "at the cap the insert must be suppressed")
|
|
|
|
// Acknowledging one row drops below the cap → inserts re-arm.
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE admin_notifications SET acknowledged_at = NOW()
|
|
WHERE ctid = (
|
|
SELECT ctid FROM admin_notifications
|
|
WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL
|
|
LIMIT 1
|
|
)
|
|
`)
|
|
require.NoError(t, err)
|
|
require.False(t, notificationsCapExceeded(ctx, "critical_payment_log"), "acknowledging one row must re-arm inserts")
|
|
}
|