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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user