Compare commits
No commits in common. 'b1e1420b4ccc7251144c6f561aaa342a571ebb33' and 'b146de990f54a31e2c8c0b38a0e89966258877da' have entirely different histories.
b1e1420b4c
...
b146de990f
@ -1,59 +0,0 @@
|
|||||||
// apps/hq/test/db-resolve.test.ts — pre-ship audit FIX A: server.ts and the deploy scripts
|
|
||||||
// (apps/hq/scripts/apply-onboarding-pipeline.ts, apps/hq/scripts/migrate-onboarding-templates.ts)
|
|
||||||
// must all resolve the DB engine + target through the ONE shared function. Before this fix
|
|
||||||
// the scripts computed it as `DATABASE_URL ?? ''` (no production fallback), so on the
|
|
||||||
// production box — where DATABASE_URL is unset — each script silently opened a brand-new
|
|
||||||
// empty SQLite file instead of the real Postgres.
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
|
|
||||||
|
|
||||||
const HARDCODED = 'postgres://postgres:inv123@host.docker.internal:5432/hq'
|
|
||||||
|
|
||||||
describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
|
|
||||||
it('DATABASE_URL wins regardless of NODE_ENV', () => {
|
|
||||||
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y', NODE_ENV: 'production' })).toBe('postgres://x/y')
|
|
||||||
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the hardcoded production URL only when NODE_ENV=production and DATABASE_URL is unset', () => {
|
|
||||||
expect(resolveDbUrl({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves to SQLite (empty string) outside production with no DATABASE_URL — dev/tests stay green', () => {
|
|
||||||
expect(resolveDbUrl({})).toBe('')
|
|
||||||
expect(resolveDbUrl({ NODE_ENV: 'development' })).toBe('')
|
|
||||||
expect(resolveDbUrl({ NODE_ENV: 'test' })).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('requireDbUrlForEnv (deploy-script guard — hard-fail instead of a throwaway SQLite write)', () => {
|
|
||||||
it('behaves exactly like resolveDbUrl when a Postgres target is available', () => {
|
|
||||||
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
|
||||||
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
|
|
||||||
expect(requireDbUrlForEnv({})).toBe('') // dev with no env: SQLite is fine, no hard-fail
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hard-fails instead of silently returning SQLite when NODE_ENV=production resolves nothing', () => {
|
|
||||||
// Simulates the hardcoded URL having been accidentally emptied out.
|
|
||||||
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production' }, '')).toThrow(/refusing to run/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('pgTargetDescription (never leaks the password into logs)', () => {
|
|
||||||
it('extracts host:port/dbname', () => {
|
|
||||||
expect(pgTargetDescription('postgres://user:secret@db.example.com:5432/hq'))
|
|
||||||
.toBe('db.example.com:5432/hq')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits the port when absent', () => {
|
|
||||||
expect(pgTargetDescription('postgres://user:secret@db.example.com/hq')).toBe('db.example.com/hq')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('never includes the credentials', () => {
|
|
||||||
expect(pgTargetDescription('postgres://user:hunter2@host/db')).not.toContain('hunter2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('degrades gracefully on an unparsable string', () => {
|
|
||||||
expect(pgTargetDescription('not-a-url')).toMatch(/unparsable/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
// 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[] }
|
|
||||||
expect(body.ok).toBe(true)
|
|
||||||
expect(Array.isArray(body.projects)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue