feat(image): add scratch container image

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.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 0a6023e942
commit 581a672fcf
9 files changed
+1858

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
.git
.gitignore
.dockerignore
LICENSE
dockerfile
makefile
.vars
vars.example
.cursor
*.md
*.sh
dpsapi
data
**/*.db
**/*.db-*
coverage.out
*.cov
e2e
**/node_modules
test-results
playwright-report
+38
View File
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Stage 1 · go builder
ARG GO_BUILDER=golang
ARG GO_VERSION=1.27.1-alpine
FROM ${GO_BUILDER}:${GO_VERSION} AS build
ARG GO_OS=linux
ARG GO_ARCH=amd64
ARG GIT_HOST
ARG REPO_ORG
ARG REPO_NAME
ARG APP_VERSION=latest
ARG COMMIT_ID=undefined
# Copy the project inside the builder container
WORKDIR $GOPATH/src/${GIT_HOST}/$REPO_ORG/$REPO_NAME/
# Download the modules first, so they are cached across source changes
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build the static binary (pure-Go SQLite, no cgo)
RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \
go build \
-trimpath \
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=${COMMIT_ID}'" \
-o /app .
# Stage 2 · scratch image
FROM scratch
# Copy only the binary from the build stage
COPY --from=build /app /app
# Execute the binary
ENTRYPOINT ["/app"]
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Compact malicious/fuzz pass against the final image (PLAN §12 malicious/fuzzer personas, image
* side): SQL-injection-like and shell-metacharacter descriptions and token arguments through
* `docker exec` (no shell exists in the image, so nothing can be interpreted — `docker diff`
* proves no file was created), a long description, deterministic random descriptions, hostile
* Authorization/forwarding headers and path tricks from outside the container, then invariant
* checks on a DB snapshot and normal operations.
*/
import {
test,
expect,
assertDbInvariants,
buildRawRequest,
dockerCli,
expectCliFailure,
expectCliSuccess,
expectContainersResponse,
expectErrorResponse,
expectNoInternalDetails,
goQuote,
parseLogs,
parseTokenList,
readOperations,
readTokens,
} from '../../harness';
import { execApp, expectedOrigins, hex } from './image-helpers';
test('abuse through docker exec and HTTP from outside: verbatim storage, no shell, no bypass, invariants hold, normal operation continues', async ({ image, http, fuzz, secrets }) => {
test.setTimeout(120_000);
const c = await image.start({ suffix: 'abuse' });
c.fakeDocker!.many(2);
const victim = (await c.exec(['token', 'new', 'victim'])).stdout.trim();
// ---- CLI: hostile descriptions ----
const hostile: string[] = [
`'); DROP TABLE tokens; --`,
`x' OR '1'='1`,
`"; DELETE FROM operations; UPDATE tokens SET active=1; --`,
`$(touch /pwned)`,
'`touch /pwned2`',
'; rm -rf / ; echo owned > /app',
'| cat /etc/passwd > /data/leak',
'&& /bin/sh -c id',
'%s%n%x %!v',
'\x1b[31mred\x1b[0m\r\x07',
'Веаrеr confusable zero‑width​',
'x'.repeat(100_000),
];
// deterministic random descriptions (printable ASCII + some Unicode; no NUL — not representable in argv)
const alphabet = [...`abcXYZ019 '"\`$;|&<>(){}[]*?~!#%\\/\t\n`, 'é', '☃', '日', '🚀', '​'];
for (let i = 0; i < 6; i++) {
const len = fuzz.rng.int(0, 64);
hostile.push(fuzz.record({ kind: 'description', i, value: Array.from({ length: len }, () => fuzz.rng.pick(alphabet)).join('') }).value);
}
const stored = new Map<string, string>([[victim, 'victim']]);
for (const [i, d] of hostile.entries()) {
await test.step(`token new hostile #${i}`, async () => {
const r = await c.exec(['token', 'new', d]);
expectCliSuccess(r, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
stored.set(r.stdout.trim(), d);
});
}
// ---- CLI: hostile token arguments ----
const flip = (s: string) => s.replace(/[a-z]/, (ch) => ch.toUpperCase());
const badDeletes: Array<[string, string]> = [
[`' OR '1'='1`, 'token not found'],
['%', 'token not found'],
['*', 'token not found'],
[`${victim}' --`, 'token not found'],
[`${victim} `, 'token not found'],
[`\n${victim}`, 'token not found'],
[flip(victim), 'token not found'],
[victim.slice(0, 42), 'token not found'],
['', 'token not found'],
['$(id)', 'token not found'],
];
for (const [arg, message] of badDeletes) {
await test.step(`token delete ${JSON.stringify(arg.slice(0, 12))}…`, async () => {
const r = await execApp(c, ['token', 'delete', arg]);
expectCliFailure(r, { exitCode: 1, message });
if (arg) expect(r.stderr.includes(arg), 'argument never echoed').toBe(false);
});
}
expectCliFailure(await execApp(c, ['token', 'delete', victim, '; rm -rf /']), { exitCode: 2, message: 'usage error: token delete requires exactly one argument' });
expectCliFailure(await execApp(c, ['token', 'new', 'a', '$(id)']), { exitCode: 2, message: 'usage error: token new takes at most one argument' });
// nothing outside the mounts changed in the container filesystem: no /pwned, /app untouched
const diff = await dockerCli(['diff', c.name]);
// As observed: Docker reports the mount points it created itself (/data, /var/run/docker.sock)
expect(diff.stdout.trim().split('\n').sort(), 'docker diff').toEqual(['A /data', 'A /var', 'A /var/run', 'A /var/run/docker.sock']);
// ---- HTTP from outside: hostile authorization and paths ----
let authenticated = 0;
await test.step('hostile Authorization values → 401, no details', async () => {
const values = [
`Bearer ' OR '1'='1`,
`Bearer ${victim}' OR '1'='1`,
'Bearer %',
'Bearer *',
`Bearer ${flip(victim)}`,
`Bearer ${victim.slice(0, 42)}`,
`Bearer ${victim}x`,
`Bearer ${victim}`,
`Bearer ${victim}, Bearer ${victim}`,
`Bearer\t${victim}`,
`Token ${victim}`,
'Bearer ' + 'A'.repeat(8192),
];
for (const v of values) {
const r = await http.curl({ url: c.url(), headers: [`Authorization: ${v}`] });
expect(r.exitCode).toBe(0);
expectErrorResponse(r, 401);
expectNoInternalDetails(r.body);
}
// duplicated header and CRLF header-injection attempt over a raw socket
const { hostname, port } = new URL(c.baseURL);
const dup = await http.raw({ host: hostname, port: Number(port), data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer ${victim}`, `Authorization: Bearer ${victim}`] }) });
expectErrorResponse(dup.response!, 401);
const inj = await http.raw({ host: hostname, port: Number(port), data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer x\r\nAuthorization: Bearer ${victim}`] }) });
expectErrorResponse(inj.response!, 401);
});
await test.step('forwarding headers cannot spoof the audited origin; path tricks', async () => {
const r = await http.curl({ url: c.url(), token: victim, headers: ['X-Forwarded-For: 203.0.113.9', 'Forwarded: for=203.0.113.9', 'X-Real-IP: 203.0.113.9'] });
expectContainersResponse(r);
authenticated++;
const enc = await http.curl({ url: c.url('/api/v1/%63ontainers?x=%27--'), token: victim });
expectContainersResponse(enc);
authenticated++;
for (const p of ['/api/v1/containers/', '//api/v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/api/v1%2Fcontainers', '/API/V1/CONTAINERS']) {
const x = await http.curl({ url: c.url(p), token: victim, pathAsIs: true });
expectErrorResponse(x, 404);
}
expectErrorResponse(await http.curl({ url: c.url(), token: victim, method: 'DELETE' }), 405);
});
const origins = await expectedOrigins(c);
// ---- invariants on a snapshot ----
await test.step('database invariants after the abuse', async () => {
const snap = await c.snapshotDb();
const rows = readTokens(snap);
expect(rows.length).toBe(stored.size);
for (const r of rows) expect(r.descriptionHex, 'stored verbatim').toBe(hex(stored.get(r.token)!));
expect(rows.every((r) => r.active === 1), 'no hostile input deactivated (or reactivated) anything').toBe(true);
const ops = readOperations(snap);
expect(ops).toHaveLength(authenticated);
for (const o of ops) {
expect(o.token).toBe(victim);
expect(origins, 'origin is the TCP peer, never 203.0.113.9').toContain(o.origin);
}
assertDbInvariants(snap, { tokenCount: stored.size, operationCount: authenticated });
// token list stays one strictly parseable line per token whatever the descriptions contain
const list = await c.exec(['token', 'list']);
expectCliSuccess(list, { stderr: '' });
const parsed = parseTokenList(list.stdout);
expect(parsed).toHaveLength(stored.size);
for (const p of parsed) expect(p.descriptionQuoted).toBe(goQuote(stored.get(p.token)!));
});
// ---- normal operation continues ----
await test.step('normal administration afterwards', async () => {
const fresh = (await c.exec(['token', 'new', 'after abuse'])).stdout.trim();
expect(secrets.has(fresh)).toBe(true);
expect(expectContainersResponse(await http.get(c.url(), { token: fresh }))).toHaveLength(2);
authenticated++;
expectCliSuccess(await c.exec(['token', 'delete', victim]), { stdout: '', stderr: '' });
expectErrorResponse(await http.get(c.url(), { token: victim }), 401);
expectContainersResponse(await http.get(c.url(), { token: fresh }));
authenticated++;
});
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const logs = parseLogs(exit.stderr);
expect(logs.unparsed).toEqual([]);
expect(logs.count({ level: 'ERROR' })).toBe(0);
secrets.assertNoSecretsIn(exit.stderr, 'docker logs');
const snap = await c.snapshotDb();
assertDbInvariants(snap, { tokenCount: stored.size + 1, operationCount: authenticated, auditBeforeDeletion: true });
});
+568
View File
@@ -0,0 +1,568 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12 "Container-image E2E tests", §13 (Dockerfile) and §15 (runtime contract) against the
* final image ($DPSAPI_IMAGE): image contents and config, the scratch/two-stage build, the static
* binary, default entrypoint/server mode with the default bind address, `docker exec /app token …`,
* `/app audit` and `/app help` with stdout/stderr/exit captured separately and verified against the
* database, usage and
* configuration failures through `docker exec` and `docker run`, no shell / no Docker CLI, and
* `curl` from outside the container against the real Engine.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
test,
expect,
REPO_ROOT,
AUDIT_COLUMNS,
EMPTY_AUDIT,
EMPTY_TOKEN_LIST,
TOKEN_LIST_COLUMNS,
USAGE_TEXT,
assertDbInvariants,
dockerCli,
expectCliFailure,
expectCliSuccess,
expectContainersResponse,
expectErrorResponse,
goQuote,
mapLimit,
msToNanos,
parseAuditOutput,
parseLogs,
parseTokenList,
padTable,
readOperations,
readTokens,
rfc3339ToNanos,
RFC3339NANO_UTC_RE,
runProcess,
shellQuote,
toolEnv,
} from '../../harness';
import { chronological, expectedAuditStdout, expectedTokenListStdout, stateDigest } from '../../harness/personas';
import { EM_BY_ARCH, PT_DYNAMIC, PT_INTERP, execApp, exportCreatedContainer, expectedOrigins, hex, imageTag, readElf, saveImage } from './image-helpers';
const TOKEN_LINE = /^[A-Za-z0-9_-]{43}\n$/;
test.describe('image contents and build', () => {
test('image layers (docker save) contain exactly one regular file: /app', async ({ image, ws }) => {
const saved = await saveImage(ws, image.image);
// FROM scratch: no base layers, the COPY of the binary is the only filesystem layer
expect(saved.manifest[0].Layers, 'filesystem layers').toHaveLength(1);
expect(saved.config.rootfs.diff_ids).toHaveLength(1);
const entries = saved.layers.flat();
expect(entries.map((e) => [e.type, e.name]), 'every entry of every layer').toEqual([['-', 'app']]);
const [app] = entries;
expect(app.perms, 'executable').toMatch(/^r.x/);
expect(app.owner).toBe('0/0');
const bin = await image.extractBinary();
expect(app.size, 'layer file is the extracted binary').toBe(fs.statSync(bin).size);
// history: one layer-producing step (COPY /app) + the ENTRYPOINT metadata step — nothing else
const history = saved.config.history;
expect(history.filter((h) => !h.empty_layer).map((h) => h.created_by), 'layer-producing history steps').toEqual([expect.stringMatching(/^COPY \/app \/app\b/)]);
expect(history.map((h) => h.created_by)).toEqual([expect.stringMatching(/^COPY \/app \/app\b/), 'ENTRYPOINT ["/app"]']);
expect(history.some((h) => /\b(RUN|apk|apt|sh -c)\b/.test(h.created_by ?? '')), 'no RUN step in the final stage').toBe(false);
// image config
const cfg = saved.config.config;
expect(cfg.Entrypoint).toEqual(['/app']);
expect(cfg.Cmd ?? null, 'no CMD: no arguments → server mode').toBeNull();
expect(cfg.User ?? '', 'runs as root (README: so it can open the socket and write /data)').toBe('');
// As observed (PLAN does not define these): buildkit's default PATH only, no VOLUME/EXPOSE/WORKDIR beyond "/"
expect(cfg.Env).toEqual(['PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin']);
expect(cfg.Volumes ?? null).toBeNull();
expect(cfg.ExposedPorts ?? null).toBeNull();
expect(saved.config.os).toBe('linux');
// the same through `docker image inspect`
const inspect = JSON.parse((await dockerCli(['image', 'inspect', image.image])).stdout)[0] as { Config: { Entrypoint: string[]; Cmd: unknown }; RootFS: { Layers: string[] } };
expect(inspect.Config.Entrypoint).toEqual(['/app']);
expect(inspect.Config.Cmd ?? null).toBeNull();
expect(inspect.RootFS.Layers).toEqual(saved.config.rootfs.diff_ids);
});
test('container filesystem (docker export): /app plus only what Docker injects at creation', async ({ image, ws, testKey }) => {
const entries = await exportCreatedContainer(ws, testKey, image.image);
// Docker adds these to every container's init layer at `docker create` (empty mount targets for
// /etc/hosts, /etc/hostname, /etc/resolv.conf, /dev/console, the .dockerenv marker, the
// /etc/mtab symlink and the /dev, /proc, /sys mount points). They are not in the image layers
// (previous test) and are recognisable here as zero-byte placeholders / directories / symlink.
const injected = new Map<string, string>([
['.dockerenv', '-'],
['dev/', 'd'],
['dev/console', '-'],
['dev/pts/', 'd'],
['dev/shm/', 'd'],
['etc/', 'd'],
['etc/hostname', '-'],
['etc/hosts', '-'],
['etc/mtab', 'l'],
['etc/resolv.conf', '-'],
['proc/', 'd'],
['sys/', 'd'],
]);
const unexpected = entries.filter((e) => e.name !== 'app' && injected.get(e.name) !== e.type);
expect(unexpected.map((e) => `${e.type} ${e.name}`), 'entries that are neither /app nor Docker-injected').toEqual([]);
const nonEmptyFiles = entries.filter((e) => e.type === '-' && e.size > 0).map((e) => e.name);
expect(nonEmptyFiles, 'the only file with content is /app').toEqual(['app']);
expect(entries.find((e) => e.name === 'etc/mtab')!.linkTarget).toBe('/proc/mounts');
const bins = entries.filter((e) => /(^|\/)(sh|bash|ash|busybox|docker|dockerd|apk|curl|wget)$/.test(e.name));
expect(bins.map((e) => e.name), 'no shell, docker CLI, package manager or HTTP client').toEqual([]);
});
test('the binary from the image is statically linked (no PT_INTERP, no PT_DYNAMIC)', async ({ image }) => {
const bin = await image.extractBinary();
const arch = JSON.parse((await dockerCli(['image', 'inspect', '--format', '{{json .Architecture}}', image.image])).stdout) as string;
const elf = readElf(bin);
expect(elf.machine, `ELF machine matches image architecture ${arch}`).toBe(EM_BY_ARCH[arch]);
expect(elf.type, 'ET_EXEC (a static Go binary is not a PIE)').toBe(2);
expect(elf.programHeaderTypes.length).toBeGreaterThan(0);
expect(elf.programHeaderTypes.includes(PT_INTERP), 'no program interpreter (dynamic loader)').toBe(false);
expect(elf.programHeaderTypes.includes(PT_DYNAMIC), 'no dynamic section').toBe(false);
});
test('dockerfile: two stages, CGO_ENABLED=0 with version/commit ldflags, final FROM scratch copies only /app', async () => {
const text = fs.readFileSync(path.join(REPO_ROOT, 'dockerfile'), 'utf8');
// join continuation lines, drop comments and blanks
const lines = text
.replace(/\\\n/g, ' ')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
const froms = lines.map((l, i) => [l, i] as const).filter(([l]) => /^FROM\s/i.test(l));
expect(froms.map(([l]) => l), 'exactly two stages').toEqual([expect.stringMatching(/^FROM \$\{GO_BUILDER\}:\$\{GO_VERSION\} AS build$/), 'FROM scratch']);
const builder = lines.slice(froms[0][1] + 1, froms[1][1]);
const final = lines.slice(froms[1][1] + 1);
expect(final, 'final stage: copy the binary, set the entrypoint, nothing else').toEqual(['COPY --from=build /app /app', 'ENTRYPOINT ["/app"]']);
const build = builder.find((l) => /^RUN .*go build/.test(l))!;
expect(build, 'go build step').toBeTruthy();
expect(build).toMatch(/\bCGO_ENABLED=0\b/);
expect(build).toMatch(/-X 'main\.APP_VERSION=\$\{APP_VERSION\}'/);
expect(build).toMatch(/-X 'main\.COMMIT_ID=\$\{COMMIT_ID\}'/);
expect(build).toMatch(/-o \/app\b/);
// builder image selected by build arguments (.vars / makefile convention)
expect(lines.slice(0, froms[0][1]).map((l) => l.replace(/=.*/, '')), 'global ARGs before the first FROM').toEqual(['ARG GO_BUILDER', 'ARG GO_VERSION']);
});
});
test.describe('server mode (default entrypoint)', () => {
test('no arguments, HTTP_ADDR/HTTP_PORT/LOG_LEVEL unset → listens on 0.0.0.0:80; version/commit in "server listening"', async ({ image, http }) => {
const c = await image.start({ logLevel: null, suffix: 'defaults' });
const info = (await c.inspect()) as { Path: string; Args: string[]; Config: { Env: string[]; Cmd: unknown } };
expect(info.Path).toBe('/app');
expect(info.Args, 'no arguments').toEqual([]);
expect(info.Config.Env.filter((e) => /^(HTTP_ADDR|HTTP_PORT|LOG_LEVEL)=/.test(e)), 'optional variables unset').toEqual([]);
expect(info.Config.Env).toContain('DB_PATH=/data/dpsapi.db');
const created = await c.exec(['token', 'new', 'defaults']);
expectCliSuccess(created, { stdout: TOKEN_LINE, stderr: '' });
const token = created.stdout.trim();
c.fakeDocker!.many(2);
// reached through the port published from container port 80
expect(expectContainersResponse(await http.get(c.url(), { token }))).toHaveLength(2);
expectErrorResponse(await http.get(c.url()), 401);
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
expect(exit.stdout, 'the server never writes to stdout').toBe('');
const logs = parseLogs(exit.stderr);
expect(logs.unparsed).toEqual([]);
expect(logs.levels(), 'LOG_LEVEL unset → INFO').toEqual(['INFO']);
const listening = logs.filter({ msg: 'server listening' });
expect(listening).toHaveLength(1);
expect(listening[0].attrs.addr).toBe('0.0.0.0:80');
const tag = imageTag(image.image);
if (tag !== undefined) expect(listening[0].attrs.version, 'APP_VERSION embedded = the image tag (make docker tags with APP_VERSION)').toBe(tag);
expect(listening[0].attrs.version).toMatch(/^\S+$/);
expect(listening[0].attrs.commit, 'COMMIT_ID embedded (short hash, or "undefined" without commits)').toMatch(/^([0-9a-f]{4,40}|undefined)$/);
expect(logs.requests().map((r) => r.attrs.status)).toEqual(['200', '401']);
expect(logs.has({ msg: 'server stopped' })).toBe(true);
});
test('explicit HTTP_ADDR/HTTP_PORT are honoured inside the container', async ({ image, http }) => {
const [v4, v6] = await Promise.all([
image.start({ addr: '0.0.0.0', containerPort: 8080, suffix: 'p8080' }),
image.start({ addr: '::', containerPort: 1, suffix: 'p1' }),
]);
for (const [c, addr] of [
[v4, '0.0.0.0:8080'],
[v6, '[::]:1'],
] as const) {
const token = (await c.exec(['token', 'new'])).stdout.trim();
expect(expectContainersResponse(await http.get(c.url(), { token }))).toEqual([]);
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
expect(parseLogs(exit.stderr).find({ msg: 'server listening' })!.attrs.addr).toBe(addr);
}
});
});
test.describe('docker exec /app token …', () => {
test('token new / list / delete: stdout, stderr and exit code separately; rows verified in the DB snapshot', async ({ image, secrets }) => {
const c = await image.start({ suffix: 'cli' });
const cases: Array<[string, string | undefined]> = [
['no description', undefined],
['empty argument', ''],
['normal', 'ci pipeline'],
['spaces', ' two words and trailing '],
['Unicode', 'café ☕ 日本語 עברית 🚀 Ωmega'],
['special characters', `!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`],
['newline and tab', 'line1\nline2\tcol'],
['looks like a flag', '--help'],
];
const created = new Map<string, string>(); // token -> description
const windows = new Map<string, [bigint, bigint]>();
for (const [name, d] of cases) {
await test.step(`token new: ${name}`, async () => {
const before = msToNanos(Date.now() - 1);
const r = await c.exec(d === undefined ? ['token', 'new'] : ['token', 'new', d]);
const after = msToNanos(Date.now() + 1);
expectCliSuccess(r, { stdout: TOKEN_LINE, stderr: '' });
const token = r.stdout.trim();
expect(secrets.has(token), 'token registered as a secret').toBe(true);
created.set(token, d ?? '');
windows.set(token, [before, after]);
});
}
await test.step('quoting through a host shell: docker exec … token new "<quoted>"', async () => {
const d = `it's "quoted" $HOME \`id\` ; | &`;
const r = await runProcess('/bin/sh', ['-c', `docker exec ${c.name} /app token new ${shellQuote(d)}`], { env: toolEnv(), label: 'sh -c docker exec' });
secrets.registerAllIn(r.stdout);
expectCliSuccess(r, { stdout: TOKEN_LINE, stderr: '' });
created.set(r.stdout.trim(), d);
const unquoted = await runProcess('/bin/sh', ['-c', `docker exec ${c.name} /app token new two words`], { env: toolEnv(), label: 'sh -c docker exec' });
expectCliFailure(unquoted, { exitCode: 2, message: 'usage error: token new takes at most one argument' });
});
const tokens = [...created.keys()];
const victim = tokens[2];
await test.step('token list: padded header + one row per token, Go-quoted descriptions, all active', async () => {
const r = await c.exec(['token', 'list']);
expectCliSuccess(r, { stderr: '' });
const rows = parseTokenList(r.stdout);
expect(r.stdout.split('\n')[0].split(/ +/)).toEqual([...TOKEN_LIST_COLUMNS]);
expectCliSuccess(await c.exec(['token', 'list', 'all']), { stdout: r.stdout, stderr: '' });
expect(new Set(rows.map((x) => x.token))).toEqual(new Set(tokens));
for (const row of rows) {
expect(row.descriptionQuoted).toBe(goQuote(created.get(row.token)!));
expect(row.active).toBe(true);
expect(row.deletedAt).toBeNull();
expect(row.createdAt).toMatch(RFC3339NANO_UTC_RE);
}
});
let deleteWindow: [bigint, bigint] = [0n, 0n];
await test.step('token delete: silent success, then already-inactive / unknown failures', async () => {
const before = msToNanos(Date.now() - 1);
const r = await c.exec(['token', 'delete', victim]);
deleteWindow = [before, msToNanos(Date.now() + 1)];
expectCliSuccess(r, { stdout: '', stderr: '' });
const again = await execApp(c, ['token', 'delete', victim]);
expectCliFailure(again, { exitCode: 1, message: 'token already inactive' });
const unknown = await execApp(c, ['token', 'delete', 'A'.repeat(43)]);
expectCliFailure(unknown, { exitCode: 1, message: 'token not found' });
expect(unknown.stderr.includes('A'.repeat(43)), 'supplied token never echoed').toBe(false);
const rows = parseTokenList((await c.exec(['token', 'list', 'all'])).stdout);
const v = rows.find((x) => x.token === victim)!;
expect(v.active).toBe(false);
expect(v.deletedAt).toMatch(RFC3339NANO_UTC_RE);
expect(rows.filter((x) => !x.active).map((x) => x.token)).toEqual([victim]);
const active = parseTokenList((await c.exec(['token', 'list'])).stdout);
expect(active.map((x) => x.token), 'token list hides the deleted token').toEqual(rows.filter((x) => x.token !== victim).map((x) => x.token));
});
await test.step('database rows (snapshot of the mounted DB)', async () => {
const snap = await c.snapshotDb();
const rows = readTokens(snap);
expect(rows.map((r) => r.token).sort()).toEqual([...tokens].sort());
for (const r of rows) {
expect(r.descriptionHex, `description of ${JSON.stringify(created.get(r.token))} stored byte-for-byte`).toBe(hex(created.get(r.token)!));
expect(r.active).toBe(r.token === victim ? 0 : 1);
const w = windows.get(r.token);
if (w) expect(rfc3339ToNanos(r.created_at) >= w[0] && rfc3339ToNanos(r.created_at) <= w[1], 'created_at within the docker exec window').toBe(true);
}
const del = rows.find((r) => r.token === victim)!.deleted_at!;
expect(rfc3339ToNanos(del) >= deleteWindow[0] && rfc3339ToNanos(del) <= deleteWindow[1], 'deleted_at within the delete window').toBe(true);
expect(readOperations(snap), 'CLI commands never write audit rows').toEqual([]);
assertDbInvariants(snap, { tokenCount: tokens.length, operationCount: 0 });
// exact padded tables (rune-counted widths: the descriptions include multi-byte text)
for (const scope of ['active', 'all'] as const) {
expectCliSuccess(await c.exec(scope === 'all' ? ['token', 'list', 'all'] : ['token', 'list']), { stdout: expectedTokenListStdout(snap, scope), stderr: '' });
}
});
expect((await c.stop()).exitCode).toBe(0);
});
test('usage and configuration failures through docker exec: exact exit code 2/1 and stderr; nothing changes', async ({ image, http }) => {
const c = await image.start({ suffix: 'execfail' });
const keep = (await c.exec(['token', 'new', 'keep'])).stdout.trim();
const listBefore = (await c.exec(['token', 'list'])).stdout;
const levelMsg = (v: string) => `configuration error: invalid LOG_LEVEL ${JSON.stringify(v)}: must be one of ERROR, WARN, INFO, DEBUG, TRACE`;
const cases: Array<{ name: string; args: string[]; env?: Record<string, string>; exitCode: 1 | 2; message: string }> = [
{ name: 'unknown command', args: ['bogus'], exitCode: 2, message: 'usage error: unknown command' },
{ name: '--help is an unknown command', args: ['--help'], exitCode: 2, message: 'usage error: unknown command' },
{ name: '-h is an unknown command', args: ['-h'], exitCode: 2, message: 'usage error: unknown command' },
{ name: 'help is case-sensitive', args: ['Help'], exitCode: 2, message: 'usage error: unknown command' },
{ name: 'help with an argument', args: ['help', 'token'], exitCode: 2, message: 'usage error: help takes no arguments' },
{ name: 'case-sensitive command', args: ['Token', 'list'], exitCode: 2, message: 'usage error: unknown command' },
{ name: 'missing subcommand', args: ['token'], exitCode: 2, message: 'usage error: missing token subcommand' },
{ name: 'unknown subcommand', args: ['token', 'remove', keep], exitCode: 2, message: 'usage error: unknown token subcommand' },
{ name: 'list with an unknown argument', args: ['token', 'list', 'ALL'], exitCode: 2, message: 'usage error: unknown token list argument' },
{ name: 'list --all', args: ['token', 'list', '--all'], exitCode: 2, message: 'usage error: unknown token list argument' },
{ name: 'list with two arguments', args: ['token', 'list', 'all', 'all'], exitCode: 2, message: 'usage error: token list takes at most one argument' },
{ name: 'audit with two arguments', args: ['audit', '1', 'h'], exitCode: 2, message: 'usage error: audit takes at most one argument' },
{ name: 'invalid audit argument', args: ['audit', '0'], exitCode: 2, message: 'usage error: invalid audit argument' },
{ name: 'configuration error wins over help', args: ['help'], env: { HTTP_PORT: '0' }, exitCode: 2, message: 'configuration error: invalid HTTP_PORT "0": must be an integer between 1 and 65535' },
{ name: 'new with two arguments', args: ['token', 'new', 'a', 'b'], exitCode: 2, message: 'usage error: token new takes at most one argument' },
{ name: 'delete without argument', args: ['token', 'delete'], exitCode: 2, message: 'usage error: token delete requires exactly one argument' },
{ name: 'delete with two arguments', args: ['token', 'delete', keep, keep], exitCode: 2, message: 'usage error: token delete requires exactly one argument' },
{ name: 'delete unknown token', args: ['token', 'delete', 'B'.repeat(43)], exitCode: 1, message: 'token not found' },
{ name: 'invalid LOG_LEVEL', args: ['token', 'list'], env: { LOG_LEVEL: 'verbose' }, exitCode: 2, message: levelMsg('verbose') },
{ name: 'empty DB_PATH', args: ['token', 'list'], env: { DB_PATH: '' }, exitCode: 2, message: 'configuration error: DB_PATH must not be empty' },
{ name: 'invalid HTTP_PORT', args: ['token', 'list'], env: { HTTP_PORT: '0' }, exitCode: 2, message: 'configuration error: invalid HTTP_PORT "0": must be an integer between 1 and 65535' },
{ name: 'invalid HTTP_ADDR', args: ['token', 'list'], env: { HTTP_ADDR: 'localhost' }, exitCode: 2, message: 'configuration error: invalid HTTP_ADDR "localhost": must be an IPv4 or IPv6 address' },
{ name: 'configuration error wins over usage error', args: ['bogus'], env: { LOG_LEVEL: 'x' }, exitCode: 2, message: levelMsg('x') },
// scratch has no /tmp: a DB_PATH in a missing directory is a runtime (exit 1) failure
{ name: 'DB_PATH in a missing directory', args: ['token', 'list'], env: { DB_PATH: '/tmp/dpsapi.db' }, exitCode: 1, message: 'initialize database schema: unable to open database file (14)' },
];
for (const k of cases) {
await test.step(k.name, async () => {
const r = await execApp(c, k.args, { env: k.env });
expectCliFailure(r, { exitCode: k.exitCode, message: k.message });
if (k.message.startsWith('usage error:')) expect(r.stderr, 'exact stderr: error line + usage text').toBe(`dpsapi: ${k.message}\n${USAGE_TEXT}`);
expect(r.stderr.includes(keep), 'token never echoed').toBe(false);
});
}
expect((await c.exec(['token', 'list'])).stdout, 'token state unchanged').toBe(listBefore);
// the server process is unaffected by failing exec'd commands
expect(await c.isRunning()).toBe(true);
expectContainersResponse(await http.get(c.url(), { token: keep }));
expect((await c.stop()).exitCode).toBe(0);
assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: 1 });
});
test('usage, configuration and startup failures through docker run: exact exit codes, stderr, no database created', async ({ image, ws }) => {
const levelMsg = 'configuration error: invalid LOG_LEVEL "verbose": must be one of ERROR, WARN, INFO, DEBUG, TRACE';
const cases: Array<{ name: string; args: string[]; env?: Record<string, string | undefined>; exitCode: 0 | 1 | 2; message?: string; stdout?: string; createsDb: boolean }> = [
{ name: 'server: DB_PATH unset', args: [], env: { DB_PATH: undefined }, exitCode: 2, message: 'configuration error: DB_PATH is required', createsDb: false },
{ name: 'server: HTTP_PORT out of range', args: [], env: { HTTP_PORT: '70000' }, exitCode: 2, message: 'configuration error: invalid HTTP_PORT "70000": must be an integer between 1 and 65535', createsDb: false },
{ name: 'server: HTTP_ADDR with a port', args: [], env: { HTTP_ADDR: '0.0.0.0:80' }, exitCode: 2, message: 'configuration error: invalid HTTP_ADDR "0.0.0.0:80": must be an IPv4 or IPv6 address', createsDb: false },
{ name: 'server: invalid LOG_LEVEL', args: [], env: { LOG_LEVEL: 'verbose' }, exitCode: 2, message: levelMsg, createsDb: false },
{ name: 'server: empty HTTP_PORT', args: [], env: { HTTP_PORT: '' }, exitCode: 2, message: 'configuration error: HTTP_PORT must not be empty', createsDb: false },
{ name: 'unknown command', args: ['serve'], exitCode: 2, message: 'usage error: unknown command', createsDb: false },
{ name: 'missing token subcommand', args: ['token'], exitCode: 2, message: 'usage error: missing token subcommand', createsDb: false },
{ name: 'CLI: DB_PATH unset', args: ['token', 'list'], env: { DB_PATH: undefined }, exitCode: 2, message: 'configuration error: DB_PATH is required', createsDb: false },
// runtime (exit 1) failures: the server opens the DB first, then fails to listen
{ name: 'server: address not available in the container', args: [], env: { HTTP_ADDR: '192.0.2.1' }, exitCode: 1, message: 'listen tcp4 192.0.2.1:80: bind: cannot assign requested address', createsDb: true },
{ name: 'CLI: DB_PATH directory missing', args: ['token', 'list'], env: { DB_PATH: '/data/missing/dpsapi.db' }, exitCode: 1, message: 'initialize database schema: unable to open database file (14)', createsDb: false },
{ name: 'CLI success: token list on a fresh DB', args: ['token', 'list'], exitCode: 0, stdout: EMPTY_TOKEN_LIST, createsDb: true },
{ name: 'CLI success: token list all on a fresh DB', args: ['token', 'list', 'all'], exitCode: 0, stdout: EMPTY_TOKEN_LIST, createsDb: true },
{ name: 'CLI success: audit on a fresh DB', args: ['audit', '1w'], exitCode: 0, stdout: EMPTY_AUDIT, createsDb: true },
{ name: 'CLI success: audit all on a fresh DB', args: ['audit', 'all'], exitCode: 0, stdout: EMPTY_AUDIT, createsDb: true },
{ name: 'audit ALL is case-sensitive', args: ['audit', 'ALL'], exitCode: 2, message: 'usage error: invalid audit argument', createsDb: false },
{ name: 'audit all with an extra argument', args: ['audit', 'all', 'x'], exitCode: 2, message: 'usage error: audit takes at most one argument', createsDb: false },
{ name: 'help: usage text on stdout, no database', args: ['help'], exitCode: 0, stdout: USAGE_TEXT, createsDb: false },
{ name: 'help with an argument', args: ['help', 'me'], exitCode: 2, message: 'usage error: help takes no arguments', createsDb: false },
{ name: 'unknown token list argument', args: ['token', 'list', 'everything'], exitCode: 2, message: 'usage error: unknown token list argument', createsDb: false },
{ name: 'CLI: configuration error wins over help', args: ['help'], env: { DB_PATH: undefined }, exitCode: 2, message: 'configuration error: DB_PATH is required', createsDb: false },
];
const results = await mapLimit(cases, 4, async (k, i) => {
const dataDir = ws.mkdir(`run-data-${i}`);
return { k, dataDir, r: await image.run(k.args, { env: k.env, dataDir, suffix: `run${i}`, timeoutMs: 30_000 }) };
});
for (const { k, dataDir, r } of results) {
await test.step(k.name, async () => {
if (k.exitCode === 0) expectCliSuccess(r, { stdout: k.stdout, stderr: '' });
else expectCliFailure(r, { exitCode: k.exitCode, message: k.message });
if (k.message?.startsWith('usage error:')) expect(r.stderr.endsWith(USAGE_TEXT)).toBe(true);
// usage/configuration errors never open the database; the mounted dir is host-listable
expect(fs.readdirSync(dataDir).includes('dpsapi.db'), 'database file created').toBe(k.createsDb);
});
}
});
test('no shell and no docker CLI in the image: docker exec of them fails at the runtime level (127), not as an app exit code', async ({ image }) => {
const c = await image.start({ suffix: 'noshell' });
const probes: Array<[string[], RegExp]> = [
[['/bin/sh'], /exec: "\/bin\/sh": stat \/bin\/sh: no such file or directory/],
[['sh', '-c', 'true'], /exec: "sh": executable file not found in \$PATH/],
[['/bin/busybox'], /no such file or directory/],
[['docker', 'ps'], /exec: "docker": executable file not found in \$PATH/],
[['/usr/bin/docker'], /no such file or directory/],
[['ls', '/'], /executable file not found in \$PATH/],
];
for (const [argv, msg] of probes) {
await test.step(argv.join(' '), async () => {
const r = await c.execRaw(argv);
// docker exec reports "command not found" from the OCI runtime as 127; such results are
// infrastructure-level (>= 125) and c.exec() would raise a HarnessError for them.
expect(r.signal).toBeNull();
expect(r.timedOut).toBe(false);
expect(r.exitCode).toBe(127);
// the docker CLI (29.x, no TTY) prints the OCI runtime error on its stdout, so check both streams
const out = r.stdout + r.stderr;
expect(out).toMatch(/OCI runtime exec failed/);
expect(out).toMatch(msg);
});
}
// As observed (docker 29.x): a daemon-side exec failure before the process starts (unknown user —
// scratch has no /etc/passwd) makes `docker exec` exit 1, NOT >= 125. The harness must still
// classify it as infrastructure, never as an application failure.
const daemon = await c.execRaw(['/app', 'token', 'list'], { user: 'nobody' });
expect(daemon.exitCode).toBe(1);
expect(daemon.stderr).toMatch(/^Error response from daemon: unable to find user nobody/);
await expect(c.exec(['token', 'list'], { user: 'nobody' })).rejects.toThrow(/\[harness:infrastructure\]/);
await expect(execApp(c, ['token', 'list'], { user: 'nobody' })).rejects.toThrow(/\[harness:infrastructure\]/);
expect(await c.isRunning()).toBe(true);
expect((await c.stop()).exitCode).toBe(0);
});
});
test.describe('docker exec /app audit and /app help', () => {
test('help: usage text on stdout only, exit 0 (also at DEBUG: stdout identical, stderr only log records)', async ({ image }) => {
const c = await image.start({ suffix: 'help' });
const r = await c.exec(['help']);
expectCliSuccess(r, { stdout: USAGE_TEXT, stderr: '' });
const debug = await c.exec(['help'], { env: { LOG_LEVEL: 'DEBUG' } });
expectCliSuccess(debug, { stdout: USAGE_TEXT });
const logs = parseLogs(debug.stderr);
expect(logs.unparsed.map((u) => u.text), 'DEBUG stderr consists of log records only').toEqual([]);
expect(logs.find({ msg: 'dispatch' })?.attrs).toEqual({ mode: 'cli' });
expect(logs.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([{ command: 'help' }]);
expect(logs.count({ msg: 'database opened' }), 'help never opens the database').toBe(0);
expect(await c.isRunning(), 'the server is unaffected').toBe(true);
expect((await c.stop()).exitCode).toBe(0);
});
test('audit: every selection form (incl. audit all) equals the operations rows of the DB snapshot (padded table, oldest first, full tokens registered as secrets); read-only; usage errors', async ({ image, http, secrets }) => {
const c = await image.start({ suffix: 'audit' });
expectCliSuccess(await c.exec(['audit']), { stdout: EMPTY_AUDIT, stderr: '' });
expectCliSuccess(await c.exec(['audit', 'all']), { stdout: EMPTY_AUDIT, stderr: '' });
const created: string[] = [];
for (const d of ['audit one', 'audit two', 'audit three']) {
const r = await c.exec(['token', 'new', d]);
expectCliSuccess(r, { stdout: TOKEN_LINE, stderr: '' });
created.push(r.stdout.trim());
}
const [a, b, gone] = created;
c.fakeDocker!.many(1);
const plan = [a, b, a, gone, a, b, a, a, b, a, gone, a, b];
for (const t of plan) expectContainersResponse(await http.get(c.url(), { token: t }));
expectCliSuccess(await c.exec(['token', 'delete', gone]), { stdout: '', stderr: '' });
const snap = await c.snapshotDb();
const ops = readOperations(snap);
expect(ops.map((o) => o.token), 'one row per authenticated request, in order').toEqual(plan);
const state = stateDigest(snap);
for (const [args, sel] of [
[[], { limit: 10n }],
[['1'], { limit: 1n }],
[['5'], { limit: 5n }],
[['13'], { limit: 13n }],
[['1000'], { limit: 1000n }],
[['0010'], { limit: 10n }],
] as Array<[string[], { limit: bigint }]>) {
const r = await c.exec(['audit', ...args]);
expectCliSuccess(r, { stdout: expectedAuditStdout(ops, sel), stderr: '' });
}
// windows: every row is minutes old at most, so 1h / 1d / 1w and the largest window print all of them
for (const w of ['1h', '1d', '1w', '15250w']) {
const r = await c.exec(['audit', w]);
expectCliSuccess(r, { stdout: expectedAuditStdout(ops, { limit: 1000n }), stderr: '' });
}
const all = parseAuditOutput((await c.exec(['audit', '100'])).stdout);
expect(all.map((x) => x.token), 'oldest first, the deleted token printed in full').toEqual(plan);
for (const t of created) expect(secrets.has(t), 'tokens printed by audit are registered secrets').toBe(true);
// audit all: every row (13 > the default 10), oldest first, exact padded table
const everything = await c.exec(['audit', 'all']);
expectCliSuccess(everything, { stdout: expectedAuditStdout(ops, { all: true }), stderr: '' });
expect(everything.stdout).toBe(padTable([[...AUDIT_COLUMNS], ...chronological(ops).map((o) => [o.time, o.token, o.origin, o.operation])]));
expect(parseAuditOutput(everything.stdout).map((x) => x.token), 'audit all: every row, oldest first').toEqual(plan);
// at DEBUG: identical stdout, the dispatch record says all=true, no token in the log records
const debug = await c.exec(['audit', 'all'], { env: { LOG_LEVEL: 'DEBUG' } });
expectCliSuccess(debug, { stdout: everything.stdout });
const logs = parseLogs(debug.stderr);
expect(logs.unparsed.map((u) => u.text), 'DEBUG stderr consists of log records only').toEqual([]);
expect(logs.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([{ command: 'audit', all: 'true' }]);
secrets.assertNoSecretsIn(debug.stderr, 'DEBUG stderr of docker exec /app audit all');
for (const [args, msg] of [
[['0'], 'invalid audit argument'],
[['5H'], 'invalid audit argument'],
[['-1'], 'invalid audit argument'],
[['1', 'h'], 'audit takes at most one argument'],
[['ALL'], 'invalid audit argument'],
[['All'], 'invalid audit argument'],
[['--all'], 'invalid audit argument'],
[['all1'], 'invalid audit argument'],
[['all', 'x'], 'audit takes at most one argument'],
] as Array<[string[], string]>) {
const r = await c.exec(['audit', ...args]);
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}` });
expect(r.stderr, 'exact stderr, the argument is not echoed').toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
}
expect((await c.stop()).exitCode).toBe(0);
const after = await c.snapshotDb();
expect(stateDigest(after), 'audit never writes').toBe(state);
assertDbInvariants(after, { tokenCount: 3, operationCount: plan.length, auditBeforeDeletion: true });
});
});
test('curl from outside the container reaches /api/v1/containers on the real Engine (suite containers and dpsapi itself listed; 401s)', async ({ image, docker, http, testKey }) => {
test.setTimeout(120_000);
const [a, b] = await Promise.all([
docker.run({ suffix: 'peer-a', labels: { 'com.example.role': 'a' } }),
docker.run({ suffix: 'peer.b_2', command: ['tail', '-f', '/dev/null'] }),
]);
const c = await image.start({ docker: 'real', suffix: 'real' });
const token = (await c.exec(['token', 'new', 'curl outside'])).stdout.trim();
const inactive = (await c.exec(['token', 'new', 'inactive'])).stdout.trim();
expectCliSuccess(await c.exec(['token', 'delete', inactive]), { stdout: '', stderr: '' });
const res = await http.curl({ url: c.url(), token });
expect(res.exitCode).toBe(0);
const list = expectContainersResponse<{ Id: string; Names: string[]; Labels: Record<string, string>; State: string; Image: string }>(res);
const own = list.filter((x) => x.Labels?.['dpsapi.e2e.test'] === testKey);
expect(own.map((x) => x.Id).sort(), 'exactly our running containers, including the dpsapi container itself').toEqual([a.id, b.id, c.id].sort());
expect(own.map((x) => x.Id).sort()).toEqual((await docker.runningIds()).sort());
const self = own.find((x) => x.Id === c.id)!;
expect(self.Names).toEqual([`/${c.name}`]);
expect(self.Image).toBe(image.image);
expect(own.find((x) => x.Id === a.id)!.Labels['com.example.role']).toBe('a');
expect(list.every((x) => x.State === 'running'), 'only running containers').toBe(true);
// the same through Playwright's client
expect(expectContainersResponse<{ Id: string; Labels: Record<string, string> }>(await http.get(c.url(), { token })).filter((x) => x.Labels?.['dpsapi.e2e.test'] === testKey).map((x) => x.Id).sort()).toEqual([a.id, b.id, c.id].sort());
const unauth: Array<[string, string[]]> = [
['no Authorization header', []],
['unknown token', ['Authorization: Bearer ' + 'Z'.repeat(43)]],
['inactive token', [`Authorization: Bearer ${inactive}`]],
['wrong scheme', [`Authorization: Basic ${token}`]],
['empty bearer', ['Authorization: Bearer ']],
['two Authorization headers', [`Authorization: Bearer ${token}`, `Authorization: Bearer ${token}`]],
];
for (const [name, headers] of unauth) {
await test.step(name, async () => {
const r = await http.curl({ url: c.url(), headers });
expect(r.exitCode).toBe(0);
expectErrorResponse(r, 401);
});
}
const origins = await expectedOrigins(c); // network info is only available while running
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const snap = await c.snapshotDb();
const ops = readOperations(snap);
expect(ops, 'one audit row per authenticated request, none for the 401s').toHaveLength(2);
for (const o of ops) {
expect(o.token).toBe(token);
expect(origins).toContain(o.origin);
}
assertDbInvariants(snap, { tokenCount: 2, operationCount: 2 });
});
+194
View File
@@ -0,0 +1,194 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12 matrix "Docker socket available, inaccessible, temporarily failing, and returning
* empty/non-empty container sets" inside the image: socket not mounted, FakeDocker socket mounted
* (empty, 1000 containers, Engine error, flaky then recovery, connection dropped), permission
* denied for a non-root container user, and the real Engine reflecting containers the suite starts
* and stops. The FakeDocker socket is bind-mounted, so its inode is pinned: modes are switched
* live, the socket is never recreated.
*/
import fs from 'node:fs';
import {
test,
expect,
assertDbInvariants,
expectContainersResponse,
expectErrorResponse,
expectNoInternalDetails,
parseLogs,
readOperations,
type ContainerSummary,
type HttpClient,
type ImageContainer,
type RealDocker,
type TestContainer,
} from '../../harness';
import { chownDir, expectedOrigins, ownerOf } from './image-helpers';
test('socket not mounted at all: server starts, authenticated requests → 500 generic body, still audited', async ({ image, http }) => {
const c = await image.start({ docker: 'none', logLevel: 'DEBUG', suffix: 'nosock' });
const token = (await c.exec(['token', 'new', 'no socket'])).stdout.trim();
const pw = await http.get(c.url(), { token });
expectErrorResponse(pw, 500);
expectNoInternalDetails(pw.body);
const cu = await http.curl({ url: c.url(), token });
expect(cu.exitCode).toBe(0);
expectErrorResponse(cu, 500);
expectErrorResponse(await http.get(c.url()), 401);
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const logs = parseLogs(exit.stderr);
const errors = logs.filter({ level: 'ERROR', msg: 'docker request failed' });
expect(errors).toHaveLength(2);
expect(errors[0].attrs.error, 'diagnostic stays in the log').toMatch(/no such file or directory/);
expect(logs.filter({ msg: 'audit row inserted' })).toHaveLength(2);
const snap = await c.snapshotDb();
expect(readOperations(snap).map((o) => o.token), 'audited before the Docker call failed').toEqual([token, token]);
assertDbInvariants(snap, { tokenCount: 1, operationCount: 2 });
});
test('FakeDocker socket: empty → [], 1000 containers, Engine errors → 500, flaky then recovery', async ({ image, http }) => {
const c = await image.start({ suffix: 'fake' });
const f = c.fakeDocker!;
const token = (await c.exec(['token', 'new'])).stdout.trim();
let authenticated = 0;
const get = async () => {
authenticated++;
return http.get(c.url(), { token });
};
await test.step('empty', async () => {
f.empty();
const r = await get();
expect(expectContainersResponse(r)).toEqual([]);
expect(r.body).toBe('[]');
const cu = await http.curl({ url: c.url(), token });
authenticated++;
expect(cu.body).toBe('[]');
});
await test.step('many: 1000 summaries, complete and in order', async () => {
f.many(1000);
const ids = f.containers.map((x) => x.Id);
const got = expectContainersResponse<ContainerSummary>(await get());
expect(got.map((x) => x.Id)).toEqual(ids);
expect(got[999].Names).toEqual(['/fake-999']);
});
await test.step('Engine 500 / dropped connection / malformed JSON → 500 without details', async () => {
for (const apply of [() => f.error500('boom: /var/lib/docker secret path'), () => f.destroy(), () => f.badJson('{"x"')]) {
apply();
const r = await get();
expectErrorResponse(r, 500);
expectNoInternalDetails(r.body);
}
});
await test.step('flaky (2 failures) then recovery', async () => {
f.many(3).flaky(2);
const statuses: number[] = [];
for (let i = 0; i < 4; i++) statuses.push((await get()).status);
expect(statuses).toEqual([500, 500, 200, 200]);
});
// One Engine call per authenticated request, except the dropped-connection case: Go's HTTP
// transport transparently retries an idempotent GET once when a reused keep-alive connection is
// closed before any response byte (as observed; transport behaviour, not app logic).
expect(f.listCalls(), 'Engine list calls').toBeGreaterThanOrEqual(authenticated);
expect(f.listCalls(), 'Engine list calls (at most one transport retry)').toBeLessThanOrEqual(authenticated + 1);
expect(f.stats.other, 'no other Engine endpoints').toBe(0);
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const logs = parseLogs(exit.stderr);
expect(logs.filter({ level: 'ERROR' }).map((r) => r.msg)).toEqual(Array(5).fill('docker request failed'));
expect(logs.find({ level: 'ERROR' })!.attrs.error).toContain('boom');
assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: authenticated });
});
test('permission denied: --user 65534:65534 cannot open the mounted socket → 500, audited; data dir owned by that uid', async ({ image, http, ws, testKey, newFakeDocker }) => {
// FakeDocker socket owned by the host user, mode 0600: uid 65534 has no access.
const fake = await newFakeDocker();
fs.chmodSync(fake.socketPath, 0o600);
fake.many(2);
const variants: Array<{ name: string; docker: 'real' | typeof fake }> = [
{ name: 'FakeDocker socket (0600, other owner)', docker: fake },
// the real socket is root:docker 0660 — uid 65534 with no supplementary groups cannot connect
{ name: 'real Engine socket', docker: 'real' },
];
const containers: ImageContainer[] = [];
for (const [i, v] of variants.entries()) {
// /data must be writable by uid 65534: hand a fresh dir to it (0700) through a helper container
const dataDir = ws.mkdir(`data-nobody-${i}`);
await chownDir(testKey, dataDir, '65534:65534', '700');
containers.push(await image.start({ user: '65534:65534', docker: v.docker, dataDir, logLevel: 'DEBUG', suffix: `nobody${i}` }));
}
for (const [i, c] of containers.entries()) {
await test.step(variants[i].name, async () => {
const created = await c.exec(['token', 'new', 'as nobody']);
expect(created.exitCode, created.stderr).toBe(0);
const token = created.stdout.trim();
expect(await ownerOf(testKey, c.dataDir, 'dpsapi.db'), 'DB created by the container user').toBe('65534:65534');
const r = await http.get(c.url(), { token });
expectErrorResponse(r, 500);
expectNoInternalDetails(r.body);
expectErrorResponse(await http.get(c.url()), 401);
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const err = parseLogs(exit.stderr).filter({ level: 'ERROR', msg: 'docker request failed' });
expect(err).toHaveLength(1);
expect(err[0].attrs.error).toMatch(/permission denied/);
expect(parseLogs(exit.stderr).find({ msg: 'server listening' })!.attrs.addr, 'non-root still binds :80 (Docker sets ip_unprivileged_port_start=0)').toBe('0.0.0.0:80');
assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: 1 });
});
}
expect(fake.listCalls(), 'nothing reached the FakeDocker').toBe(0);
expect(fake.stats.total).toBe(0);
});
interface ApiContainer {
Id: string;
Names: string[];
Labels: Record<string, string>;
State: string;
}
async function ownViaApi(http: HttpClient, c: ImageContainer, token: string, docker: RealDocker): Promise<string[]> {
const pw = expectContainersResponse<ApiContainer>(await http.get(c.url(), { token }));
const cu = await http.curl({ url: c.url(), token });
expect(cu.exitCode).toBe(0);
const own = (l: ApiContainer[]) => l.filter((x) => x.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((x) => x.Id).sort();
expect(own(expectContainersResponse<ApiContainer>(cu)), 'curl and Playwright agree').toEqual(own(pw));
expect(pw.filter((x) => x.State !== 'running'), 'running containers only').toEqual([]);
return own(pw);
}
test('real Engine socket: containers started, stopped, restarted and removed by the suite are reflected', async ({ image, docker, http }) => {
test.setTimeout(120_000);
const c = await image.start({ docker: 'real', suffix: 'realsock' });
const token = (await c.exec(['token', 'new'])).stdout.trim();
const expectState = async (expected: TestContainer[], label: string) => {
const api = await ownViaApi(http, c, token, docker);
expect(api, `${label}: API = docker ps for our label`).toEqual((await docker.runningIds()).sort());
expect(api, `${label}: expected set (+ the dpsapi container)`).toEqual([...expected.map((x) => x.id), c.id].sort());
};
await expectState([], 'only dpsapi');
const [x, y, z] = await docker.runMany(3, (i) => ({ suffix: `r${i}`, labels: { 'com.example.i': String(i) } }));
await expectState([x, y, z], 'three started');
await docker.stop(y, 1);
await expectState([x, z], 'one stopped');
await docker.start(y);
await expectState([x, y, z], 'restarted');
await Promise.all([docker.remove(x), docker.remove(z)]);
await expectState([y], 'two removed');
await docker.remove(y);
await expectState([], 'all removed');
const origins = await expectedOrigins(c);
expect((await c.stop()).exitCode).toBe(0);
const snap = await c.snapshotDb();
const ops = readOperations(snap);
expect(ops).toHaveLength(12);
for (const o of ops) expect(origins).toContain(o.origin);
assertDbInvariants(snap, { tokenCount: 1, operationCount: 12 });
});
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Self-check of the image helpers used by the container-image suite (phase 2 adds the real
* image tests next to this file). Proves: start with socket + data mounts and a published port,
* HTTP readiness, `docker exec` CLI results, curl from outside, DB snapshot of root-owned files,
* graceful stop exit code, one-shot `docker run`, and cleanup.
*/
import {
test,
expect,
assertDbInvariants,
expectCliSuccess,
expectContainersResponse,
parseLogs,
parseTokenList,
readOperations,
readTokens,
} from '../../harness';
test('image helpers: start, exec, HTTP, snapshot, stop, one-shot run', async ({ image, http }) => {
test.setTimeout(120_000);
const c = await image.start({ logLevel: 'INFO' });
expect(c.name).toMatch(/^dpsapi-e2e-[0-9a-f]{8}-[0-9a-f]{8}-img\d+$/);
c.fakeDocker!.many(2);
const created = await c.exec(['token', 'new', 'image selfcheck']);
expectCliSuccess(created, { stderr: '' });
const token = created.stdout.trim();
const listed = await c.exec(['token', 'list']);
expectCliSuccess(listed, { stderr: '' });
expect(parseTokenList(listed.stdout).map((r) => [r.token, r.description])).toEqual([[token, 'image selfcheck']]);
expect(expectContainersResponse(await http.get(c.url(), { token }))).toHaveLength(2);
expect(expectContainersResponse(await http.curl({ url: c.url(), token }))).toHaveLength(2);
const snap = await c.snapshotDb();
expect(readTokens(snap).map((t) => t.token)).toEqual([token]);
expect(readOperations(snap)).toHaveLength(2);
assertDbInvariants(snap, { tokenCount: 1, operationCount: 2 });
const exit = await c.stop();
expect(exit.exitCode).toBe(0);
const logs = parseLogs(exit.stderr);
expect(logs.requests()).toHaveLength(2);
expect(logs.has({ msg: 'server stopped' })).toBe(true);
// one-shot container on the same data directory
const again = await image.run(['token', 'list'], { dataDir: c.dataDir });
expectCliSuccess(again);
expect(parseTokenList(again.stdout)).toHaveLength(1);
});
test('image helpers: the image binary extracted to the host runs through the host CLI/server helpers', async ({ image, cli, startServer, http, ws }) => {
const bin = await image.extractBinary();
const imageCli = cli.withBinary(bin);
const token = await imageCli.tokenNew('from extracted binary');
const server = await startServer({ binary: bin });
expectContainersResponse(await http.get(server.url(), { token }));
expect((await server.stop()).exitCode).toBe(0);
expect((await imageCli.tokenList()).map((r) => r.token)).toEqual([token]);
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 1 });
});
+213
View File
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Helpers shared by the container-image specs (not a spec file itself: Playwright only collects
* `*.spec.ts`). Kept next to the specs instead of in harness/ so the shared harness stays
* untouched; everything here only creates resources named/labelled like the harness does.
*
* What the harness lacked for these tests:
* - running a throwaway, labelled helper container (chown/stat of a data dir for the
* `--user 65534:65534` case) — image.ts only has snapshot/remove helpers;
* - creating a never-started container for `docker export`, and `docker save` of the image;
* - parsing `tar -tv` listings and ELF program headers.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ALPINE_IMAGE,
HarnessError,
LABEL_RUN,
LABEL_TEST,
RESOURCE_PREFIX,
dockerCli,
expect,
runId,
runProcess,
toolEnv,
type CliResult,
type ImageContainer,
type ImageExecOptions,
type ProcResult,
type Workspace,
} from '../../harness';
let helperSeq = 0;
/** Labels every suite-owned container must carry (so teardown removes and leak-checks it). */
export function ownLabels(testKey: string): string[] {
return ['--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${testKey}`];
}
/** Name of a suite-owned helper container. */
export function helperName(testKey: string, what: string): string {
return `${RESOURCE_PREFIX}${runId()}-${testKey}-${what}${++helperSeq}`;
}
/**
* Runs a throwaway alpine container (no network, labelled) with `dir` mounted at /d and returns
* its result; a non-zero exit is a HarnessError (helper failures are never app outcomes).
*/
export async function alpineOnDir(testKey: string, dir: string, script: string): Promise<ProcResult> {
if (!fs.existsSync(dir)) throw new Error(`helper mount source ${dir} does not exist (dockerd would create a root-owned dir)`);
return dockerCli(['run', '--rm', '--name', helperName(testKey, 'helper'), ...ownLabels(testKey), '--network', 'none', '-v', `${dir}:/d`, ALPINE_IMAGE, 'sh', '-c', script]);
}
/** chown/chmod a host directory through a helper container (e.g. hand it to uid 65534). */
export async function chownDir(testKey: string, dir: string, owner: string, mode: string): Promise<void> {
await alpineOnDir(testKey, dir, `chown ${owner} /d && chmod ${mode} /d`);
}
/** `uid:gid` of a file inside a directory the host user may not be able to traverse. */
export async function ownerOf(testKey: string, dir: string, file: string): Promise<string> {
return (await alpineOnDir(testKey, dir, `stat -c %u:%g /d/${file}`)).stdout.trim();
}
export interface TarEntry {
/** '-' regular, 'd' directory, 'l' symlink, ... */
type: string;
perms: string;
owner: string;
size: number;
/** Path as stored (directories keep their trailing slash). */
name: string;
linkTarget?: string;
}
/** `tar -tvf <file>` parsed (GNU tar format). */
export async function listTar(file: string): Promise<TarEntry[]> {
const r = await runProcess('tar', ['-tvf', file], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -tv' });
expect(r.exitCode, `tar -tvf ${file}: ${r.stderr}`).toBe(0);
return r.stdout
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const m = /^(\S)(\S{9})\s+(\S+)\s+(\d+)\s+\S+\s+\S+\s+(.*)$/.exec(l);
if (!m) throw new Error(`unparseable tar listing line ${JSON.stringify(l)}`);
const [name, linkTarget] = m[1] === 'l' ? m[5].split(' -> ') : [m[5], undefined];
return { type: m[1], perms: m[2], owner: m[3], size: Number(m[4]), name, linkTarget };
});
}
export interface SavedImage {
/** Directory the `docker save` archive was unpacked into. */
dir: string;
manifest: Array<{ Config: string; RepoTags: string[] | null; Layers: string[] }>;
config: {
architecture: string;
os: string;
config: Record<string, unknown>;
history: Array<{ created_by?: string; empty_layer?: boolean }>;
rootfs: { type: string; diff_ids: string[] };
};
/** Entries of every layer tarball, in layer order. */
layers: TarEntry[][];
}
/** `docker save` + unpack: manifest, image config and the listing of every layer (no container is created). */
export async function saveImage(ws: Workspace, image: string): Promise<SavedImage> {
const tar = ws.path('image-save.tar');
const dir = ws.mkdir('image-save');
await dockerCli(['save', '-o', tar, image], { timeoutMs: 120_000 });
const x = await runProcess('tar', ['-xf', tar, '-C', dir], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -x' });
expect(x.exitCode, x.stderr).toBe(0);
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')) as SavedImage['manifest'];
expect(manifest, 'docker save archive holds exactly one image').toHaveLength(1);
const config = JSON.parse(fs.readFileSync(path.join(dir, manifest[0].Config), 'utf8')) as SavedImage['config'];
const layers: TarEntry[][] = [];
for (const l of manifest[0].Layers) layers.push(await listTar(path.join(dir, l)));
return { dir, manifest, config, layers };
}
/**
* `docker create` (never started) + `docker export`: the container's merged filesystem as Docker
* materializes it, including the entries Docker injects at container creation.
*/
export async function exportCreatedContainer(ws: Workspace, testKey: string, image: string): Promise<TarEntry[]> {
const name = helperName(testKey, 'export');
const out = ws.path('container-export.tar');
await dockerCli(['create', '--name', name, ...ownLabels(testKey), '--network', 'none', image]);
try {
await dockerCli(['export', '-o', out, name], { timeoutMs: 120_000 });
} finally {
await dockerCli(['rm', '-f', name], { allowFailure: true });
}
return listTar(out);
}
export interface ElfInfo {
elfClass: 32 | 64;
littleEndian: boolean;
/** e_type: 2 ET_EXEC, 3 ET_DYN. */
type: number;
machine: number;
/** p_type of every program header. */
programHeaderTypes: number[];
}
export const PT_DYNAMIC = 2;
export const PT_INTERP = 3;
export const EM_BY_ARCH: Record<string, number> = { amd64: 62, arm64: 183, '386': 3, arm: 40 };
/** Minimal ELF header + program header table parser. */
export function readElf(file: string): ElfInfo {
const b = fs.readFileSync(file);
expect(b.subarray(0, 4).toString('latin1'), 'ELF magic').toBe('\x7fELF');
const elfClass = b[4] === 2 ? 64 : 32;
const le = b[5] === 1;
const u16 = (o: number) => (le ? b.readUInt16LE(o) : b.readUInt16BE(o));
const u32 = (o: number) => (le ? b.readUInt32LE(o) : b.readUInt32BE(o));
const u64 = (o: number) => Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o));
const type = u16(16);
const machine = u16(18);
const phoff = elfClass === 64 ? u64(32) : u32(28);
const phentsize = elfClass === 64 ? u16(54) : u16(42);
const phnum = elfClass === 64 ? u16(56) : u16(44);
const programHeaderTypes: number[] = [];
for (let i = 0; i < phnum; i++) programHeaderTypes.push(u32(phoff + i * phentsize));
return { elfClass, littleEndian: le, type, machine, programHeaderTypes };
}
/** Tag of an image reference (`repo/name:tag` → `tag`), undefined for digests/untagged refs. */
export function imageTag(ref: string): string | undefined {
if (ref.includes('@')) return undefined;
const last = ref.slice(ref.lastIndexOf('/') + 1);
const i = last.indexOf(':');
return i < 0 ? undefined : last.slice(i + 1);
}
/** Gateway of the container's (single) network: the source address docker-proxy uses toward it. Call while the container runs. */
export async function gatewayOf(c: ImageContainer): Promise<string> {
const info = await c.inspect();
const nets = (info.NetworkSettings as { Networks: Record<string, { Gateway: string }> }).Networks;
const gws = Object.values(nets).map((n) => n.Gateway).filter(Boolean);
expect(gws, 'container has exactly one network with a gateway').toHaveLength(1);
return gws[0];
}
/**
* Origins a request from the host to a 127.0.0.1-published port may legitimately carry inside the
* container: the network gateway (docker-proxy / masquerade — what this host shows) or loopback
* (some userland-proxy-less setups). PLAN only requires "the request's network source address";
* the exact value through Docker's port publishing is environment-defined, asserted as observed.
*/
export async function expectedOrigins(c: ImageContainer): Promise<string[]> {
return [await gatewayOf(c), '127.0.0.1'];
}
/**
* `docker exec … /app` that also treats daemon-side exec failures as infrastructure errors.
* ImageContainer.exec only raises for exit codes >= 125, but the docker CLI exits 1 when the
* daemon refuses the exec before starting the process (e.g. unknown --user), which would otherwise
* look like the application's exit code 1.
*/
export async function execApp(c: ImageContainer, args: string[], opts: ImageExecOptions = {}): Promise<CliResult> {
const r = await c.exec(args, opts);
if (r.exitCode !== 0 && /^(Error response from daemon:|OCI runtime exec failed)/.test(r.stderr + r.stdout)) {
throw new HarnessError('infrastructure', `docker exec ${c.name} /app ${args.slice(0, 2).join(' ')} failed at the docker level (exit ${r.exitCode}): ${(r.stderr + r.stdout).trim()}`, r);
}
return r;
}
/** Hex of a string's UTF-8 bytes, upper case (as SQLite hex()). */
export const hex = (s: string): string => Buffer.from(s, 'utf8').toString('hex').toUpperCase();
+219
View File
@@ -0,0 +1,219 @@
// 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']);
});
+357
View File
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12: "At least one Playwright test must exercise the same scenario through multiple
* transport/client styles where practical: Playwright HTTP requests, curl, direct binary CLI
* execution, and docker exec against the final image" + the matrix line "binary invocation from
* the host test environment and invocation through docker exec in the container-image suite".
*
* One scenario (create, list, authenticated GET, bad-auth GETs, delete, GET → 401, list / list all,
* `audit`, audit check in the DB)
* runs through five driver combinations and the normalized outcomes must be identical. A second
* test proves the host binary and the image operate on the same on-disk format by handing one
* database back and forth.
*/
import {
test,
expect,
AUDIT_COLUMNS,
TOKEN_LIST_COLUMNS,
assertDbInvariants,
buildRawRequest,
expectCliSuccess,
expectContainersResponse,
expectErrorResponse,
makeContainerSummary,
parseAuditOutput,
parseLogs,
parseTokenList,
readOperations,
readTokens,
type CliResult,
type ContainerSummary,
type FakeDocker,
type ResponseView,
type SecretRegistry,
view,
} from '../../harness';
import { expectedOrigins } from './image-helpers';
/** The same Engine answer for every driver, so response bodies are comparable byte for byte. */
const FIXED_CONTAINERS: ContainerSummary[] = [
makeContainerSummary(0, undefined, { Names: ['/mt-web'], Labels: { role: 'web', 'unicode.label': 'naïve ☃' } }),
makeContainerSummary(1, undefined, { Names: ['/mt-db'] }),
makeContainerSummary(2, undefined, { Names: ['/mt-cache'], Command: 'redis-server --save ""' }),
];
const DESCRIPTION = 'multi-transport ☃ "quoted" $HOME `id` ; |';
const UNKNOWN = 'Q'.repeat(43);
interface Driver {
name: string;
tokenNew(description?: string): Promise<CliResult>;
/** `token list` (active tokens) or, with all, `token list all`. */
tokenList(all?: boolean): Promise<CliResult>;
audit(args?: string[]): Promise<CliResult>;
tokenDelete(token: string): Promise<CliResult>;
/** GET /api/v1/containers with exactly these Authorization header values (none when empty). */
get(authorization: string[]): Promise<ResponseView>;
/** Stops the server, returns its exit code + stderr and a readable copy of the database. */
finish(): Promise<{ exitCode: number; stderr: string; dbPath: string }>;
origins: string[];
}
interface Outcome {
cli: Array<{ step: string; exitCode: number | null; stdout: string; stderr: string }>;
http: Array<{ step: string; status: number; contentType?: string; wwwAuthenticate?: string; body: string }>;
list: Array<Array<[string, boolean, boolean]>>;
db: { tokens: Array<[string, number, boolean]>; operations: Array<[string, string]> };
serverExit: number;
logStatuses: string[];
}
/** Runs the scenario through one driver and returns its normalized outcome (tokens replaced by placeholders). */
async function runScenario(d: Driver, secrets: SecretRegistry): Promise<Outcome> {
const names = new Map<string, string>();
const norm = (s: string) => {
let out = s;
for (const [tok, label] of names) out = out.split(tok).join(label);
return out;
};
const out: Outcome = { cli: [], http: [], list: [], db: { tokens: [], operations: [] }, serverExit: -1, logStatuses: [] };
const cli = (step: string, r: CliResult, stdout = norm(r.stdout)) => out.cli.push({ step, exitCode: r.exitCode, stdout, stderr: r.stderr });
// tables are compared by cells (strictly parsed, padding re-rendered by the parser); timestamps
// and the driver-specific origin replaced by placeholders
const tokenTable = (stdout: string) =>
[TOKEN_LIST_COLUMNS.join('\t'), ...parseTokenList(stdout).map((r) => [norm(r.token), r.descriptionQuoted, String(r.active), '<time>', r.deletedAt === null ? '-' : '<time>'].join('\t'))].join('\n') + '\n';
const auditTable = (stdout: string) => {
const rows = parseAuditOutput(stdout);
for (const r of rows) expect(d.origins, `${d.name}: printed origin`).toContain(r.origin);
return [AUDIT_COLUMNS.join('\t'), ...rows.map((r) => ['<time>', norm(r.token), '<origin>', r.operation].join('\t'))].join('\n') + '\n';
};
const http = (step: string, r: ResponseView) => out.http.push({ step, status: r.status, contentType: r.headers['content-type'], wwwAuthenticate: r.headers['www-authenticate'], body: r.body });
const a = await d.tokenNew(DESCRIPTION);
expectCliSuccess(a, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
const token = secrets.register(a.stdout.trim());
names.set(token, '<A>');
cli('new A', a);
const b = await d.tokenNew();
expectCliSuccess(b, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
const bystander = secrets.register(b.stdout.trim());
names.set(bystander, '<B>');
cli('new B', b);
const l1 = await d.tokenList();
cli('list 1', l1, tokenTable(l1.stdout));
out.list.push(parseTokenList(l1.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const ok = await d.get([`Bearer ${token}`]);
expect(expectContainersResponse<ContainerSummary>(ok).map((c) => c.Names[0])).toEqual(['/mt-web', '/mt-db', '/mt-cache']);
http('GET valid', ok);
for (const [step, auth] of [
['GET no auth', []],
['GET unknown token', [`Bearer ${UNKNOWN}`]],
['GET wrong scheme', [`Basic ${token}`]],
] as Array<[string, string[]]>) {
const r = await d.get(auth);
expectErrorResponse(r, 401);
http(step, r);
}
const del = await d.tokenDelete(token);
cli('delete A', del);
const gone = await d.get([`Bearer ${token}`]);
expectErrorResponse(gone, 401);
http('GET deleted', gone);
const l2 = await d.tokenList(true);
cli('list all 2', l2, tokenTable(l2.stdout));
out.list.push(parseTokenList(l2.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const l3 = await d.tokenList();
cli('list 3', l3, tokenTable(l3.stdout));
out.list.push(parseTokenList(l3.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const au = await d.audit();
cli('audit', au, auditTable(au.stdout));
const fin = await d.finish();
out.serverExit = fin.exitCode;
out.logStatuses = parseLogs(fin.stderr).requests().map((r) => r.attrs.status);
const tokens = readTokens(fin.dbPath);
out.db.tokens = tokens.map((t) => [names.get(t.token) ?? '<?>', t.active, t.deleted_at === null]);
const ops = readOperations(fin.dbPath);
for (const o of ops) expect(d.origins, `${d.name}: audited origin`).toContain(o.origin);
out.db.operations = ops.map((o) => [names.get(o.token) ?? '<?>', o.operation]);
assertDbInvariants(fin.dbPath, { tokenCount: 2, operationCount: 1, auditBeforeDeletion: true });
return out;
}
const EXPECTED: Outcome = {
cli: [
{ step: 'new A', exitCode: 0, stdout: '<A>\n', stderr: '' },
{ step: 'new B', exitCode: 0, stdout: '<B>\n', stderr: '' },
{ step: 'list 1', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<A>\t"multi-transport ☃ \\"quoted\\" $HOME \`id\` ; |"\ttrue\t<time>\t-\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'delete A', exitCode: 0, stdout: '', stderr: '' },
{ step: 'list all 2', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<A>\t"multi-transport ☃ \\"quoted\\" $HOME \`id\` ; |"\tfalse\t<time>\t<time>\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'list 3', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'audit', exitCode: 0, stdout: `TIME\tTOKEN\tORIGIN\tOPERATION\n<time>\t<A>\t<origin>\tGET containers\n`, stderr: '' },
],
http: [
// body: re-serialized by the app (Docker SDK container.Summary); compared across drivers below
{ step: 'GET valid', status: 200, contentType: 'application/json', wwwAuthenticate: undefined, body: '<same for every driver>' },
{ step: 'GET no auth', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET unknown token', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET wrong scheme', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET deleted', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
],
list: [
[
[DESCRIPTION, true, true],
['', true, true],
],
[
[DESCRIPTION, false, false],
['', true, true],
],
[['', true, true]],
],
db: {
tokens: [
['<A>', 0, false],
['<B>', 1, true],
],
operations: [['<A>', 'GET containers']],
},
serverExit: 0,
logStatuses: ['200', '401', '401', '401', '401'],
};
test('same scenario through host CLI + Playwright / curl / raw socket and docker exec + curl / Playwright: equivalent outcomes', async ({ cli, startServer, http, image, ws, secrets }) => {
test.setTimeout(120_000);
/** Host binary (`cli` fixture) + host server on a dedicated DB, with an HTTP client style. */
const hostDriver = async (name: string, style: 'playwright' | 'curl' | 'raw'): Promise<Driver> => {
const dir = ws.mkdir(`mt-${style}`);
const dbPath = `${dir}/dpsapi.db`;
const env = { DB_PATH: dbPath };
const server = await startServer({ dbPath, logLevel: 'INFO' });
(server.fakeDocker as FakeDocker).setContainers(FIXED_CONTAINERS);
const get = async (auth: string[]): Promise<ResponseView> => {
if (style === 'playwright') {
if (auth.length > 1) throw new Error('the Playwright client sends at most one Authorization header');
return view(await http.get(server.url(), { headers: auth.length ? { Authorization: auth[0] } : {} }));
}
if (style === 'curl') {
const r = await http.curl({ url: server.url(), headers: auth.map((v) => `Authorization: ${v}`) });
expect(r.exitCode).toBe(0);
return view(r);
}
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers', headers: auth.map((v) => ['Authorization', v] as [string, string]) }) });
expect(r.outcome).toBe('response');
return view(r.response!);
};
return {
name,
tokenNew: (desc) => cli.run(desc === undefined ? ['token', 'new'] : ['token', 'new', desc], { env }),
tokenList: (all) => cli.run(all ? ['token', 'list', 'all'] : ['token', 'list'], { env }),
audit: (args = []) => cli.run(['audit', ...args], { env }),
tokenDelete: (t) => cli.run(['token', 'delete', t], { env }),
get,
finish: async () => {
const exit = await server.stop();
return { exitCode: exit.exitCode ?? -1, stderr: exit.stderr, dbPath };
},
origins: ['127.0.0.1'],
};
};
/** Final image: docker exec for the CLI, a client from outside the container for HTTP. */
const imageDriver = async (name: string, style: 'curl' | 'playwright'): Promise<Driver> => {
const c = await image.start({ logLevel: 'INFO', suffix: `mt-${style}` });
c.fakeDocker!.setContainers(FIXED_CONTAINERS);
const origins = await expectedOrigins(c);
return {
name,
tokenNew: (desc) => c.exec(desc === undefined ? ['token', 'new'] : ['token', 'new', desc]),
tokenList: (all) => c.exec(all ? ['token', 'list', 'all'] : ['token', 'list']),
audit: (args = []) => c.exec(['audit', ...args]),
tokenDelete: (t) => c.exec(['token', 'delete', t]),
get: async (auth) => {
if (style === 'playwright') return view(await http.get(c.url(), { headers: auth.length ? { Authorization: auth[0] } : {} }));
const r = await http.curl({ url: c.url(), headers: auth.map((v) => `Authorization: ${v}`) });
expect(r.exitCode).toBe(0);
return view(r);
},
finish: async () => {
const exit = await c.stop();
return { exitCode: exit.exitCode, stderr: exit.stderr, dbPath: await c.snapshotDb() };
},
origins,
};
};
const drivers = await Promise.all([
hostDriver('host binary CLI + Playwright request', 'playwright'),
hostDriver('host binary CLI + curl', 'curl'),
hostDriver('host binary CLI + raw socket', 'raw'),
imageDriver('image: docker exec + curl from outside', 'curl'),
imageDriver('image: docker exec + Playwright request from outside', 'playwright'),
]);
// the drivers are independent (own DB, server/container, FakeDocker): run them concurrently
const outcomes = await Promise.all(drivers.map(async (d) => [d.name, await runScenario(d, secrets)] as const));
const body200 = outcomes[0][1].http[0].body;
expect(JSON.parse(body200).map((c: ContainerSummary) => c.Id)).toEqual(FIXED_CONTAINERS.map((c) => c.Id));
for (const [name, o] of outcomes) {
await test.step(name, async () => {
expect(o.http[0].body, `${name}: identical 200 body bytes`).toBe(body200);
const comparable = { ...o, http: o.http.map((h) => (h.status === 200 ? { ...h, body: '<same for every driver>' } : h)) };
expect(comparable, `${name}: outcome equals the expected scenario outcome`).toEqual(EXPECTED);
});
}
// pairwise equivalence (implied by the above, stated directly)
for (const [name, o] of outcomes.slice(1)) expect(o, `${name} ≡ ${outcomes[0][0]}`).toEqual(outcomes[0][1]);
});
test('cross-over: a DB created by the host binary is served by the image, and an image-created DB by the host binary', async ({ cli, startServer, http, image, ws, secrets }) => {
test.setTimeout(120_000);
await test.step('host → image → host on the same directory', async () => {
const dir = ws.mkdir('shared');
const env = { DB_PATH: `${dir}/dpsapi.db` };
const hostTok = await cli.tokenNew('made on the host', { env });
const hostGone = await cli.tokenNew('deleted on the host', { env });
await cli.tokenDelete(hostGone, { env });
const hostServer = await startServer({ dbPath: env.DB_PATH });
expectContainersResponse(await http.get(hostServer.url(), { token: hostTok }));
expect((await hostServer.stop()).exitCode).toBe(0);
const hostList = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(hostList, { stderr: '' });
const hostAudit = await cli.run(['audit'], { env });
expectCliSuccess(hostAudit, { stderr: '' });
expect(parseAuditOutput(hostAudit.stdout)).toHaveLength(1);
// the image mounts the host-created directory as /data
const c = await image.start({ dataDir: dir, suffix: 'from-host' });
const imgList = await c.exec(['token', 'list', 'all']);
expectCliSuccess(imgList, { stdout: hostList.stdout, stderr: '' });
expectCliSuccess(await c.exec(['audit']), { stdout: hostAudit.stdout, stderr: '' });
expectContainersResponse(await http.get(c.url(), { token: hostTok }));
expectErrorResponse(await http.get(c.url(), { token: hostGone }), 401);
const imgTok = (await c.exec(['token', 'new', 'made in the image'])).stdout.trim();
expect(secrets.has(imgTok)).toBe(true);
expectCliSuccess(await c.exec(['token', 'delete', hostTok]), { stdout: '', stderr: '' });
const imgList2 = (await c.exec(['token', 'list', 'all'])).stdout;
const imgAudit2 = (await c.exec(['audit', '1000'])).stdout;
expectCliSuccess(await c.exec(['audit', 'all']), { stdout: imgAudit2, stderr: '' });
const origins = await expectedOrigins(c);
expect((await c.stop()).exitCode).toBe(0);
await c.remove();
// back on the host: same bytes listed, image-made token authenticates against the host server
const back = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(back, { stdout: imgList2, stderr: '' });
expectCliSuccess(await cli.run(['audit', '1000'], { env }), { stdout: imgAudit2, stderr: '' });
expectCliSuccess(await cli.run(['audit', 'all'], { env }), { stdout: imgAudit2, stderr: '' });
const hostServer2 = await startServer({ dbPath: env.DB_PATH });
expectContainersResponse(await http.get(hostServer2.url(), { token: imgTok }));
expectErrorResponse(await http.get(hostServer2.url(), { token: hostTok }), 401);
expect((await hostServer2.stop()).exitCode).toBe(0);
const ops = readOperations(env.DB_PATH);
expect(ops.map((o) => o.token)).toEqual([hostTok, hostTok, imgTok]);
expect(ops[0].origin).toBe('127.0.0.1');
expect(origins).toContain(ops[1].origin);
expect(ops[2].origin).toBe('127.0.0.1');
assertDbInvariants(env.DB_PATH, { tokenCount: 3, operationCount: 3, auditBeforeDeletion: true });
});
await test.step('image-created DB served by the host binary', async () => {
const c = await image.start({ suffix: 'to-host' });
const t1 = (await c.exec(['token', 'new', 'image one'])).stdout.trim();
const t2 = (await c.exec(['token', 'new', 'image two'])).stdout.trim();
expectContainersResponse(await http.get(c.url(), { token: t1 }));
expectCliSuccess(await c.exec(['token', 'delete', t2]), { stdout: '', stderr: '' });
const imgList = (await c.exec(['token', 'list', 'all'])).stdout;
const imgActive = (await c.exec(['token', 'list'])).stdout;
expect(parseTokenList(imgActive).map((r) => r.token), 'image: token list hides the deleted token').toEqual([t1]);
expect((await c.stop()).exitCode).toBe(0);
// the files are root-owned: serve a host-owned copy of the data directory (db + wal)
const copy = await c.snapshotDb();
const env = { DB_PATH: copy };
const hostList = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(hostList, { stdout: imgList, stderr: '' });
expectCliSuccess(await cli.run(['token', 'list'], { env }), { stdout: imgActive, stderr: '' });
const server = await startServer({ dbPath: copy });
expectContainersResponse(await http.get(server.url(), { token: t1 }));
expectErrorResponse(await http.get(server.url(), { token: t2 }), 401);
expect((await server.stop()).exitCode).toBe(0);
const t3 = await cli.tokenNew('host on image DB', { env });
expect(readTokens(copy).map((t) => [t.token, t.description, t.active])).toEqual([
[t1, 'image one', 1],
[t2, 'image two', 0],
[t3, 'host on image DB', 1],
]);
expect(readOperations(copy).map((o) => o.token)).toEqual([t1, t1]);
assertDbInvariants(copy, { tokenCount: 3, operationCount: 2 });
});
});