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.
569 lines
36 KiB
TypeScript
569 lines
36 KiB
TypeScript
// 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 });
|
|
});
|