fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NONCE_STALENESS_MS,
OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE,
PAYMENT_AMBIGUOUS_STATUS,
PAYMENT_DEFINITIVE_STATUS,
SAVED_CARD_VERIFICATION_MESSAGE,
canSaveCardsForRole,
isAmbiguousPaymentFailure,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
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('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('PAYMENT_DEFINITIVE_STATUS is 402', () => {
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
});
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
});
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');
});
});
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_CODE
});
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);
});
});