Files
minabot/scripts/api-smoke.ts

58 lines
4.4 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
import { hashToken } from '../src/auth';
const directory = mkdtempSync(join(tmpdir(), 'minabot-api-smoke-'));
const databasePath = join(directory, 'test.sqlite');
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
const port = probe.port!; probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
const token = 's'.repeat(43);
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
async function boot() {
processHandle = Bun.spawn([process.execPath, 'scripts/start.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
for (let attempt = 0; attempt < 100; attempt++) {
try { if ((await fetch(`${origin}/api/health`)).ok) return; } catch { /* Wait for startup. */ }
if (processHandle.exitCode !== null) throw new Error(`Production server exited with ${processHandle.exitCode}`);
await Bun.sleep(50);
}
throw new Error('Production server did not become healthy');
}
async function stop() { if (processHandle) { processHandle.kill(); await processHandle.exited; processHandle = undefined; } }
async function request(path: string, method = 'GET', body?: unknown, status = 200) {
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${token}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
const data = await response.json();
if (response.status !== status) throw new Error(`${method} ${path}: ${response.status}: ${JSON.stringify(data)}`);
return data as any;
}
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message); }
try {
const migration = Bun.spawn([process.execPath, 'scripts/migrate.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
check(await migration.exited === 0, 'Fresh production migrations failed');
const sqlite = new Database(databasePath); const now = Date.now();
sqlite.query('INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run('smoke', 'smoke', 'Smoke', 'UTC', now, now);
sqlite.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashToken(token), 'smoke', now, now + 3600000);
sqlite.close();
await boot();
const date = (await request('/today')).date;
const h = await request('/habits', 'POST', { name: 'HTTP hydration', method: 'count', target: 8 }, 201);
const t = await request('/habits', 'POST', { name: 'HTTP tasks', method: 'tasks', tasks: [{ name: 'Check' }] }, 201);
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 7 });
await request(`/habits/${t.id}/days/${date}/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
const c = await request('/charts', 'POST', { name: 'HTTP combined', habitIds: [h.id, t.id] }, 201);
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Partial habit incorrectly contributed to combined score');
await stop(); await boot();
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 8 });
const result = await request(`/charts/${c.id}/calendar?from=${date}&to=${date}`);
check(result.days[0].ratio === 1, 'Correction did not update combined calendar');
check((await fetch(`${origin}/api/habits`)).status === 401, 'HTTP authentication was bypassed');
check((await fetch(`${origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${token}`, Origin: 'https://evil.example', 'Content-Type': 'application/json' }, body: '{}' })).status === 403, 'HTTP Origin enforcement was bypassed');
console.log('Production HTTP smoke passed: migrations, count/task logs, combined scores, correction, authentication, CSRF, and persistence across restart.');
} finally { await stop(); rmSync(directory, { recursive: true, force: true }); }