7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
662 lines
22 KiB
TypeScript
662 lines
22 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
NONCE_STALENESS_MS,
|
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
|
VERIFICATION_REQUIRED_MESSAGE,
|
|
adminRequestNewTwoFactorCode,
|
|
campaignDiscountPence,
|
|
canSaveCardsForRole,
|
|
depositChargePence,
|
|
isAmbiguousPaymentFailure,
|
|
isNonceStale,
|
|
isOverflowTipConfirmationRequired,
|
|
isSavedCardVerificationRequired,
|
|
isTwoFactorVerificationGateFailure,
|
|
isVerificationRequiredSignal,
|
|
parseTokenizeVerificationResult,
|
|
requestNewTwoFactorCode,
|
|
sanitizeDecimalInput,
|
|
shouldFallbackTo2FA,
|
|
submitPaymentWithRetry
|
|
} 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('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('shouldFallbackTo2FA', () => {
|
|
it.each([
|
|
['sca-unavailable', true],
|
|
['verified', false],
|
|
['challenge-cancelled', false],
|
|
['sca-failed', false],
|
|
['', false]
|
|
])('outcome %s → %s', (outcome, expected) => {
|
|
expect(shouldFallbackTo2FA(outcome)).toBe(expected);
|
|
});
|
|
|
|
it('demotes to the 2FA gate only on sca-unavailable', () => {
|
|
expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true);
|
|
});
|
|
|
|
it('keeps SCA primary after a successful verification', () => {
|
|
expect(shouldFallbackTo2FA('verified')).toBe(false);
|
|
});
|
|
|
|
it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => {
|
|
expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false);
|
|
});
|
|
});
|
|
|
|
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 (2FA fallback)', () => {
|
|
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('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);
|
|
});
|
|
});
|
|
|
|
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('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);
|
|
});
|
|
});
|