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.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user