Two-stage build producing a static /app on scratch; e2e tests for image contents, server and CLI through docker exec, socket handling, lifecycle and host/image parity.
220 lines
12 KiB
TypeScript
220 lines
12 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §15 persistence and §3/§8 lifecycle/logging inside the image: token state and audit history
|
|
* survive container replacement on the same data directory; `docker stop` (SIGTERM) and
|
|
* `docker kill -s INT|TERM` end the server gracefully with exit code 0; every LOG_LEVEL through
|
|
* `docker logs` (compact must/must-not checks — the host suite covers logging exhaustively), no
|
|
* token secret in `docker logs`; DEBUG/TRACE CLI records on `docker exec` stderr with a clean stdout.
|
|
*/
|
|
import {
|
|
test,
|
|
expect,
|
|
LOG_LEVELS,
|
|
assertDbInvariants,
|
|
expectCliSuccess,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
levelRank,
|
|
parseLogs,
|
|
parseAuditOutput,
|
|
parseTokenList,
|
|
readOperations,
|
|
readTokens,
|
|
tokenId,
|
|
visibleLevels,
|
|
type LogLevel,
|
|
} from '../../harness';
|
|
import { expectedOrigins } from './image-helpers';
|
|
|
|
const TOKEN_LINE = /^[A-Za-z0-9_-]{43}\n$/;
|
|
|
|
test('token state and audit history survive container replacement on the same data directory', async ({ image, http }) => {
|
|
test.setTimeout(90_000);
|
|
const first = await image.start({ suffix: 'gen1' });
|
|
first.fakeDocker!.many(2);
|
|
const keeper = (await first.exec(['token', 'new', 'keeper'])).stdout.trim();
|
|
const rotated = (await first.exec(['token', 'new', 'rotated'])).stdout.trim();
|
|
for (const t of [keeper, keeper, rotated]) expectContainersResponse(await http.get(first.url(), { token: t }));
|
|
expectCliSuccess(await first.exec(['token', 'delete', rotated]), { stdout: '', stderr: '' });
|
|
expectErrorResponse(await http.get(first.url(), { token: rotated }), 401);
|
|
const listed = await first.exec(['token', 'list', 'all']);
|
|
expectCliSuccess(listed, { stderr: '' });
|
|
const audited = await first.exec(['audit']);
|
|
expectCliSuccess(audited, { stderr: '' });
|
|
expect(parseAuditOutput(audited.stdout).map((r) => r.token)).toEqual([keeper, keeper, rotated]);
|
|
const origins1 = await expectedOrigins(first);
|
|
expect((await first.stop()).exitCode).toBe(0);
|
|
const snap1 = await first.snapshotDb();
|
|
const ops1 = readOperations(snap1);
|
|
await first.remove();
|
|
expect(await first.isRunning()).toBe(false);
|
|
|
|
// replacement container: new name/id, same /data
|
|
const second = await image.start({ suffix: 'gen2', dataDir: first.dataDir });
|
|
expect(second.id).not.toBe(first.id);
|
|
second.fakeDocker!.many(1);
|
|
const relisted = await second.exec(['token', 'list', 'all']);
|
|
expectCliSuccess(relisted, { stdout: listed.stdout, stderr: '' });
|
|
expectCliSuccess(await second.exec(['audit']), { stdout: audited.stdout, stderr: '' });
|
|
expect(expectContainersResponse(await http.get(second.url(), { token: keeper }))).toHaveLength(1);
|
|
expectErrorResponse(await http.get(second.url(), { token: rotated }), 401);
|
|
const fresh = (await second.exec(['token', 'new', 'after replacement'])).stdout.trim();
|
|
expectContainersResponse(await http.get(second.url(), { token: fresh }));
|
|
const origins2 = await expectedOrigins(second);
|
|
expect((await second.stop()).exitCode).toBe(0);
|
|
|
|
const snap2 = await second.snapshotDb();
|
|
const ops2 = readOperations(snap2);
|
|
expect(ops2.slice(0, ops1.length), 'audit history of the first container retained verbatim').toEqual(ops1);
|
|
expect(ops2.map((o) => o.token)).toEqual([keeper, keeper, rotated, keeper, fresh]);
|
|
for (const [i, o] of ops2.entries()) expect(i < 3 ? origins1 : origins2).toContain(o.origin);
|
|
const tokens = readTokens(snap2);
|
|
expect(tokens.filter((t) => t.token !== fresh), 'token rows of the first container unchanged').toEqual(readTokens(snap1));
|
|
expect(tokens.map((t) => [t.description, t.active])).toEqual([
|
|
['keeper', 1],
|
|
['rotated', 0],
|
|
['after replacement', 1],
|
|
]);
|
|
assertDbInvariants(snap2, { tokenCount: 3, operationCount: 5, auditBeforeDeletion: true });
|
|
|
|
// and a one-shot container on the same directory sees the same state
|
|
const oneShot = await image.run(['token', 'list', 'all'], { dataDir: first.dataDir });
|
|
expectCliSuccess(oneShot, { stderr: '' });
|
|
expect(parseTokenList(oneShot.stdout).map((r) => [r.token, r.active])).toEqual([
|
|
[keeper, true],
|
|
[rotated, false],
|
|
[fresh, true],
|
|
]);
|
|
const oneShotActive = await image.run(['token', 'list'], { dataDir: first.dataDir });
|
|
expectCliSuccess(oneShotActive, { stderr: '' });
|
|
expect(parseTokenList(oneShotActive.stdout).map((r) => r.token), 'token list: active tokens only').toEqual([keeper, fresh]);
|
|
const oneShotAudit = await image.run(['audit', '100'], { dataDir: first.dataDir });
|
|
expectCliSuccess(oneShotAudit, { stderr: '' });
|
|
expect(parseAuditOutput(oneShotAudit.stdout).map((r) => [r.time, r.token, r.origin]), 'audit in a one-shot container: the whole history').toEqual(ops2.map((o) => [o.time, o.token, o.origin]));
|
|
});
|
|
|
|
test('docker stop (SIGTERM) and docker kill -s INT / TERM: graceful shutdown, container exit code 0', async ({ image, http }) => {
|
|
const cases = [
|
|
{ name: 'docker stop', signal: undefined },
|
|
{ name: 'docker kill -s SIGINT', signal: 'SIGINT' as const },
|
|
{ name: 'docker kill -s SIGTERM', signal: 'SIGTERM' as const },
|
|
];
|
|
const containers = await Promise.all(cases.map((k, i) => image.start({ logLevel: 'DEBUG', suffix: `sig${i}` })));
|
|
await Promise.all(
|
|
containers.map(async (c) => {
|
|
const t = (await c.exec(['token', 'new'])).stdout.trim();
|
|
expectContainersResponse(await http.get(c.url(), { token: t }));
|
|
}),
|
|
);
|
|
const exits = await Promise.all(
|
|
containers.map(async (c, i) => {
|
|
const t0 = performance.now();
|
|
const exit = await c.stop({ signal: cases[i].signal, timeoutSec: 20 });
|
|
return { exit, ms: performance.now() - t0 };
|
|
}),
|
|
);
|
|
for (const [i, { exit, ms }] of exits.entries()) {
|
|
await test.step(cases[i].name, async () => {
|
|
expect(exit.exitCode, 'graceful exit, not 137 (SIGKILL after the grace period)').toBe(0);
|
|
expect(exit.oomKilled).toBe(false);
|
|
expect(ms, 'stops promptly, well inside the 20 s grace period').toBeLessThan(10_000);
|
|
const logs = parseLogs(exit.stderr);
|
|
expect(logs.unparsed, 'no error line').toEqual([]);
|
|
for (const m of ['shutdown requested, draining http server', 'http server shut down', 'database closed', 'server stopped']) expect(logs.has({ msg: m }), m).toBe(true);
|
|
});
|
|
}
|
|
for (const c of containers) assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: 1 });
|
|
});
|
|
|
|
test('every LOG_LEVEL through docker logs (and docker exec inherits it): must / must-not appear, no secrets', async ({ image, http, secrets }) => {
|
|
const containers = await Promise.all(LOG_LEVELS.map((level) => image.start({ logLevel: level, suffix: `log-${level.toLowerCase()}` })));
|
|
await Promise.all(
|
|
containers.map(async (c, i) => {
|
|
const level = LOG_LEVELS[i];
|
|
const shows = (l: LogLevel) => levelRank(l) >= levelRank(level);
|
|
// docker exec inherits LOG_LEVEL from the container environment
|
|
const created = await c.exec(['token', 'new', 'log probe']);
|
|
expect(created.exitCode).toBe(0);
|
|
expect(created.stdout, 'stdout carries only the token').toMatch(TOKEN_LINE);
|
|
const token = secrets.register(created.stdout.trim());
|
|
const cliLogs = parseLogs(created.stderr);
|
|
expect(cliLogs.unparsed).toEqual([]);
|
|
expect(cliLogs.levels().filter((l) => !visibleLevels(level).includes(l as LogLevel)), `exec records below ${level}`).toEqual([]);
|
|
expect(cliLogs.has({ level: 'DEBUG', msg: 'cli dispatch', attrs: { command: 'token new' } })).toBe(shows('DEBUG'));
|
|
expect(cliLogs.has({ level: 'TRACE', msg: 'row inserted', attrs: { table: 'tokens', token_id: tokenId(token) } })).toBe(shows('TRACE'));
|
|
if (!shows('DEBUG')) expect(created.stderr, 'successful CLI commands are silent at INFO and above').toBe('');
|
|
|
|
const f = c.fakeDocker!.many(1);
|
|
expectContainersResponse(await http.get(c.url(), { token }));
|
|
expectErrorResponse(await http.get(c.url()), 401);
|
|
expectErrorResponse(await http.get(c.url('/nope')), 404);
|
|
expectErrorResponse(await http.fetch(c.url(), { method: 'POST' }), 405);
|
|
f.error500();
|
|
expectErrorResponse(await http.get(c.url(), { token }), 500);
|
|
}),
|
|
);
|
|
const exits = await Promise.all(containers.map((c) => c.stop()));
|
|
for (const [i, level] of LOG_LEVELS.entries()) {
|
|
await test.step(level, async () => {
|
|
const exit = exits[i];
|
|
expect(exit.exitCode).toBe(0);
|
|
expect(exit.stdout, 'nothing on the container stdout').toBe('');
|
|
const logs = parseLogs(exit.stderr);
|
|
expect(logs.unparsed, 'every line is a slog record').toEqual([]);
|
|
const allowed = visibleLevels(level);
|
|
expect(logs.records.filter((r) => !allowed.includes(r.level as LogLevel)).map((r) => r.raw), `records below ${level}`).toEqual([]);
|
|
const shows = (l: LogLevel) => levelRank(l) >= levelRank(level);
|
|
// must appear (when visible) / must not appear (when filtered)
|
|
expect(logs.filter({ level: 'ERROR' }).map((r) => r.msg), 'ERROR: exactly the Docker failure').toEqual(['docker request failed']);
|
|
expect(logs.count({ level: 'WARN' })).toBe(0);
|
|
expect(logs.requests().map((r) => r.attrs.status)).toEqual(shows('INFO') ? ['200', '401', '404', '405', '500'] : []);
|
|
expect(logs.count({ msg: 'server listening' })).toBe(shows('INFO') ? 1 : 0);
|
|
expect(logs.filter({ msg: 'authentication result' }).map((r) => r.attrs.result)).toEqual(shows('DEBUG') ? ['ok', 'missing_header', 'ok'] : []);
|
|
expect(logs.count({ level: 'DEBUG', msg: 'docker request started' })).toBe(shows('DEBUG') ? 2 : 0);
|
|
expect(logs.count({ level: 'TRACE', msg: 'function entry', attrs: { func: 'WebServer.handleContainers' } })).toBe(shows('TRACE') ? 3 : 0);
|
|
expect(logs.count({ level: 'TRACE', msg: 'row inserted', attrs: { table: 'operations' } })).toBe(shows('TRACE') ? 2 : 0);
|
|
// secrets / credentials never logged
|
|
secrets.assertNoSecretsIn(exit.stderr + exit.stdout, `docker logs at ${level}`);
|
|
expect(exit.stderr).not.toContain('Bearer');
|
|
expect(exit.stderr.toLowerCase()).not.toContain('authorization');
|
|
});
|
|
}
|
|
});
|
|
|
|
test('docker exec -e LOG_LEVEL=DEBUG|TRACE: diagnostics on stderr, stdout stays exactly the command result', async ({ image, secrets }) => {
|
|
const c = await image.start({ logLevel: 'INFO', suffix: 'execlog' });
|
|
for (const level of ['DEBUG', 'TRACE'] as const) {
|
|
await test.step(level, async () => {
|
|
const env = { LOG_LEVEL: level };
|
|
const created = await c.exec(['token', 'new', `${level} probe`], { env });
|
|
expectCliSuccess(created, { stdout: TOKEN_LINE });
|
|
const token = secrets.register(created.stdout.trim());
|
|
const cl = parseLogs(created.stderr);
|
|
expect(cl.unparsed).toEqual([]);
|
|
expect(cl.find({ msg: 'configuration resolved' })!.attrs).toEqual({ db_path: '/data/dpsapi.db', http_addr: '0.0.0.0', http_port: '80', log_level: level });
|
|
expect(cl.find({ msg: 'cli dispatch' })!.attrs).toEqual({ command: 'token new' });
|
|
expect(cl.find({ msg: 'token created' })!.attrs).toEqual({ token_id: tokenId(token) });
|
|
expect(cl.has({ msg: 'function entry', attrs: { func: 'TokenStore.CreateToken' } })).toBe(level === 'TRACE');
|
|
expect(cl.count({ level: 'TRACE' }) > 0).toBe(level === 'TRACE');
|
|
|
|
const listed = await c.exec(['token', 'list'], { env });
|
|
expectCliSuccess(listed);
|
|
const rows = parseTokenList(listed.stdout); // strict: stdout holds only the table
|
|
expect(rows.map((r) => r.token)).toContain(token);
|
|
expect(parseLogs(listed.stderr).find({ msg: 'cli dispatch' })!.attrs).toEqual({ command: 'token list' });
|
|
|
|
const deleted = await c.exec(['token', 'delete', token], { env });
|
|
expectCliSuccess(deleted, { stdout: '' });
|
|
expect(parseLogs(deleted.stderr).find({ msg: 'token deactivated' })!.attrs).toEqual({ token_id: tokenId(token) });
|
|
if (level === 'TRACE') expect(parseLogs(deleted.stderr).find({ msg: 'row updated', attrs: { table: 'tokens' } })!.attrs).toMatchObject({ token_id: tokenId(token), active_from: 'true', active_to: 'false' });
|
|
for (const r of [created, listed, deleted]) secrets.assertNoSecretsIn(r.stderr, `docker exec stderr at ${level}`);
|
|
});
|
|
}
|
|
// the container itself stayed at INFO: exec's env override does not leak into the server
|
|
const exit = await c.stop();
|
|
expect(exit.exitCode).toBe(0);
|
|
expect(parseLogs(exit.stderr).levels()).toEqual(['INFO']);
|
|
});
|