Files
reze-test/test/api.test.js
T
reze 1965c6efaa reze: Fix unicode splitting, strict booleans, symbol validation and entropy
Six fixes, each with a test:

1. Character pools are built as arrays of code points, so astral characters
   (emoji) are never split into lone surrogates. Entropy counts code points.
2. Boolean options are parsed strictly: true/false, 1/0, "true"/"false",
   "1"/"0", "yes"/"no", "on"/"off" (case-insensitive). Anything else
   is a 400 naming the option instead of a silent truthy fallback.
3. customSymbols is validated: at most 64 code points, duplicates dropped,
   control characters and whitespace rejected with a 400.
4. Random mode entropy with requireEach now counts only the passwords that
   contain a character of every enabled class (inclusion-exclusion). The
   value is an upper bound and is labelled as such in the API
   (entropyApproximate/entropyNote) and in the UI.
5. docker-compose.yml uses expose instead of ports, so it no longer
   collides with Dokploy's own port 3000.
6. SIGTERM/SIGINT shut the server down gracefully: close the listener, let
   in-flight requests finish, force-exit after 5s. Measured at ~60ms.

The server now exports createServer() so the API can be tested over HTTP
without binding a fixed port.
2026-10-21 09:26:32 +00:00

225 lines
6.6 KiB
JavaScript

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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value));
assert.equal(Array.from(value).length, 12);
for (const codePoint of Array.from(value)) {
assert.equal(codePoint, '😀');
}
});
});
test('the API labels an upper bound entropy as approximate', async () => {
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');
}
}
});