fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets
- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
newCardTokenizeResult,
|
||||
parseTokenizeVerificationResult,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
@@ -427,6 +428,37 @@ describe('parseTokenizeVerificationResult', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-card tokenizeWithVerification contract (dev/mock parity with the real SDK)', () => {
|
||||
// The real Square Web Payments SDK returns `{ nonce, verificationToken:
|
||||
// null }` from a NEW-card tokenizeWithVerification — the SCA-verified cnon
|
||||
// nonce IS the charge source, and a separate verificationToken exists only
|
||||
// on the saved-card-on-file SCA flow. The dev mock must mirror this exactly:
|
||||
// the charge surfaces send `new_card_token: newCardToken ?? verificationToken`
|
||||
// and gate `save_card: X && !verificationToken`, so an always-non-null mock
|
||||
// verificationToken silently suppressed "Save this card for next time" on
|
||||
// new-card flows in dev. MockCardForm.tokenizeWithVerification builds its
|
||||
// result through newCardTokenizeResult (the shared single source), so these
|
||||
// tests pin the contract the mock and the real form both honour.
|
||||
it('returns the cnon nonce as the charge source and a NULL verificationToken', () => {
|
||||
expect(newCardTokenizeResult('cnon:test-card')).toEqual({
|
||||
nonce: 'cnon:test-card',
|
||||
verificationToken: null
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the cnon: nonce prefix the backend dev mock accepts as a charge source', () => {
|
||||
expect(newCardTokenizeResult('cnon:visa').nonce).toMatch(/^cnon:/);
|
||||
});
|
||||
|
||||
it('pins the save_card gate: the nonce wins the charge source and the save stays live', () => {
|
||||
const { nonce, verificationToken } = newCardTokenizeResult('cnon:test-card');
|
||||
// `new_card_token: newCardToken ?? verificationToken` → the nonce is used.
|
||||
expect(nonce ?? verificationToken).toBe('cnon:test-card');
|
||||
// `save_card: X && !verificationToken` → the save is honoured (regression pin).
|
||||
expect(verificationToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTwoFactorVerificationGateFailure', () => {
|
||||
it.each([
|
||||
[403, 'A two-factor verification code is required to use this saved card', true],
|
||||
@@ -778,7 +810,7 @@ describe('runSavedCardSCAProactively', () => {
|
||||
|
||||
it('maps a verified tokenize result and records it via onOutcome', async () => {
|
||||
mockSCATokenize.fn.mockResolvedValue({
|
||||
verificationToken: 'verify_mock_ok',
|
||||
verificationToken: 'cnon:sca-test_5000_ok',
|
||||
outcome: 'verified'
|
||||
});
|
||||
const mod = await loadSquareInMockMode();
|
||||
@@ -789,7 +821,7 @@ describe('runSavedCardSCAProactively', () => {
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('verified');
|
||||
expect(res.verificationToken).toBe('verify_mock_ok');
|
||||
expect(res.verificationToken).toBe('cnon:sca-test_5000_ok');
|
||||
expect(outcomes).toEqual(['verified']);
|
||||
});
|
||||
|
||||
@@ -865,9 +897,107 @@ describe('runSavedCardSCAProactively', () => {
|
||||
onOutcome: (o) => outcomes.push(o)
|
||||
});
|
||||
expect(res.outcome).toBe('verified');
|
||||
expect(res.verificationToken).toMatch(/^verify_mock_/);
|
||||
expect(res.verificationToken).toMatch(/^cnon:sca-/);
|
||||
expect(outcomes).toEqual(['verified']);
|
||||
});
|
||||
|
||||
it('mints saved-card SCA tokens the backend mock accepts (cnon:sca- prefix)', async () => {
|
||||
// The backend dev mock's saved-card SCA gate (square_dev.go
|
||||
// isSCATokenizeResultSource) only accepts a GENUINE tokenize-result —
|
||||
// a cnon: source carrying the `cnon:sca-` marker. The dev frontend's
|
||||
// deterministic token must match that prefix so the mock charge lands
|
||||
// in dev exactly as a real Square tokenize-result would.
|
||||
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 5000,
|
||||
squareCardId: 'ccof:mock_123',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.verificationToken).toMatch(/^cnon:sca-mock_5000_ok$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev mock token-shape parity with the backend gate', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
// The backend dev mock's isSCATokenizeResultSource accepts a cnon: source
|
||||
// ONLY when it carries the `cnon:sca-` marker (or the legacy verify_mock_
|
||||
// transition shape). The frontend's deterministic fallback token must stay
|
||||
// inside that accepted set or dev saved-card charges fail at the mock.
|
||||
it('the deterministic token satisfies the backend isSCATokenizeResultSource predicate', async () => {
|
||||
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 2500,
|
||||
squareCardId: 'ccof:1234567890',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.outcome).toBe('verified');
|
||||
// Predicate parity — the same test the backend mock runs:
|
||||
// isSCATokenizeResultSource = cnon:sca-* || verify_mock_*.
|
||||
expect(res.verificationToken).toMatch(/^cnon:sca-/);
|
||||
});
|
||||
|
||||
it('derives the token prefix from the first 4 chars of the ccof card id', async () => {
|
||||
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 2500,
|
||||
squareCardId: 'ccof:1234567890',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.verificationToken).toBe('cnon:sca-1234_2500_ok');
|
||||
});
|
||||
|
||||
it('falls back to the test prefix when the card id yields no 4-char prefix', async () => {
|
||||
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
|
||||
const mod = await loadSquareInMockMode();
|
||||
const res = await mod.runSavedCardSCAProactively({
|
||||
amountPence: 2500,
|
||||
squareCardId: 'ccof:',
|
||||
onOutcome: () => {}
|
||||
});
|
||||
expect(res.verificationToken).toBe('cnon:sca-test_2500_ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backend 402 error-body parity (errors.go writeVerificationRequiredResponse)', () => {
|
||||
it('VERIFICATION_REQUIRED_MESSAGE matches the backend response text verbatim', () => {
|
||||
// backend/handlers/payments/errors.go writes:
|
||||
// {"error": "Your card issuer requires verification. Approve this
|
||||
// payment in your banking app.", "code": "verification_required"}
|
||||
// The frontend must key the SCA challenge on the byte-identical copy.
|
||||
expect(VERIFICATION_REQUIRED_MESSAGE).toBe(
|
||||
'Your card issuer requires verification. Approve this payment in your banking app.'
|
||||
);
|
||||
});
|
||||
|
||||
it('isVerificationRequiredSignal detects the exact structured 402 body the backend writes', () => {
|
||||
const backendBody = JSON.stringify({
|
||||
error: 'Your card issuer requires verification. Approve this payment in your banking app.',
|
||||
code: 'verification_required'
|
||||
});
|
||||
expect(isVerificationRequiredSignal(402, backendBody)).toBe(true);
|
||||
});
|
||||
|
||||
it('isVerificationRequiredSignal rejects a backend body carrying a different code', () => {
|
||||
const body = JSON.stringify({ error: 'Payment failed', code: 'card_declined' });
|
||||
expect(isVerificationRequiredSignal(402, body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs:
|
||||
|
||||
Reference in New Issue
Block a user