feat: frontend SCA-only posture — tokenize-result as charge source (C1), C6 refusal dialog, no 2FA fallback
- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine 'sca-unavailable' now drives the REFUSAL path (the customer is told the payment cannot complete and to pay online later), never the 2FA code fallback (PSR 2017 SCA is non-waivable; merchant liability is not cured by consent). SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1' + scaFallbackConsentFields() carry the versioned consent on the explicit opt-in path only (shipped surfaces send none). SquareTokenizeResult docs updated: tokenize-result token is the charge source, tokenless OK proceeds token-less under the backend's SCA-only gate. - C1 wire contract on every saved-card surface (booking, tip, gift-card buy, till, account): the proactive SCA tokenize-result is sent as new_card_token (the charge SOURCE alongside the saved-card ref), never the legacy verification_token; 402 verification-required now means the tokenize-result was consumed/expired between tokenize and charge. - New ScaFallbackConsentDialog surfaces the refusal notice; the code input (useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via a backend gate rejection (defensive/opt-in). - Till (M10): proactive saved-card SCA runs per sale line BEFORE the first charge; sca-unavailable aborts the whole sale before any charge. - Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization; 402 verification-required on save surfaces SCA-first guidance instead of a generic failure.
This commit is contained in:
@@ -3,6 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
SCA_FALLBACK_CONSENT_VERSION,
|
||||
SCA_REFUSAL_MESSAGE_ONLINE,
|
||||
SCA_REFUSAL_MESSAGE_TILL,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
@@ -17,8 +20,10 @@ import {
|
||||
parseTokenizeVerificationResult,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
shouldFallbackTo2FA,
|
||||
submitPaymentWithRetry
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
type SavedCardVerificationOutcome
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
|
||||
@@ -306,7 +311,7 @@ describe('isVerificationRequiredSignal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFallbackTo2FA', () => {
|
||||
describe('shouldShowSCARefusal', () => {
|
||||
it.each([
|
||||
['sca-unavailable', true],
|
||||
['verified', false],
|
||||
@@ -314,19 +319,59 @@ describe('shouldFallbackTo2FA', () => {
|
||||
['sca-failed', false],
|
||||
['', false]
|
||||
])('outcome %s → %s', (outcome, expected) => {
|
||||
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
|
||||
expect(shouldShowSCARefusal(outcome)).toBe(expected);
|
||||
});
|
||||
|
||||
it('demotes to the 2FA gate only on sca-unavailable', () => {
|
||||
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true);
|
||||
it('refuses the charge only on sca-unavailable (C6 SCA-only posture)', () => {
|
||||
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps SCA primary after a successful verification', () => {
|
||||
expect(shouldFallbackTo2FA('verified')).toBe(false);
|
||||
expect(shouldShowSCARefusal('verified')).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
|
||||
expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false);
|
||||
expect(shouldShowSCARefusal('challenge-cancelled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C6 SCA-unavailable refusal (no 2FA fallback for saved-card charges)', () => {
|
||||
it('online refusal tells the customer the payment failed and can be retried', () => {
|
||||
expect(SCA_REFUSAL_MESSAGE_ONLINE.length).toBeGreaterThan(0);
|
||||
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('not');
|
||||
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('did not go through');
|
||||
// Online payments are refused as failed — never "pay online later" (the
|
||||
// customer IS online; the deposit/gift-card purchase simply fails).
|
||||
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('pay online later');
|
||||
// The refusal must NOT offer the verification-code fallback.
|
||||
expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('verification code');
|
||||
});
|
||||
|
||||
it('till refusal is the only surface that invites paying online later', () => {
|
||||
expect(SCA_REFUSAL_MESSAGE_TILL.length).toBeGreaterThan(0);
|
||||
// Only the in-person till surface may suggest paying online later.
|
||||
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).toContain('pay online later');
|
||||
// It still must NOT offer the verification-code fallback.
|
||||
expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).not.toContain('verification code');
|
||||
});
|
||||
|
||||
it('the refusal path never sends consent_accepted on its own', () => {
|
||||
// A refused charge (sca-unavailable) shows the refusal and proceeds
|
||||
// nowhere — the versioned consent payload exists only for the explicit
|
||||
// opt-in path (TWO_FACTOR_FALLBACK deployments) via
|
||||
// scaFallbackConsentFields(true), which the refusal never reaches: the
|
||||
// charge surfaces always call it with consent unaccepted, so it yields
|
||||
// an empty object and no consent_accepted/consent_version is sent.
|
||||
expect(shouldShowSCARefusal('sca-unavailable')).toBe(true);
|
||||
expect(scaFallbackConsentFields(false)).toEqual({});
|
||||
});
|
||||
|
||||
it('SCA_FALLBACK_CONSENT_VERSION stays versioned for the explicit opt-in path', () => {
|
||||
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
|
||||
expect(scaFallbackConsentFields(true)).toEqual({
|
||||
consent_version: SCA_FALLBACK_CONSENT_VERSION,
|
||||
consent_accepted: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -363,7 +408,7 @@ describe('parseTokenizeVerificationResult', () => {
|
||||
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
|
||||
});
|
||||
|
||||
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
|
||||
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (C6 refusal)', () => {
|
||||
expect(
|
||||
parseTokenizeVerificationResult({
|
||||
status: 'FAILED',
|
||||
@@ -669,6 +714,23 @@ describe('adminRequestNewTwoFactorCode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SCA-unavailable fallback consent (C6)', () => {
|
||||
it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => {
|
||||
expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/);
|
||||
});
|
||||
|
||||
it('scaFallbackConsentFields returns the versioned payload when accepted', () => {
|
||||
expect(scaFallbackConsentFields(true)).toEqual({
|
||||
consent_version: SCA_FALLBACK_CONSENT_VERSION,
|
||||
consent_accepted: true
|
||||
});
|
||||
});
|
||||
|
||||
it('scaFallbackConsentFields returns an empty object when not accepted', () => {
|
||||
expect(scaFallbackConsentFields(false)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('depositChargePence', () => {
|
||||
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
|
||||
expect(depositChargePence(2000, 500)).toBe(1500);
|
||||
@@ -686,3 +748,333 @@ describe('depositChargePence', () => {
|
||||
expect(depositChargePence(2000, 0)).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
// The mock card form's saved-card SCA helper drives every outcome of
|
||||
// runSavedCardSCAProactively. tokenizeSavedCardWithVerification dynamically
|
||||
// imports MockCardForm when isSquareMock() is true; the vi.hoisted mock lets
|
||||
// the tests choose the tokenize result per scenario, so the outcome mapping
|
||||
// (verified / challenge-cancelled / sca-unavailable / sca-failed) and the
|
||||
// onOutcome wiring are exercised through the real shared implementation.
|
||||
const mockSCATokenize = vi.hoisted(() => ({ fn: vi.fn() }));
|
||||
vi.mock('$lib/components/payments/MockCardForm.svelte', () => ({
|
||||
tokenizeSavedCard: mockSCATokenize.fn
|
||||
}));
|
||||
|
||||
describe('runSavedCardSCAProactively', () => {
|
||||
async function loadSquareInMockMode(): Promise<typeof SquareModule> {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock');
|
||||
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', '');
|
||||
vi.stubEnv('VITE_SQUARE_LOCATION_ID', '');
|
||||
vi.stubEnv('DEV', true);
|
||||
return await import('./square');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
mockSCATokenize.fn.mockClear();
|
||||
});
|
||||
|
||||
it('maps a verified tokenize result and records it via onOutcome', async () => {
|
||||
mockSCATokenize.fn.mockResolvedValue({
|
||||
verificationToken: 'verify_mock_ok',
|
||||
outcome: 'verified'
|
||||
});
|
||||
const mod = await loadSquareInMockMode();
|
||||
const outcomes: SavedCardVerificationOutcome[] = [];
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:test-card',
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('verified');
|
||||
expect(res.verificationToken).toBe('verify_mock_ok');
|
||||
expect(outcomes).toEqual(['verified']);
|
||||
});
|
||||
|
||||
it('maps a challenge-cancelled tokenize result (retryable via SCA, never 2FA)', async () => {
|
||||
mockSCATokenize.fn.mockResolvedValue({
|
||||
verificationToken: null,
|
||||
outcome: 'challenge-cancelled'
|
||||
});
|
||||
const mod = await loadSquareInMockMode();
|
||||
const outcomes: SavedCardVerificationOutcome[] = [];
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:test-card',
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('challenge-cancelled');
|
||||
expect(res.verificationToken).toBeUndefined();
|
||||
expect(outcomes).toEqual(['challenge-cancelled']);
|
||||
});
|
||||
|
||||
it('maps an sca-unavailable tokenize result (C6 refusal)', async () => {
|
||||
mockSCATokenize.fn.mockResolvedValue({
|
||||
verificationToken: null,
|
||||
outcome: 'sca-unavailable'
|
||||
});
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:test-card',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.outcome).toBe('sca-unavailable');
|
||||
expect(res.verificationToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps an sca-failed tokenize result', async () => {
|
||||
mockSCATokenize.fn.mockResolvedValue({
|
||||
verificationToken: null,
|
||||
outcome: 'sca-failed'
|
||||
});
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:test-card',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.outcome).toBe('sca-failed');
|
||||
});
|
||||
|
||||
it('demotes to sca-unavailable when no saved card is resolved', async () => {
|
||||
const mod = await loadSquareInMockMode();
|
||||
const outcomes: SavedCardVerificationOutcome[] = [];
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: '',
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('sca-unavailable');
|
||||
expect(outcomes).toEqual(['sca-unavailable']);
|
||||
expect(mockSCATokenize.fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the deterministic dev token when the mock tokenizer throws', async () => {
|
||||
// tokenizeSavedCardWithVerification swallows a throwing MockCardForm
|
||||
// tokenizer (dev-mock convenience) and returns the deterministic fake
|
||||
// token — so the shared runner reports verified, never a dead-end.
|
||||
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
|
||||
const mod = await loadSquareInMockMode();
|
||||
const outcomes: SavedCardVerificationOutcome[] = [];
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:test-card',
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('verified');
|
||||
expect(res.verificationToken).toMatch(/^verify_mock_/);
|
||||
expect(outcomes).toEqual(['verified']);
|
||||
});
|
||||
});
|
||||
|
||||
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs:
|
||||
// this vitest config has no svelte plugin, so `$state` / `$derived` are
|
||||
// provided as plain-value globals (no reactivity — each composable instance is
|
||||
// a state snapshot). That still exercises the full imperative surface: the
|
||||
// consent accept/decline flags, the code setter, the mint selection and the
|
||||
// requestNewCode toast flow for every outcome.
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() }
|
||||
}));
|
||||
vi.mock('svelte-sonner', () => toastMocks);
|
||||
vi.mock('$lib/square/square', () => ({
|
||||
requestNewTwoFactorCode: vi.fn(async () => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
message: 'sent'
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('useTwoFactorCodeForSavedCard', () => {
|
||||
async function loadComposable() {
|
||||
vi.stubGlobal('$state', (v: unknown) => v);
|
||||
vi.stubGlobal('$derived', (v: unknown) => v);
|
||||
const mod = (await import('../stores/twoFactorCode.svelte')) as {
|
||||
useTwoFactorCodeForSavedCard: (options: {
|
||||
enabled: () => boolean;
|
||||
gateActive: () => boolean;
|
||||
mint?: () => Promise<{ status: number; ok: boolean; message: string }>;
|
||||
scaAvailable?: () => boolean;
|
||||
}) => {
|
||||
code: string;
|
||||
setCode: (v: string) => void;
|
||||
reveal: boolean;
|
||||
showInput: boolean;
|
||||
missing: boolean;
|
||||
requesting: boolean;
|
||||
requestNewCode: () => Promise<void>;
|
||||
consentAccepted: boolean;
|
||||
acceptConsent: () => void;
|
||||
declineConsent: () => void;
|
||||
};
|
||||
};
|
||||
return mod;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts in a clean state with the 2FA input hidden', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true
|
||||
});
|
||||
expect(comp.showInput).toBe(false);
|
||||
expect(comp.missing).toBe(false);
|
||||
expect(comp.reveal).toBe(false);
|
||||
expect(comp.consentAccepted).toBe(false);
|
||||
expect(comp.code).toBe('');
|
||||
expect(comp.requesting).toBe(false);
|
||||
});
|
||||
|
||||
it('still exposes the 2FA code state for account/admin uses (C6)', async () => {
|
||||
// The composable keeps the full code-state API even though charge
|
||||
// surfaces no longer auto-trigger it: code, setCode, reveal, missing,
|
||||
// requestNewCode and the consent flags all remain available.
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true
|
||||
});
|
||||
comp.setCode('123456');
|
||||
expect(comp.code).toBe('123456');
|
||||
comp.reveal = true;
|
||||
expect(comp.reveal).toBe(true);
|
||||
expect(comp.consentAccepted).toBe(false);
|
||||
comp.acceptConsent();
|
||||
expect(comp.consentAccepted).toBe(true);
|
||||
});
|
||||
|
||||
it('an sca-unavailable outcome alone never surfaces the 2FA input (C6 refusal)', async () => {
|
||||
// C6: even when SCA is reported unavailable, the code input must NOT
|
||||
// appear without the explicit opt-in consent (or a backend reveal) —
|
||||
// the charge surfaces refuse instead, so no verification-code fallback
|
||||
// is ever offered off an SCA outcome on its own.
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true,
|
||||
scaAvailable: () => false
|
||||
});
|
||||
expect(comp.consentAccepted).toBe(false);
|
||||
expect(comp.showInput).toBe(false);
|
||||
expect(comp.missing).toBe(false);
|
||||
});
|
||||
|
||||
it('acceptConsent reveals the input; declineConsent hides it again', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true
|
||||
});
|
||||
comp.acceptConsent();
|
||||
expect(comp.reveal).toBe(true);
|
||||
expect(comp.consentAccepted).toBe(true);
|
||||
comp.declineConsent();
|
||||
expect(comp.reveal).toBe(false);
|
||||
expect(comp.consentAccepted).toBe(false);
|
||||
});
|
||||
|
||||
it('setCode populates the code the caller later submits', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true
|
||||
});
|
||||
comp.setCode('123456');
|
||||
expect(comp.code).toBe('123456');
|
||||
});
|
||||
|
||||
it('requestNewCode clears the code and toasts the server message on success', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const mint = vi.fn(async () => ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
message: 'A new verification code has been sent.'
|
||||
}));
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true,
|
||||
mint
|
||||
});
|
||||
comp.setCode('654321');
|
||||
await comp.requestNewCode();
|
||||
expect(mint).toHaveBeenCalledTimes(1);
|
||||
expect(comp.code).toBe('');
|
||||
expect(toastMocks.toast.success).toHaveBeenCalledWith('A new verification code has been sent.');
|
||||
});
|
||||
|
||||
it('requestNewCode toasts the mint-cooldown error on 429', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const mint = vi.fn(async () => ({
|
||||
status: 429,
|
||||
ok: false,
|
||||
message: 'Too many requests. Wait before requesting a new code.'
|
||||
}));
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true,
|
||||
mint
|
||||
});
|
||||
await comp.requestNewCode();
|
||||
expect(toastMocks.toast.error).toHaveBeenCalledWith(
|
||||
'Too many requests. Wait before requesting a new code.'
|
||||
);
|
||||
});
|
||||
|
||||
it('requestNewCode toasts the delivery-unavailable error on 503', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const mint = vi.fn(async () => ({
|
||||
status: 503,
|
||||
ok: false,
|
||||
message: 'Verification codes are unavailable right now. Try again later.'
|
||||
}));
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true,
|
||||
mint
|
||||
});
|
||||
await comp.requestNewCode();
|
||||
expect(toastMocks.toast.error).toHaveBeenCalledWith(
|
||||
'Verification codes are unavailable right now. Try again later.'
|
||||
);
|
||||
});
|
||||
|
||||
it('requestNewCode defaults to the session mint when none is supplied', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true
|
||||
});
|
||||
await comp.requestNewCode();
|
||||
expect(toastMocks.toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestNewCode guards against concurrent mints', async () => {
|
||||
const { useTwoFactorCodeForSavedCard } = await loadComposable();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const mint = vi.fn(async () => {
|
||||
await gate;
|
||||
return { status: 200, ok: true, message: 'slow' };
|
||||
});
|
||||
const comp = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => true,
|
||||
mint
|
||||
});
|
||||
const first = comp.requestNewCode();
|
||||
const second = comp.requestNewCode();
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(mint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user