- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate - webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table - cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password - lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env - erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added - env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency - auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO - frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
1380 lines
48 KiB
TypeScript
1380 lines
48 KiB
TypeScript
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,
|
||
buildCashTillPaymentBody,
|
||
campaignDiscountPence,
|
||
canSaveCardsForRole,
|
||
cashChargeBasePence,
|
||
depositChargePence,
|
||
isAmbiguousPaymentFailure,
|
||
isNonceStale,
|
||
isOverflowTipConfirmationRequired,
|
||
isSavedCardVerificationRequired,
|
||
isTwoFactorVerificationGateFailure,
|
||
isVerificationRequiredSignal,
|
||
newCardTokenizeResult,
|
||
parseTokenizeVerificationResult,
|
||
requestNewTwoFactorCode,
|
||
resendEmailVerification,
|
||
sanitizeDecimalInput,
|
||
scaFallbackConsentFields,
|
||
shouldShowSCARefusal,
|
||
submitPaymentWithRetry,
|
||
type SavedCardVerificationOutcome,
|
||
verifyEmailCode
|
||
} from './square';
|
||
import type * as SquareModule from './square';
|
||
|
||
describe('isNonceStale', () => {
|
||
const tokenizedFor = 2500;
|
||
|
||
it('is false for a fresh nonce (under the staleness limit)', () => {
|
||
const tokenizedAt = 1_000_000;
|
||
const now = tokenizedAt + NONCE_STALENESS_MS - 1;
|
||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
|
||
});
|
||
|
||
it('is false exactly at the staleness boundary (limit is exclusive, > not >=)', () => {
|
||
const tokenizedAt = 1_000_000;
|
||
const now = tokenizedAt + NONCE_STALENESS_MS;
|
||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
|
||
});
|
||
|
||
it('is true just past the staleness boundary', () => {
|
||
const tokenizedAt = 1_000_000;
|
||
const now = tokenizedAt + NONCE_STALENESS_MS + 1;
|
||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(true);
|
||
});
|
||
|
||
it('is true when the nonce was tokenized for a different amount than now', () => {
|
||
expect(isNonceStale(1_000_000, tokenizedFor, tokenizedFor + 1, Date.now())).toBe(true);
|
||
});
|
||
|
||
it('is true for a very old nonce', () => {
|
||
expect(isNonceStale(1, tokenizedFor, tokenizedFor, Date.now())).toBe(true);
|
||
});
|
||
|
||
it('defaults `now` to Date.now() when omitted', () => {
|
||
expect(isNonceStale(Date.now(), tokenizedFor, tokenizedFor)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
|
||
type SquareEnv = {
|
||
VITE_SQUARE_ENVIRONMENT?: string;
|
||
VITE_SQUARE_APPLICATION_ID?: string;
|
||
VITE_SQUARE_LOCATION_ID?: string;
|
||
DEV?: boolean;
|
||
};
|
||
|
||
// square.ts captures APP_ID/LOCATION_ID/SQUARE_ENV at module load, so each
|
||
// env combination must reload the module with vi.resetModules + vi.stubEnv.
|
||
// All three VITE_* keys are stubbed explicitly ('' when omitted) so local
|
||
// .env files can never leak into the suite.
|
||
async function loadSquareWithEnv(env: SquareEnv): Promise<typeof SquareModule> {
|
||
vi.resetModules();
|
||
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', env.VITE_SQUARE_ENVIRONMENT ?? '');
|
||
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', env.VITE_SQUARE_APPLICATION_ID ?? '');
|
||
vi.stubEnv('VITE_SQUARE_LOCATION_ID', env.VITE_SQUARE_LOCATION_ID ?? '');
|
||
if (env.DEV !== undefined) {
|
||
vi.stubEnv('DEV', env.DEV);
|
||
}
|
||
return await import('./square');
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
});
|
||
|
||
it('mock env in a dev build → isSquareMock true, isSquareConfigured true', async () => {
|
||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: true });
|
||
expect(mod.isSquareMock()).toBe(true);
|
||
expect(mod.isSquareConfigured()).toBe(true);
|
||
});
|
||
|
||
it('mock env in a production build → isSquareMock false (DEV gate)', async () => {
|
||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: false });
|
||
expect(mod.isSquareMock()).toBe(false);
|
||
});
|
||
|
||
it('mock env without credentials → isSquareConfigured true (mock form needs no keys)', async () => {
|
||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock' });
|
||
expect(mod.isSquareConfigured()).toBe(true);
|
||
});
|
||
|
||
it('sandbox env with credentials → not mock, configured, config returned', async () => {
|
||
const mod = await loadSquareWithEnv({
|
||
VITE_SQUARE_ENVIRONMENT: 'sandbox',
|
||
VITE_SQUARE_APPLICATION_ID: 'sandbox-sq0idb-abc123',
|
||
VITE_SQUARE_LOCATION_ID: 'L0MOCK123'
|
||
});
|
||
expect(mod.isSquareMock()).toBe(false);
|
||
expect(mod.isSquareConfigured()).toBe(true);
|
||
expect(mod.getSquareConfig()).toEqual({
|
||
appId: 'sandbox-sq0idb-abc123',
|
||
locationId: 'L0MOCK123'
|
||
});
|
||
});
|
||
|
||
it('sandbox env without credentials → not configured, config null', async () => {
|
||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox' });
|
||
expect(mod.isSquareMock()).toBe(false);
|
||
expect(mod.isSquareConfigured()).toBe(false);
|
||
expect(mod.getSquareConfig()).toBeNull();
|
||
});
|
||
|
||
it('empty env → neither mock nor configured', async () => {
|
||
const mod = await loadSquareWithEnv({});
|
||
expect(mod.isSquareMock()).toBe(false);
|
||
expect(mod.isSquareConfigured()).toBe(false);
|
||
expect(mod.getSquareConfig()).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('sanitizeDecimalInput', () => {
|
||
it.each([
|
||
['', ''],
|
||
['0', '0'],
|
||
['12.34', '12.34'],
|
||
['1.2.3', '1.23'],
|
||
['£50', '50'],
|
||
['1,234.56', '1234.56'],
|
||
['abc', ''],
|
||
['..', '.'],
|
||
['1.', '1.']
|
||
])('sanitizes %s → %s', (input, expected) => {
|
||
expect(sanitizeDecimalInput(input)).toBe(expected);
|
||
});
|
||
});
|
||
|
||
describe('campaignDiscountPence', () => {
|
||
const base = {
|
||
eligible: true,
|
||
discounts: [
|
||
{ source: 'campaign', name: '10% Off', percent: 10, amount: 5 },
|
||
{ source: 'campaign', name: 'Referral', percent: 5, amount: 2.5 }
|
||
],
|
||
original_total: 50,
|
||
discounted_total: 42.5
|
||
};
|
||
|
||
it('sums eligible discount amounts in pence', () => {
|
||
expect(campaignDiscountPence(base)).toBe(750);
|
||
});
|
||
|
||
it('rounds each discount amount to pence before summing', () => {
|
||
expect(
|
||
campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })
|
||
).toBe(501);
|
||
});
|
||
|
||
it('is 0 when no preview', () => {
|
||
expect(campaignDiscountPence(null)).toBe(0);
|
||
});
|
||
|
||
it('is 0 when the preview is not eligible', () => {
|
||
expect(campaignDiscountPence({ ...base, eligible: false })).toBe(0);
|
||
});
|
||
|
||
it('is 0 for an empty discount list', () => {
|
||
expect(campaignDiscountPence({ ...base, discounts: [] })).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('canSaveCardsForRole', () => {
|
||
it.each([
|
||
['admin', true],
|
||
['verified_email', true],
|
||
['unverified_email', false],
|
||
['guest', false],
|
||
['affiliate', false],
|
||
['user', false],
|
||
['', false],
|
||
[undefined, false]
|
||
])('role %s → %s', (role, expected) => {
|
||
expect(canSaveCardsForRole(role)).toBe(expected);
|
||
});
|
||
});
|
||
|
||
describe('buildCashTillPaymentBody', () => {
|
||
it('sends the due amount alone with no tip flag when no tip applies', () => {
|
||
// `tip_enabled` is intentionally absent (undefined) — the old inline
|
||
// bodies only set it when a tip actually existed.
|
||
expect(buildCashTillPaymentBody(4000, 0)).toEqual({
|
||
amount: 4000,
|
||
payment_type: 'full',
|
||
payment_method: 'cash'
|
||
});
|
||
expect(buildCashTillPaymentBody(4000, 0).tip_enabled).toBeUndefined();
|
||
});
|
||
|
||
it('folds the tip into the amount and flags tip_enabled (backend has no tip_amount field)', () => {
|
||
// CreateTerminalPaymentRequest derives the tip from `amount - remaining`
|
||
// when tip_enabled — the tip must ride inside `amount`, never as a dead
|
||
// `tip_amount` key.
|
||
expect(buildCashTillPaymentBody(4000, 500)).toEqual({
|
||
amount: 4500,
|
||
payment_type: 'full',
|
||
payment_method: 'cash',
|
||
tip_enabled: true
|
||
});
|
||
});
|
||
|
||
it('always carries the full/cash payment contract the backend keys on', () => {
|
||
expect(buildCashTillPaymentBody(4000, 0)).toMatchObject({
|
||
payment_type: 'full',
|
||
payment_method: 'cash'
|
||
});
|
||
});
|
||
|
||
it('rounds via the caller; the helper sums the pence inputs verbatim', () => {
|
||
expect(buildCashTillPaymentBody(4000, 50).amount).toBe(4050);
|
||
});
|
||
});
|
||
|
||
describe('cashChargeBasePence', () => {
|
||
// FIX-1/FIX-2: The charge base is the FULL totalDuePence — neither the
|
||
// campaign credit nor the loyalty discount is subtracted. The backend
|
||
// applies both at completion via separate discount rows, and the tip carve
|
||
// (`amount − remaining`) uses GetBookingRemainingBalancePence which does
|
||
// NOT account for pending discounts. Subtracting them would undercharge
|
||
// the booking and absorb the tip into booking credit.
|
||
it('charges the full total due — campaign and loyalty are applied server-side', () => {
|
||
// Booking £100 net (campaign already subtracted), £10 campaign credit,
|
||
// £10 loyalty redemption → charge base = £100 (full totalDuePence)
|
||
expect(cashChargeBasePence(10000, 1000, 1000)).toBe(10000);
|
||
});
|
||
|
||
it('ignores campaignPence and loyaltyPence — always returns totalDuePence', () => {
|
||
// totalDue = £90 (no campaign), loyalty £10 → base = £90, not £80
|
||
expect(cashChargeBasePence(9000, 0, 1000)).toBe(9000);
|
||
});
|
||
|
||
it('never goes below zero', () => {
|
||
expect(cashChargeBasePence(0, 0, 0)).toBe(0);
|
||
expect(cashChargeBasePence(-100, 0, 0)).toBe(0);
|
||
});
|
||
|
||
it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => {
|
||
// Booking £100 net, campaign £10, loyalty £10: base = 10000.
|
||
// Tender £110 → tip £10 → amount £110. The backend carves against
|
||
// the full £100 remaining (no discount deduction), so it records
|
||
// £10 tip — matching the UI claim.
|
||
const basePence = cashChargeBasePence(10000, 1000, 1000);
|
||
const tipPence = 11000 - basePence;
|
||
expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(11000);
|
||
expect(tipPence).toBe(1000);
|
||
});
|
||
|
||
it('campaignPence and loyaltyPence are accepted but ignored (call-site compat)', () => {
|
||
// The parameters exist for call-site compatibility — the calling
|
||
// modals still compute them for display. The arithmetic ignores them.
|
||
expect(cashChargeBasePence(5000, 9999, 9999)).toBe(5000);
|
||
});
|
||
});
|
||
|
||
describe('payment failure classification', () => {
|
||
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
|
||
expect(isSavedCardVerificationRequired(402, true)).toBe(true);
|
||
});
|
||
|
||
it('a 402 on a new-card charge is a plain decline, not a verification failure', () => {
|
||
expect(isSavedCardVerificationRequired(402, false)).toBe(false);
|
||
});
|
||
|
||
it('isAmbiguousPaymentFailure matches only 503', () => {
|
||
expect(isAmbiguousPaymentFailure(503)).toBe(true);
|
||
expect(isAmbiguousPaymentFailure(402)).toBe(false);
|
||
expect(isAmbiguousPaymentFailure(200)).toBe(false);
|
||
expect(isAmbiguousPaymentFailure(500)).toBe(false);
|
||
});
|
||
|
||
it.each([
|
||
[true, 402, true],
|
||
[false, 402, false],
|
||
[true, 503, false],
|
||
[true, 200, false],
|
||
[false, 200, false]
|
||
])('isSavedCardVerificationRequired(%s, %d) → %s', (usedSavedCard, status, expected) => {
|
||
expect(isSavedCardVerificationRequired(status, usedSavedCard)).toBe(expected);
|
||
});
|
||
|
||
it('SAVED_CARD_VERIFICATION_MESSAGE is non-empty and mentions verification', () => {
|
||
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
|
||
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
|
||
});
|
||
|
||
it('VERIFICATION_REQUIRED_MESSAGE is non-empty and mentions verification', () => {
|
||
expect(VERIFICATION_REQUIRED_MESSAGE.length).toBeGreaterThan(0);
|
||
expect(VERIFICATION_REQUIRED_MESSAGE.toLowerCase()).toContain('verification');
|
||
});
|
||
});
|
||
|
||
describe('isVerificationRequiredSignal', () => {
|
||
it('matches a 402 JSON body carrying the verification_required code', () => {
|
||
const body = JSON.stringify({
|
||
error: 'Saved card charge requires buyer verification',
|
||
code: 'verification_required'
|
||
});
|
||
expect(isVerificationRequiredSignal(402, body)).toBe(true);
|
||
});
|
||
|
||
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
402,
|
||
'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification'
|
||
)
|
||
).toBe(true);
|
||
});
|
||
|
||
it('matches the plain "verification required" phrasing', () => {
|
||
expect(
|
||
isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')
|
||
).toBe(true);
|
||
});
|
||
|
||
it('is false for a 402 body with a different code', () => {
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
402,
|
||
JSON.stringify({ error: 'Declined', code: 'card_declined' })
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
it('is false for a 402 body with only error text and no code', () => {
|
||
expect(isVerificationRequiredSignal(402, 'Payment failed')).toBe(false);
|
||
});
|
||
|
||
it('is false for a non-JSON body that does not mention verification', () => {
|
||
expect(isVerificationRequiredSignal(402, 'Payment declined')).toBe(false);
|
||
});
|
||
|
||
it('is false for an empty body', () => {
|
||
expect(isVerificationRequiredSignal(402, '')).toBe(false);
|
||
});
|
||
|
||
it('is false for any non-402 status even with the code present', () => {
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
503,
|
||
JSON.stringify({ error: 'x', code: 'verification_required' })
|
||
)
|
||
).toBe(false);
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
400,
|
||
JSON.stringify({ error: 'x', code: 'verification_required' })
|
||
)
|
||
).toBe(false);
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
200,
|
||
JSON.stringify({ error: 'x', code: 'verification_required' })
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
it('is false when the code is not an exact match (guards against prefix drift)', () => {
|
||
expect(
|
||
isVerificationRequiredSignal(
|
||
402,
|
||
JSON.stringify({ error: 'x', code: 'verification_required_extra' })
|
||
)
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('shouldShowSCARefusal', () => {
|
||
it.each([
|
||
['sca-unavailable', true],
|
||
['verified', false],
|
||
['challenge-cancelled', false],
|
||
['sca-failed', false],
|
||
['', false]
|
||
])('outcome %s → %s', (outcome, expected) => {
|
||
expect(shouldShowSCARefusal(outcome)).toBe(expected);
|
||
});
|
||
|
||
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(shouldShowSCARefusal('verified')).toBe(false);
|
||
});
|
||
|
||
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
|
||
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
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('parseTokenizeVerificationResult', () => {
|
||
it('maps a status OK result with a token to a verified outcome carrying the SAME token', () => {
|
||
expect(
|
||
parseTokenizeVerificationResult({ status: 'OK', token: 'ccof:sca-verified-token' })
|
||
).toEqual({
|
||
verificationToken: 'ccof:sca-verified-token',
|
||
outcome: 'verified'
|
||
});
|
||
});
|
||
|
||
it('maps a status OK result with NO token to a verified, tokenless outcome (no SCA required)', () => {
|
||
expect(parseTokenizeVerificationResult({ status: 'OK' })).toEqual({
|
||
verificationToken: null,
|
||
outcome: 'verified'
|
||
});
|
||
});
|
||
|
||
it('maps a VERIFICATION_CHALLENGE status to a retryable challenge-cancelled outcome', () => {
|
||
expect(parseTokenizeVerificationResult({ status: 'VERIFICATION_CHALLENGE' })).toEqual({
|
||
verificationToken: null,
|
||
outcome: 'challenge-cancelled'
|
||
});
|
||
});
|
||
|
||
it('maps a cancel-coded error to a retryable challenge-cancelled outcome', () => {
|
||
expect(
|
||
parseTokenizeVerificationResult({
|
||
status: 'FAILED',
|
||
errors: [{ code: 'CANCEL', message: 'challenge cancelled by buyer' }]
|
||
})
|
||
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
|
||
});
|
||
|
||
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (C6 refusal)', () => {
|
||
expect(
|
||
parseTokenizeVerificationResult({
|
||
status: 'FAILED',
|
||
errors: [{ code: 'CARD_DECLINED_VERIFICATION_REQUIRED' }]
|
||
})
|
||
).toEqual({ verificationToken: null, outcome: 'sca-unavailable' });
|
||
});
|
||
|
||
it('maps any other failure to sca-failed', () => {
|
||
expect(
|
||
parseTokenizeVerificationResult({
|
||
status: 'FAILED',
|
||
errors: [{ code: 'CARD_DECLINED', message: 'card declined' }]
|
||
})
|
||
).toEqual({ verificationToken: null, outcome: 'sca-failed' });
|
||
});
|
||
});
|
||
|
||
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],
|
||
[403, 'Two-factor authentication is required to use online card payments', true],
|
||
[429, 'Too many attempts', true],
|
||
[400, 'Invalid verification code', true],
|
||
[400, 'Verification code expired — request a new one', true],
|
||
[400, 'Booking must be in_progress or completed to create payment', false],
|
||
[400, 'Invalid request', false],
|
||
[402, 'Payment failed', false],
|
||
[200, '', false],
|
||
[500, 'internal server error', false]
|
||
])('status %d + message "%s" → %s', (status, message, expected) => {
|
||
expect(isTwoFactorVerificationGateFailure(status, message)).toBe(expected);
|
||
});
|
||
|
||
it('treats any 403 as a gate failure on a saved-card charge (missing code / 2FA not enabled)', () => {
|
||
expect(isTwoFactorVerificationGateFailure(403, 'something else')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('isOverflowTipConfirmationRequired', () => {
|
||
it('matches the backend overflow-guard error body by its code', () => {
|
||
const body = JSON.stringify({
|
||
error: 'The extra amount will be recorded as a tip. Confirm to continue.',
|
||
code: 'overflow_tip_confirmation_required'
|
||
});
|
||
expect(isOverflowTipConfirmationRequired(body)).toBe(true);
|
||
});
|
||
|
||
it('is false for a 400 body with a different code', () => {
|
||
expect(
|
||
isOverflowTipConfirmationRequired(
|
||
JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' })
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
it('is false for a body with only the error text and no code', () => {
|
||
expect(isOverflowTipConfirmationRequired('The extra amount will be recorded as a tip.')).toBe(
|
||
false
|
||
);
|
||
});
|
||
|
||
it('is false for a non-JSON body', () => {
|
||
expect(isOverflowTipConfirmationRequired('Payment declined')).toBe(false);
|
||
});
|
||
|
||
it('is false for an empty body', () => {
|
||
expect(isOverflowTipConfirmationRequired('')).toBe(false);
|
||
});
|
||
|
||
it('is false when the code is not an exact match (guards against prefix drift)', () => {
|
||
expect(
|
||
isOverflowTipConfirmationRequired(
|
||
JSON.stringify({ error: 'x', code: 'overflow_tip_confirmation_required_extra' })
|
||
)
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('submitPaymentWithRetry', () => {
|
||
function jsonResponse(status: number): Response {
|
||
return new Response(null, { status });
|
||
}
|
||
|
||
it('retries on a 503 and resolves the follow-up 200', async () => {
|
||
const submit = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse(503))
|
||
.mockResolvedValueOnce(jsonResponse(200));
|
||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||
expect(response.status).toBe(200);
|
||
expect(response.ok).toBe(true);
|
||
expect(submit).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('returns a 402 immediately without retrying', async () => {
|
||
const submit = vi.fn().mockResolvedValue(jsonResponse(402));
|
||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||
expect(response.status).toBe(402);
|
||
expect(submit).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('succeeds after two 503s followed by a 200', async () => {
|
||
const submit = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse(503))
|
||
.mockResolvedValueOnce(jsonResponse(503))
|
||
.mockResolvedValueOnce(jsonResponse(200));
|
||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||
expect(response.status).toBe(200);
|
||
expect(submit).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it('gives up after maxRetries and returns the last 503', async () => {
|
||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||
expect(response.status).toBe(503);
|
||
expect(submit).toHaveBeenCalledTimes(4);
|
||
});
|
||
|
||
it('honours a custom maxRetries', async () => {
|
||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||
const response = await submitPaymentWithRetry(submit, { maxRetries: 1, retryDelayMs: 1 });
|
||
expect(response.status).toBe(503);
|
||
expect(submit).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('backs off with retryDelayMs between retries', async () => {
|
||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||
const submit = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse(503))
|
||
.mockResolvedValueOnce(jsonResponse(200));
|
||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1234 });
|
||
expect(response.status).toBe(200);
|
||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
|
||
});
|
||
|
||
it('does not auto-retry a 503 on a verificationCodeGated submission (finding 4)', async () => {
|
||
// A fresh saved-card charge already CONSUMED the 2FA code at the backend
|
||
// gate and the backend re-issued a fresh one — a same-body retry would
|
||
// re-send a dead code and come back 400. The 503 must be surfaced to the
|
||
// caller (which re-runs the gate) instead of retrying.
|
||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||
const response = await submitPaymentWithRetry(submit, {
|
||
verificationCodeGated: true,
|
||
retryDelayMs: 1
|
||
});
|
||
expect(response.status).toBe(503);
|
||
expect(submit).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('still auto-retries a 503 when verificationCodeGated is false', async () => {
|
||
const submit = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse(503))
|
||
.mockResolvedValueOnce(jsonResponse(200));
|
||
const response = await submitPaymentWithRetry(submit, {
|
||
verificationCodeGated: false,
|
||
retryDelayMs: 1
|
||
});
|
||
expect(response.status).toBe(200);
|
||
expect(submit).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe('requestNewTwoFactorCode', () => {
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('returns ok with the server message on a 200 mint', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue(jsonResponse({ message: 'Verification code sent' }))
|
||
);
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(true);
|
||
expect(result.status).toBe(200);
|
||
expect(result.message).toBe('Verification code sent');
|
||
});
|
||
|
||
it('falls back to a default message when the 200 body has none', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 200)));
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(true);
|
||
expect(result.message).toContain('verification code');
|
||
});
|
||
|
||
it('surfaces the 429 mint-cooldown error message', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
.mockResolvedValue(
|
||
jsonResponse({ error: 'Too many requests. Wait before requesting a new code.' }, 429)
|
||
)
|
||
);
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(false);
|
||
expect(result.status).toBe(429);
|
||
expect(result.message).toContain('Too many requests');
|
||
});
|
||
|
||
it('surfaces the 503 delivery-unavailable error message', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
.mockResolvedValue(jsonResponse({ error: 'Verification code delivery unavailable' }, 503))
|
||
);
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(false);
|
||
expect(result.status).toBe(503);
|
||
expect(result.message).toContain('delivery');
|
||
});
|
||
|
||
it('reports a network failure with status 0', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(false);
|
||
expect(result.status).toBe(0);
|
||
expect(result.message).toContain('Network error');
|
||
});
|
||
|
||
it('attaches the Bearer token from localStorage and POSTs', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
vi.stubGlobal('localStorage', {
|
||
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
|
||
});
|
||
const result = await requestNewTwoFactorCode();
|
||
expect(result.ok).toBe(true);
|
||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(url).toBe('/api/user/2fa/code');
|
||
expect(init.method).toBe('POST');
|
||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
|
||
});
|
||
});
|
||
|
||
describe('adminRequestNewTwoFactorCode', () => {
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('POSTs to the admin customer-scoped mint URL', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const result = await adminRequestNewTwoFactorCode('usr_abc');
|
||
expect(result.ok).toBe(true);
|
||
const [url] = fetchMock.mock.calls[0] as [string];
|
||
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
|
||
});
|
||
|
||
it('URL-encodes the customer userID', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
await adminRequestNewTwoFactorCode('usr a/b');
|
||
const [url] = fetchMock.mock.calls[0] as [string];
|
||
expect(url).toBe('/api/admin/users/usr%20a%2Fb/2fa/code');
|
||
});
|
||
|
||
it('surfaces the 429 mint-cooldown error message', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi
|
||
.fn()
|
||
.mockResolvedValue(
|
||
jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429)
|
||
)
|
||
);
|
||
const result = await adminRequestNewTwoFactorCode('usr_abc');
|
||
expect(result.ok).toBe(false);
|
||
expect(result.status).toBe(429);
|
||
expect(result.message).toContain('Too many requests');
|
||
});
|
||
|
||
it('attaches the Bearer token from localStorage and POSTs', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
vi.stubGlobal('localStorage', {
|
||
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
|
||
});
|
||
await adminRequestNewTwoFactorCode('usr_abc');
|
||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
|
||
expect(init.method).toBe('POST');
|
||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
|
||
});
|
||
});
|
||
|
||
describe('resendEmailVerification', () => {
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('POSTs to /api/verify/generate with the email and email_verify purpose', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true, message: 'ok' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const response = await resendEmailVerification('user@example.com');
|
||
expect(response.ok).toBe(true);
|
||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(url).toBe('/api/verify/generate');
|
||
expect(init.method).toBe('POST');
|
||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||
expect(init.body).toBe(JSON.stringify({ email: 'user@example.com', purpose: 'email_verify' }));
|
||
});
|
||
|
||
it('passes through the backend response (the caller surfaces success/message)', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue(jsonResponse({ success: false, message: 'cooldown' }, 429));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const response = await resendEmailVerification('user@example.com');
|
||
expect(response.status).toBe(429);
|
||
expect(response.ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('verifyEmailCode', () => {
|
||
function jsonResponse(body: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('POSTs to /api/verify/check with the email, code and email_verify purpose', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6');
|
||
expect(response.ok).toBe(true);
|
||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||
expect(url).toBe('/api/verify/check');
|
||
expect(init.method).toBe('POST');
|
||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||
expect(init.body).toBe(
|
||
JSON.stringify({
|
||
email: 'user@example.com',
|
||
code: 'a1b2c3d4e5f6',
|
||
purpose: 'email_verify'
|
||
})
|
||
);
|
||
});
|
||
|
||
it('passes through the backend success response body', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6');
|
||
const data = await response.json();
|
||
expect(data).toEqual({ success: true, message: 'Email verified successfully' });
|
||
});
|
||
|
||
it('passes through a failed verification (invalid/expired code)', async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValue(new Response('invalid or expired code', { status: 400 }));
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
const response = await verifyEmailCode('user@example.com', 'wrong-code');
|
||
expect(response.status).toBe(400);
|
||
expect(response.ok).toBe(false);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
it('charges the full deposit when the credit equals the deposit (A6 clamp-up)', () => {
|
||
expect(depositChargePence(2000, 2000)).toBe(2000);
|
||
});
|
||
|
||
it('charges the full deposit when the credit exceeds the deposit (A6 clamp-up)', () => {
|
||
expect(depositChargePence(2000, 2500)).toBe(2000);
|
||
});
|
||
|
||
it('is unchanged when no campaign credit applies', () => {
|
||
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: 'cnon:sca-test_5000_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('cnon:sca-test_5000_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(/^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:
|
||
// 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);
|
||
});
|
||
});
|