import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createServer } from '../server.js'; const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); async function withServer(fn) { const server = createServer(); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); const base = `http://127.0.0.1:${port}`; try { await fn(base); } finally { await new Promise((resolve) => server.close(resolve)); } } function post(base, body) { return fetch(`${base}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } test('the API rejects unparseable booleans with a 400 naming the option', async () => { await withServer(async (base) => { const response = await post(base, { mode: 'random', length: 16, useLower: 'maybe', useUpper: false, useDigits: false, useSymbols: false, }); assert.equal(response.status, 400); const payload = await response.json(); assert.match(payload.error, /useLower/); }); }); test('the API accepts "no" as false', async () => { await withServer(async (base) => { const response = await post(base, { mode: 'random', length: 16, useLower: 'no', useUpper: false, useDigits: true, useSymbols: false, }); assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.passwords.length, 1); assert.ok(!/[a-z]/.test(payload.passwords[0].value), 'expected no lowercase characters'); }); }); test('the API rejects invalid custom symbol sets with a 400', async () => { await withServer(async (base) => { const cases = [ ['a b', /whitespace/], ['a\tb', /control characters or whitespace/], ['x'.repeat(65), /at most 64/], ]; for (const [customSymbols, pattern] of cases) { const response = await post(base, { mode: 'random', length: 16, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols, }); assert.equal(response.status, 400, `expected 400 for ${JSON.stringify(customSymbols)}`); const payload = await response.json(); assert.match(payload.error, pattern); } }); }); test('the API never splits astral characters', async () => { await withServer(async (base) => { const response = await post(base, { mode: 'random', length: 12, useLower: false, useUpper: false, useDigits: false, useSymbols: true, customSymbols: '😀', requireEach: true, }); assert.equal(response.status, 200); const payload = await response.json(); const value = payload.passwords[0].value; assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { await withServer(async (base) => { const constrained = await post(base, { mode: 'random', length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: true, requireEach: true, }); const constrainedPayload = await constrained.json(); assert.equal(constrainedPayload.entropyApproximate, true); assert.equal(constrainedPayload.passwords[0].entropyApproximate, true); assert.match(constrainedPayload.entropyNote, /upper bound/); const plain = await post(base, { mode: 'random', length: 16, useLower: true, useUpper: true, useDigits: true, useSymbols: true, requireEach: false, }); const plainPayload = await plain.json(); assert.equal(plainPayload.entropyApproximate, false); assert.match(plainPayload.entropyNote, /exact/); const memorable = await post(base, { mode: 'memorable', words: 4, digits: 2 }); const memorablePayload = await memorable.json(); assert.equal(memorablePayload.entropyApproximate, false); }); }); test('SIGTERM stops the server quickly', async () => { const port = 34567; const child = spawn(process.execPath, ['server.js'], { cwd: REPO_ROOT, env: { ...process.env, PORT: String(port) }, stdio: ['ignore', 'pipe', 'pipe'], }); try { const base = `http://127.0.0.1:${port}`; const deadline = Date.now() + 10000; for (;;) { try { const response = await fetch(`${base}/healthz`); if (response.ok) break; } catch { // not up yet } if (Date.now() > deadline) throw new Error('server did not start in time'); await new Promise((resolve) => setTimeout(resolve, 50)); } const started = Date.now(); const exit = await new Promise((resolve) => { child.once('exit', (code, signal) => resolve({ code, signal })); child.kill('SIGTERM'); }); const elapsed = Date.now() - started; assert.equal(exit.code, 0, `expected a clean exit, got code ${exit.code} signal ${exit.signal}`); assert.ok(elapsed < 4000, `expected the shutdown to be quick, took ${elapsed}ms`); } finally { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); } } }); test('SIGINT stops the server quickly', async () => { const port = 34568; const child = spawn(process.execPath, ['server.js'], { cwd: REPO_ROOT, env: { ...process.env, PORT: String(port) }, stdio: ['ignore', 'pipe', 'pipe'], }); try { const base = `http://127.0.0.1:${port}`; const deadline = Date.now() + 10000; for (;;) { try { const response = await fetch(`${base}/healthz`); if (response.ok) break; } catch { // not up yet } if (Date.now() > deadline) throw new Error('server did not start in time'); await new Promise((resolve) => setTimeout(resolve, 50)); } const started = Date.now(); const exit = await new Promise((resolve) => { child.once('exit', (code, signal) => resolve({ code, signal })); child.kill('SIGINT'); }); const elapsed = Date.now() - started; assert.equal(exit.code, 0, `expected a clean exit, got code ${exit.code} signal ${exit.signal}`); assert.ok(elapsed < 4000, `expected the shutdown to be quick, took ${elapsed}ms`); } finally { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); } } });