Stop two test suites from depending on the network and the scheduler

The second flake recorded as 'costs' is not a timing flake. It is a live
HTTPS call: budget.service.ts settlement() reaches getRates()
unconditionally, unlike the two guarded call sites above it, and
ExchangeRatesService fetches api.frankfurter.dev with a 10 s abort.
tests/integration/budget.test.ts stubbed fetch nowhere at all, so the suite
talked to the internet - fast when the API answers, ten seconds per call
when it does not. The stub fails closed, which changes no assertion: every
settlement case here is single-currency, so rates never enter the
arithmetic. tools-budget-advanced had the stub inside a single case; hoisted
so a second settlement case cannot quietly reintroduce the call.

The five ReDoS budgets in maps.service.test.ts measured wall-clock time,
which on a loaded machine reports how long the test waited for a core.
MAPS-024 has gone red at 523 ms against an unchanged file for exactly that
reason. They now measure CPU time, which is what catastrophic backtracking
actually burns. This does not soften them: CPU time is always <= wall-clock
time, so every budget got stricter, and pool:'forks' keeps the measurement
to this file.
This commit is contained in:
Maurice
2026-08-10 09:08:57 +02:00
committed by Maurice
parent 7f5c537ad6
commit 0d8870cff7
3 changed files with 40 additions and 23 deletions
+8
View File
@@ -63,9 +63,17 @@ beforeAll(async () => {
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
// GET /budget/settlement reaches ExchangeRatesService.getRates unconditionally
// (budget.service.ts settlement()), and that is a real fetch to
// api.frankfurter.dev with a 10 s abort. Without this stub the suite talks to
// the internet: slow, offline-dependent, and it makes the run take minutes
// longer on a machine that cannot reach it. Fail closed — every assertion here
// is single-currency, so rates never enter the arithmetic.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
});
afterAll(async () => {
vi.unstubAllGlobals();
await nestApp.close();
testDb.close();
});
@@ -49,9 +49,14 @@ beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
// get_settlement_summary calls getRates(), which is a real fetch to
// api.frankfurter.dev with a 10 s abort. One case used to stub this inline;
// hoisted so a second settlement case cannot quietly reintroduce the call.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
@@ -328,8 +333,6 @@ describe('Settlement tools', () => {
});
it('get_settlement_summary returns balances and flows', async () => {
// Avoid a real exchange-rate network call: force getRates() to fail closed.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
try {
const { user, other, trip } = tripWithTwo();
// user paid 100 for an item split between both → other owes user 50.
+27 -21
View File
@@ -141,6 +141,23 @@ afterEach(() => {
// ── parseOpeningHours ─────────────────────────────────────────────────────────
/**
* CPU milliseconds burned by `fn`.
*
* The ReDoS budgets below used to measure wall-clock time, which on a loaded
* machine reports how long the test waited for a core rather than how long the
* regex ran — MAPS-024 has gone red at 523 ms on an unchanged file for exactly
* that reason. Catastrophic backtracking burns CPU, so CPU time is both the
* honest signal and a STRICTER one: cpu <= wall always, so no budget here got
* looser. vitest.config.ts uses pool:'forks', so this counts only this file.
*/
function cpuMillis(fn: () => void): number {
const before = process.cpuUsage();
fn();
const d = process.cpuUsage(before);
return (d.user + d.system) / 1000;
}
describe('parseOpeningHours', () => {
it('MAPS-001: returns 7 weekday descriptions and openNow', () => {
const result = parseOpeningHours('Mo-Fr 09:00-18:00');
@@ -214,12 +231,9 @@ describe('parseOpeningHours', () => {
expect(result.weekdayDescriptions[1]).toContain('?');
});
it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms', () => {
it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms of CPU', () => {
const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed';
const start = Date.now();
parseOpeningHours(adversarial);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(100);
expect(cpuMillis(() => { parseOpeningHours(adversarial); })).toBeLessThan(100);
});
it('MAPS-007b: emits machine-readable periods in Google day numbering (Sunday = 0)', () => {
@@ -545,32 +559,24 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
expect(result.lng).toBeCloseTo(-74.0445, 3);
});
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => {
const adversarial = '/@' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
expect(cpuMillis(() => { adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500);
});
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => {
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms of CPU', () => {
const adversarial = '!3d' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
expect(cpuMillis(() => { adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); })).toBeLessThan(500);
});
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms of CPU', () => {
const adversarial = '?q=' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
expect(cpuMillis(() => { adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); })).toBeLessThan(500);
});
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => {
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms of CPU', () => {
const adversarial = '<' + 'a'.repeat(10000);
const start = Date.now();
adversarial.replace(/<[^>]+>/g, '');
expect(Date.now() - start).toBeLessThan(100);
expect(cpuMillis(() => { adversarial.replace(/<[^>]+>/g, ''); })).toBeLessThan(100);
});
it('MAPS-028: throws when no coordinates found in URL', async () => {