test: add board and postgres safety net

This commit is contained in:
2026-08-14 12:57:06 +02:00
parent ff5e5dc63e
commit 9a4da41b49
11 changed files with 445 additions and 91 deletions
+87
View File
@@ -0,0 +1,87 @@
import { createServer } from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState } from '../src/types.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
async function availablePort() {
return new Promise<number>((resolve, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
const port = typeof address === 'object' && address ? address.port : 0
probe.close(error => error ? reject(error) : resolve(port))
})
})
}
suite('level persistence API', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
await runMigrations(databaseUrl, migrationsDir, () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
})
afterAll(async () => {
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
if (appPool) await appPool.end()
if (!adminClient) return
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('persists authored viewport, exhibits, and relation positions', async () => {
const createResponse = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
})
expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState
state.viewport = { x: 91, y: -42, zoom: 0.85 }
state.documents = [{ id: 'doc-1', title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }]
state.evidence = [{ id: 'folder-1', type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: ['doc-1'] }]
state.relations = [{ id: 'membership-1', fromWidgetId: 'folder-1', toWidgetId: 'doc-1', type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
})
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
})
})
+11 -4
View File
@@ -16,7 +16,7 @@ if (!databaseUrl) {
process.exit(1)
}
const pool = new Pool({ connectionString: databaseUrl })
export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
type WidgetRow = {
@@ -154,6 +154,10 @@ async function savePlaythrough(client: PoolClient, state: CaseState) {
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`,
[`default:${state.id}`, state.id, JSON.stringify(state.viewport)])
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
@@ -184,7 +188,7 @@ async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
}
}
const app = express()
export const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
app.use(express.json({ limit: '2mb' }))
@@ -285,6 +289,9 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787)
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown)
if (!process.env.VITEST) {
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}
+2 -40
View File
@@ -1,11 +1,8 @@
import 'dotenv/config'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { runMigrations } from './migrations.js'
const { Client } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('DATABASE_URL is required')
@@ -13,39 +10,4 @@ if (!databaseUrl) {
}
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
console.log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
console.log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
console.log('OSINT migrations complete.')
} finally {
await client.end()
}
await runMigrations(databaseUrl, migrationsDir)
+53
View File
@@ -0,0 +1,53 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let testDatabaseUrl = ''
suite('PostgreSQL migrations', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
testDatabaseUrl = testUrl.toString()
})
afterAll(async () => {
if (!adminClient) return
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('applies every migration transactionally and is idempotent', async () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(5)
const client = new Client({ connectionString: testDatabaseUrl })
await client.connect()
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
const tableNames = tables.rows.map(row => row.table_name)
expect(tableNames).toEqual(expect.arrayContaining(['levels', 'widgets', 'widget_relations', 'playthroughs', 'assets', 'schema_migrations']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('5')
await client.end()
const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(5)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import pg from 'pg'
const { Client } = pg
export async function runMigrations(databaseUrl: string, migrationsDir: string, log: (message: string) => void = console.log) {
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
log('OSINT migrations complete.')
} finally {
await client.end()
}
}