// 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']); });