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.
150 lines
7.4 KiB
TypeScript
150 lines
7.4 KiB
TypeScript
// 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 fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { describe, it, expect } from 'vitest'
|
|
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
|
|
|
|
const HARDCODED = 'postgres://u:p@h:5432/db'
|
|
|
|
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 — OUTCOME-based, not NODE_ENV-based)', () => {
|
|
it('behaves exactly like resolveDbUrl when a Postgres target is available (no SQLite file check runs at all)', () => {
|
|
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
|
|
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
|
|
})
|
|
|
|
it('resolves to SQLite without hard-failing when it is opted into and the target file exists (mocked fs check)', () => {
|
|
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('')
|
|
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists.
|
|
expect(requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => true)).toBe('')
|
|
})
|
|
|
|
it(':memory: never hard-fails — the guard never even calls the file-existence check', () => {
|
|
expect(
|
|
requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' }, undefined, () => {
|
|
throw new Error('must not be called for :memory:')
|
|
}),
|
|
).toBe('')
|
|
})
|
|
|
|
it('refuses when resolution selects SQLite and the target does not exist — regardless of NODE_ENV', () => {
|
|
// This is the original prod bug: an operator shell has no NODE_ENV set at all, so the old
|
|
// NODE_ENV==='production' gate never fired. The guard must fire on the OUTCOME instead.
|
|
const optIn = { HQ_DB_TARGET: 'sqlite' }
|
|
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/refusing to run/i)
|
|
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/CREATE a new SQLite database/)
|
|
// Also still refuses in the NODE_ENV=production case (hardcoded emptied out + no existing file).
|
|
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => false))
|
|
.toThrow(/refusing to run/i)
|
|
})
|
|
})
|
|
|
|
describe('requireDbUrlForEnv — SQLite must be an explicit opt-in, never an accident', () => {
|
|
// The bug this closes: apply-onboarding-pipeline.ts documented its own invocation WITHOUT a
|
|
// `DATABASE_URL=` prefix. An operator who copy-pastes that on the prod box resolves to SQLite;
|
|
// if a stale ./data/hq.db happens to exist under their cwd, the file-existence guard passes and
|
|
// the migration "succeeds" against the wrong database. Landing on SQLite must be a decision.
|
|
const existingFile = (): boolean => true
|
|
|
|
it('refuses when nothing selects a target, even though the SQLite file exists', () => {
|
|
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/refusing to run/i)
|
|
})
|
|
|
|
it('names BOTH escape hatches in the error — DATABASE_URL and the SQLite opt-in', () => {
|
|
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/DATABASE_URL=postgres/)
|
|
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/HQ_DB_TARGET=sqlite/)
|
|
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/--sqlite/)
|
|
})
|
|
|
|
it('accepts HQ_DB_TARGET=sqlite (case/space tolerant)', () => {
|
|
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, existingFile)).toBe('')
|
|
expect(requireDbUrlForEnv({ HQ_DB_TARGET: ' SQLite ' }, undefined, existingFile)).toBe('')
|
|
})
|
|
|
|
it('accepts a --sqlite argv flag (alongside the script\'s own flags)', () => {
|
|
expect(requireDbUrlForEnv({}, undefined, existingFile, ['node', 'script.ts', '--force', '--sqlite'])).toBe('')
|
|
})
|
|
|
|
it('rejects an unrelated HQ_DB_TARGET value rather than guessing', () => {
|
|
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'postgres' }, undefined, existingFile)).toThrow(/refusing to run/i)
|
|
})
|
|
|
|
it('the opt-in does NOT weaken the "never CREATE a database" guard', () => {
|
|
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => false))
|
|
.toThrow(/CREATE a new SQLite database/)
|
|
})
|
|
|
|
it('DATABASE_URL needs no opt-in — Postgres is the intended target and never touches the flag', () => {
|
|
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' }, undefined, () => false, [])).toBe('postgres://x/y')
|
|
})
|
|
})
|
|
|
|
describe('requireDbUrlForEnv — real filesystem (the actual script entry path, no mocks)', () => {
|
|
it('refuses a non-existent SQLite path', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
|
|
try {
|
|
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' }))
|
|
.toThrow(/CREATE a new SQLite database/)
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('accepts an existing SQLite path once SQLite is opted into', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'hq.db'), '')
|
|
expect(requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' })).toBe('')
|
|
// …and still refuses the same existing path with no opt-in at all.
|
|
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir })).toThrow(/HQ_DB_TARGET=sqlite/)
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it(':memory: still works with no filesystem check', () => {
|
|
expect(requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' })).toBe('')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|