You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/test/projects-stalled-route.test.ts

79 lines
4.0 KiB
TypeScript

// apps/hq/test/projects-stalled-route.test.ts — pre-ship audit FIX B: GET /projects/stalled
// had no try/catch, and a caller-supplied `?days=` flowed straight into
// `new Date(...).toISOString()`, which throws a RangeError on an out-of-range/NaN value.
// On Express 4 + Node 22 an unhandled rejection from an async route handler crashes the
// whole process — ANY authenticated role (including plain staff) could take the console
// down with one GET. `days` is now validated and the handler wrapped like its siblings.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
describe('GET /projects/stalled — hostile ?days= must not crash the server', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ base: string; staffH: Record<string, string> }> {
const db = openDb(':memory:')
await seedIfEmpty(db)
await createStaff(db, { email: 'staff-stalled@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'staff-stalled@t.in', password: 'staff-pass-1' }),
})).json()) as { token: string }).token
return { base, staffH: { authorization: `Bearer ${token}` } }
}
it('a plain request (no ?days=) works — falls back to the configured default', async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled`, { headers: staffH })
expect(res.status).toBe(200)
const body = await res.json() as { ok: boolean; projects: unknown[]; unknownAgeExcluded: number }
expect(body.ok).toBe(true)
expect(Array.isArray(body.projects)).toBe(true)
// FIX 4: the count of pending projects excluded for unprovable age travels alongside the
// list itself now, so the UI can surface "N of unknown age" instead of a silent drop.
expect(typeof body.unknownAgeExcluded).toBe('number')
})
it('a sane ?days= still works normally', async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=45`, { headers: staffH })
expect(res.status).toBe(200)
expect((await res.json() as { ok: boolean }).ok).toBe(true)
})
const hostileValues = [
'1e300', // finite in JS but overflows Date math -> used to throw RangeError
'-99999999999999', // huge negative -> overflows the other direction
'3.5', // not an integer
'abc', // NaN
'NaN',
'Infinity',
'-Infinity',
'99999999999999999999', // way outside the sane 0-3650 bound
'-1', // out of bound (negative)
]
for (const hostile of hostileValues) {
it(`?days=${hostile} is rejected with a 4xx, never throws / crashes the server`, async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=${encodeURIComponent(hostile)}`, { headers: staffH })
expect(res.status).toBeGreaterThanOrEqual(400)
expect(res.status).toBeLessThan(500)
const body = await res.json() as { ok: boolean }
expect(body.ok).toBe(false)
// The server process must still be alive for the NEXT request — the original bug's
// unhandled RangeError rejection killed the whole process.
const health = await fetch(`${base}/health`)
expect(health.status).toBe(200)
})
}
})