fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod): MONEY: - HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking - MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount - MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit - MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx) - LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded SECURITY: - 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure) - Admin 2FA mint now writes admin_audit_log + logs code reuse - Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account - Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts) - family-alive cache invalidated on password change / GDPR erasure - Login lockout keyed per user+IP with a capped ceiling FRONTEND/DUP-MOD: - OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware) - PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard) - requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode) - BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently 26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
depositChargePence,
|
||||
isAmbiguousPaymentFailure,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
@@ -420,3 +422,75 @@ describe('requestNewTwoFactorCode', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user