diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..623f0d0 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..28f89a5 --- /dev/null +++ b/dockerfile @@ -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"] diff --git a/e2e/tests/image/image-abuse.spec.ts b/e2e/tests/image/image-abuse.spec.ts new file mode 100644 index 0000000..48028a0 --- /dev/null +++ b/e2e/tests/image/image-abuse.spec.ts @@ -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([[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 }); +}); diff --git a/e2e/tests/image/image-contract.spec.ts b/e2e/tests/image/image-contract.spec.ts new file mode 100644 index 0000000..9f261e6 --- /dev/null +++ b/e2e/tests/image/image-contract.spec.ts @@ -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([ + ['.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(); // token -> description + const windows = new Map(); + 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 ""', 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; 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; 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; 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 }>(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 }); +}); diff --git a/e2e/tests/image/image-docker-socket.spec.ts b/e2e/tests/image/image-docker-socket.spec.ts new file mode 100644 index 0000000..03f138c --- /dev/null +++ b/e2e/tests/image/image-docker-socket.spec.ts @@ -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(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; + State: string; +} + +async function ownViaApi(http: HttpClient, c: ImageContainer, token: string, docker: RealDocker): Promise { + const pw = expectContainersResponse(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(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 }); +}); diff --git a/e2e/tests/image/image-harness-selfcheck.spec.ts b/e2e/tests/image/image-harness-selfcheck.spec.ts new file mode 100644 index 0000000..060e2fa --- /dev/null +++ b/e2e/tests/image/image-harness-selfcheck.spec.ts @@ -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 }); +}); diff --git a/e2e/tests/image/image-helpers.ts b/e2e/tests/image/image-helpers.ts new file mode 100644 index 0000000..1c13e37 --- /dev/null +++ b/e2e/tests/image/image-helpers.ts @@ -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 { + 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 { + 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 { + 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 ` parsed (GNU tar format). */ +export async function listTar(file: string): Promise { + 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; + 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 { + 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 { + 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 = { 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 { + const info = await c.inspect(); + const nets = (info.NetworkSettings as { Networks: Record }).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 { + 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 { + 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(); diff --git a/e2e/tests/image/image-lifecycle.spec.ts b/e2e/tests/image/image-lifecycle.spec.ts new file mode 100644 index 0000000..5796796 --- /dev/null +++ b/e2e/tests/image/image-lifecycle.spec.ts @@ -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']); +}); diff --git a/e2e/tests/image/multi-transport.spec.ts b/e2e/tests/image/multi-transport.spec.ts new file mode 100644 index 0000000..dad2d07 --- /dev/null +++ b/e2e/tests/image/multi-transport.spec.ts @@ -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; + /** `token list` (active tokens) or, with all, `token list all`. */ + tokenList(all?: boolean): Promise; + audit(args?: string[]): Promise; + tokenDelete(token: string): Promise; + /** GET /api/v1/containers with exactly these Authorization header values (none when empty). */ + get(authorization: string[]): Promise; + /** 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>; + 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 { + const names = new Map(); + 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), '