feat(image): add scratch container image

Two-stage build producing a static /app on scratch; e2e tests for image contents, server and CLI through docker exec, socket handling, lifecycle and host/image parity.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 0a6023e942
commit 581a672fcf
9 files changed
+1858

No files matched your search

+357
View File
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* PLAN §12: "At least one Playwright test must exercise the same scenario through multiple
* transport/client styles where practical: Playwright HTTP requests, curl, direct binary CLI
* execution, and docker exec against the final image" + the matrix line "binary invocation from
* the host test environment and invocation through docker exec in the container-image suite".
*
* One scenario (create, list, authenticated GET, bad-auth GETs, delete, GET → 401, list / list all,
* `audit`, audit check in the DB)
* runs through five driver combinations and the normalized outcomes must be identical. A second
* test proves the host binary and the image operate on the same on-disk format by handing one
* database back and forth.
*/
import {
test,
expect,
AUDIT_COLUMNS,
TOKEN_LIST_COLUMNS,
assertDbInvariants,
buildRawRequest,
expectCliSuccess,
expectContainersResponse,
expectErrorResponse,
makeContainerSummary,
parseAuditOutput,
parseLogs,
parseTokenList,
readOperations,
readTokens,
type CliResult,
type ContainerSummary,
type FakeDocker,
type ResponseView,
type SecretRegistry,
view,
} from '../../harness';
import { expectedOrigins } from './image-helpers';
/** The same Engine answer for every driver, so response bodies are comparable byte for byte. */
const FIXED_CONTAINERS: ContainerSummary[] = [
makeContainerSummary(0, undefined, { Names: ['/mt-web'], Labels: { role: 'web', 'unicode.label': 'naïve ☃' } }),
makeContainerSummary(1, undefined, { Names: ['/mt-db'] }),
makeContainerSummary(2, undefined, { Names: ['/mt-cache'], Command: 'redis-server --save ""' }),
];
const DESCRIPTION = 'multi-transport ☃ "quoted" $HOME `id` ; |';
const UNKNOWN = 'Q'.repeat(43);
interface Driver {
name: string;
tokenNew(description?: string): Promise<CliResult>;
/** `token list` (active tokens) or, with all, `token list all`. */
tokenList(all?: boolean): Promise<CliResult>;
audit(args?: string[]): Promise<CliResult>;
tokenDelete(token: string): Promise<CliResult>;
/** GET /api/v1/containers with exactly these Authorization header values (none when empty). */
get(authorization: string[]): Promise<ResponseView>;
/** Stops the server, returns its exit code + stderr and a readable copy of the database. */
finish(): Promise<{ exitCode: number; stderr: string; dbPath: string }>;
origins: string[];
}
interface Outcome {
cli: Array<{ step: string; exitCode: number | null; stdout: string; stderr: string }>;
http: Array<{ step: string; status: number; contentType?: string; wwwAuthenticate?: string; body: string }>;
list: Array<Array<[string, boolean, boolean]>>;
db: { tokens: Array<[string, number, boolean]>; operations: Array<[string, string]> };
serverExit: number;
logStatuses: string[];
}
/** Runs the scenario through one driver and returns its normalized outcome (tokens replaced by placeholders). */
async function runScenario(d: Driver, secrets: SecretRegistry): Promise<Outcome> {
const names = new Map<string, string>();
const norm = (s: string) => {
let out = s;
for (const [tok, label] of names) out = out.split(tok).join(label);
return out;
};
const out: Outcome = { cli: [], http: [], list: [], db: { tokens: [], operations: [] }, serverExit: -1, logStatuses: [] };
const cli = (step: string, r: CliResult, stdout = norm(r.stdout)) => out.cli.push({ step, exitCode: r.exitCode, stdout, stderr: r.stderr });
// tables are compared by cells (strictly parsed, padding re-rendered by the parser); timestamps
// and the driver-specific origin replaced by placeholders
const tokenTable = (stdout: string) =>
[TOKEN_LIST_COLUMNS.join('\t'), ...parseTokenList(stdout).map((r) => [norm(r.token), r.descriptionQuoted, String(r.active), '<time>', r.deletedAt === null ? '-' : '<time>'].join('\t'))].join('\n') + '\n';
const auditTable = (stdout: string) => {
const rows = parseAuditOutput(stdout);
for (const r of rows) expect(d.origins, `${d.name}: printed origin`).toContain(r.origin);
return [AUDIT_COLUMNS.join('\t'), ...rows.map((r) => ['<time>', norm(r.token), '<origin>', r.operation].join('\t'))].join('\n') + '\n';
};
const http = (step: string, r: ResponseView) => out.http.push({ step, status: r.status, contentType: r.headers['content-type'], wwwAuthenticate: r.headers['www-authenticate'], body: r.body });
const a = await d.tokenNew(DESCRIPTION);
expectCliSuccess(a, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
const token = secrets.register(a.stdout.trim());
names.set(token, '<A>');
cli('new A', a);
const b = await d.tokenNew();
expectCliSuccess(b, { stdout: /^[A-Za-z0-9_-]{43}\n$/, stderr: '' });
const bystander = secrets.register(b.stdout.trim());
names.set(bystander, '<B>');
cli('new B', b);
const l1 = await d.tokenList();
cli('list 1', l1, tokenTable(l1.stdout));
out.list.push(parseTokenList(l1.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const ok = await d.get([`Bearer ${token}`]);
expect(expectContainersResponse<ContainerSummary>(ok).map((c) => c.Names[0])).toEqual(['/mt-web', '/mt-db', '/mt-cache']);
http('GET valid', ok);
for (const [step, auth] of [
['GET no auth', []],
['GET unknown token', [`Bearer ${UNKNOWN}`]],
['GET wrong scheme', [`Basic ${token}`]],
] as Array<[string, string[]]>) {
const r = await d.get(auth);
expectErrorResponse(r, 401);
http(step, r);
}
const del = await d.tokenDelete(token);
cli('delete A', del);
const gone = await d.get([`Bearer ${token}`]);
expectErrorResponse(gone, 401);
http('GET deleted', gone);
const l2 = await d.tokenList(true);
cli('list all 2', l2, tokenTable(l2.stdout));
out.list.push(parseTokenList(l2.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const l3 = await d.tokenList();
cli('list 3', l3, tokenTable(l3.stdout));
out.list.push(parseTokenList(l3.stdout).map((r) => [r.description, r.active, r.deletedAt === null]));
const au = await d.audit();
cli('audit', au, auditTable(au.stdout));
const fin = await d.finish();
out.serverExit = fin.exitCode;
out.logStatuses = parseLogs(fin.stderr).requests().map((r) => r.attrs.status);
const tokens = readTokens(fin.dbPath);
out.db.tokens = tokens.map((t) => [names.get(t.token) ?? '<?>', t.active, t.deleted_at === null]);
const ops = readOperations(fin.dbPath);
for (const o of ops) expect(d.origins, `${d.name}: audited origin`).toContain(o.origin);
out.db.operations = ops.map((o) => [names.get(o.token) ?? '<?>', o.operation]);
assertDbInvariants(fin.dbPath, { tokenCount: 2, operationCount: 1, auditBeforeDeletion: true });
return out;
}
const EXPECTED: Outcome = {
cli: [
{ step: 'new A', exitCode: 0, stdout: '<A>\n', stderr: '' },
{ step: 'new B', exitCode: 0, stdout: '<B>\n', stderr: '' },
{ step: 'list 1', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<A>\t"multi-transport ☃ \\"quoted\\" $HOME \`id\` ; |"\ttrue\t<time>\t-\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'delete A', exitCode: 0, stdout: '', stderr: '' },
{ step: 'list all 2', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<A>\t"multi-transport ☃ \\"quoted\\" $HOME \`id\` ; |"\tfalse\t<time>\t<time>\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'list 3', exitCode: 0, stdout: `TOKEN\tDESCRIPTION\tACTIVE\tCREATED_AT\tDELETED_AT\n<B>\t""\ttrue\t<time>\t-\n`, stderr: '' },
{ step: 'audit', exitCode: 0, stdout: `TIME\tTOKEN\tORIGIN\tOPERATION\n<time>\t<A>\t<origin>\tGET containers\n`, stderr: '' },
],
http: [
// body: re-serialized by the app (Docker SDK container.Summary); compared across drivers below
{ step: 'GET valid', status: 200, contentType: 'application/json', wwwAuthenticate: undefined, body: '<same for every driver>' },
{ step: 'GET no auth', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET unknown token', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET wrong scheme', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
{ step: 'GET deleted', status: 401, contentType: 'application/json', wwwAuthenticate: 'Bearer', body: '{"error":"unauthorized"}' },
],
list: [
[
[DESCRIPTION, true, true],
['', true, true],
],
[
[DESCRIPTION, false, false],
['', true, true],
],
[['', true, true]],
],
db: {
tokens: [
['<A>', 0, false],
['<B>', 1, true],
],
operations: [['<A>', 'GET containers']],
},
serverExit: 0,
logStatuses: ['200', '401', '401', '401', '401'],
};
test('same scenario through host CLI + Playwright / curl / raw socket and docker exec + curl / Playwright: equivalent outcomes', async ({ cli, startServer, http, image, ws, secrets }) => {
test.setTimeout(120_000);
/** Host binary (`cli` fixture) + host server on a dedicated DB, with an HTTP client style. */
const hostDriver = async (name: string, style: 'playwright' | 'curl' | 'raw'): Promise<Driver> => {
const dir = ws.mkdir(`mt-${style}`);
const dbPath = `${dir}/dpsapi.db`;
const env = { DB_PATH: dbPath };
const server = await startServer({ dbPath, logLevel: 'INFO' });
(server.fakeDocker as FakeDocker).setContainers(FIXED_CONTAINERS);
const get = async (auth: string[]): Promise<ResponseView> => {
if (style === 'playwright') {
if (auth.length > 1) throw new Error('the Playwright client sends at most one Authorization header');
return view(await http.get(server.url(), { headers: auth.length ? { Authorization: auth[0] } : {} }));
}
if (style === 'curl') {
const r = await http.curl({ url: server.url(), headers: auth.map((v) => `Authorization: ${v}`) });
expect(r.exitCode).toBe(0);
return view(r);
}
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers', headers: auth.map((v) => ['Authorization', v] as [string, string]) }) });
expect(r.outcome).toBe('response');
return view(r.response!);
};
return {
name,
tokenNew: (desc) => cli.run(desc === undefined ? ['token', 'new'] : ['token', 'new', desc], { env }),
tokenList: (all) => cli.run(all ? ['token', 'list', 'all'] : ['token', 'list'], { env }),
audit: (args = []) => cli.run(['audit', ...args], { env }),
tokenDelete: (t) => cli.run(['token', 'delete', t], { env }),
get,
finish: async () => {
const exit = await server.stop();
return { exitCode: exit.exitCode ?? -1, stderr: exit.stderr, dbPath };
},
origins: ['127.0.0.1'],
};
};
/** Final image: docker exec for the CLI, a client from outside the container for HTTP. */
const imageDriver = async (name: string, style: 'curl' | 'playwright'): Promise<Driver> => {
const c = await image.start({ logLevel: 'INFO', suffix: `mt-${style}` });
c.fakeDocker!.setContainers(FIXED_CONTAINERS);
const origins = await expectedOrigins(c);
return {
name,
tokenNew: (desc) => c.exec(desc === undefined ? ['token', 'new'] : ['token', 'new', desc]),
tokenList: (all) => c.exec(all ? ['token', 'list', 'all'] : ['token', 'list']),
audit: (args = []) => c.exec(['audit', ...args]),
tokenDelete: (t) => c.exec(['token', 'delete', t]),
get: async (auth) => {
if (style === 'playwright') return view(await http.get(c.url(), { headers: auth.length ? { Authorization: auth[0] } : {} }));
const r = await http.curl({ url: c.url(), headers: auth.map((v) => `Authorization: ${v}`) });
expect(r.exitCode).toBe(0);
return view(r);
},
finish: async () => {
const exit = await c.stop();
return { exitCode: exit.exitCode, stderr: exit.stderr, dbPath: await c.snapshotDb() };
},
origins,
};
};
const drivers = await Promise.all([
hostDriver('host binary CLI + Playwright request', 'playwright'),
hostDriver('host binary CLI + curl', 'curl'),
hostDriver('host binary CLI + raw socket', 'raw'),
imageDriver('image: docker exec + curl from outside', 'curl'),
imageDriver('image: docker exec + Playwright request from outside', 'playwright'),
]);
// the drivers are independent (own DB, server/container, FakeDocker): run them concurrently
const outcomes = await Promise.all(drivers.map(async (d) => [d.name, await runScenario(d, secrets)] as const));
const body200 = outcomes[0][1].http[0].body;
expect(JSON.parse(body200).map((c: ContainerSummary) => c.Id)).toEqual(FIXED_CONTAINERS.map((c) => c.Id));
for (const [name, o] of outcomes) {
await test.step(name, async () => {
expect(o.http[0].body, `${name}: identical 200 body bytes`).toBe(body200);
const comparable = { ...o, http: o.http.map((h) => (h.status === 200 ? { ...h, body: '<same for every driver>' } : h)) };
expect(comparable, `${name}: outcome equals the expected scenario outcome`).toEqual(EXPECTED);
});
}
// pairwise equivalence (implied by the above, stated directly)
for (const [name, o] of outcomes.slice(1)) expect(o, `${name} ≡ ${outcomes[0][0]}`).toEqual(outcomes[0][1]);
});
test('cross-over: a DB created by the host binary is served by the image, and an image-created DB by the host binary', async ({ cli, startServer, http, image, ws, secrets }) => {
test.setTimeout(120_000);
await test.step('host → image → host on the same directory', async () => {
const dir = ws.mkdir('shared');
const env = { DB_PATH: `${dir}/dpsapi.db` };
const hostTok = await cli.tokenNew('made on the host', { env });
const hostGone = await cli.tokenNew('deleted on the host', { env });
await cli.tokenDelete(hostGone, { env });
const hostServer = await startServer({ dbPath: env.DB_PATH });
expectContainersResponse(await http.get(hostServer.url(), { token: hostTok }));
expect((await hostServer.stop()).exitCode).toBe(0);
const hostList = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(hostList, { stderr: '' });
const hostAudit = await cli.run(['audit'], { env });
expectCliSuccess(hostAudit, { stderr: '' });
expect(parseAuditOutput(hostAudit.stdout)).toHaveLength(1);
// the image mounts the host-created directory as /data
const c = await image.start({ dataDir: dir, suffix: 'from-host' });
const imgList = await c.exec(['token', 'list', 'all']);
expectCliSuccess(imgList, { stdout: hostList.stdout, stderr: '' });
expectCliSuccess(await c.exec(['audit']), { stdout: hostAudit.stdout, stderr: '' });
expectContainersResponse(await http.get(c.url(), { token: hostTok }));
expectErrorResponse(await http.get(c.url(), { token: hostGone }), 401);
const imgTok = (await c.exec(['token', 'new', 'made in the image'])).stdout.trim();
expect(secrets.has(imgTok)).toBe(true);
expectCliSuccess(await c.exec(['token', 'delete', hostTok]), { stdout: '', stderr: '' });
const imgList2 = (await c.exec(['token', 'list', 'all'])).stdout;
const imgAudit2 = (await c.exec(['audit', '1000'])).stdout;
expectCliSuccess(await c.exec(['audit', 'all']), { stdout: imgAudit2, stderr: '' });
const origins = await expectedOrigins(c);
expect((await c.stop()).exitCode).toBe(0);
await c.remove();
// back on the host: same bytes listed, image-made token authenticates against the host server
const back = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(back, { stdout: imgList2, stderr: '' });
expectCliSuccess(await cli.run(['audit', '1000'], { env }), { stdout: imgAudit2, stderr: '' });
expectCliSuccess(await cli.run(['audit', 'all'], { env }), { stdout: imgAudit2, stderr: '' });
const hostServer2 = await startServer({ dbPath: env.DB_PATH });
expectContainersResponse(await http.get(hostServer2.url(), { token: imgTok }));
expectErrorResponse(await http.get(hostServer2.url(), { token: hostTok }), 401);
expect((await hostServer2.stop()).exitCode).toBe(0);
const ops = readOperations(env.DB_PATH);
expect(ops.map((o) => o.token)).toEqual([hostTok, hostTok, imgTok]);
expect(ops[0].origin).toBe('127.0.0.1');
expect(origins).toContain(ops[1].origin);
expect(ops[2].origin).toBe('127.0.0.1');
assertDbInvariants(env.DB_PATH, { tokenCount: 3, operationCount: 3, auditBeforeDeletion: true });
});
await test.step('image-created DB served by the host binary', async () => {
const c = await image.start({ suffix: 'to-host' });
const t1 = (await c.exec(['token', 'new', 'image one'])).stdout.trim();
const t2 = (await c.exec(['token', 'new', 'image two'])).stdout.trim();
expectContainersResponse(await http.get(c.url(), { token: t1 }));
expectCliSuccess(await c.exec(['token', 'delete', t2]), { stdout: '', stderr: '' });
const imgList = (await c.exec(['token', 'list', 'all'])).stdout;
const imgActive = (await c.exec(['token', 'list'])).stdout;
expect(parseTokenList(imgActive).map((r) => r.token), 'image: token list hides the deleted token').toEqual([t1]);
expect((await c.stop()).exitCode).toBe(0);
// the files are root-owned: serve a host-owned copy of the data directory (db + wal)
const copy = await c.snapshotDb();
const env = { DB_PATH: copy };
const hostList = await cli.run(['token', 'list', 'all'], { env });
expectCliSuccess(hostList, { stdout: imgList, stderr: '' });
expectCliSuccess(await cli.run(['token', 'list'], { env }), { stdout: imgActive, stderr: '' });
const server = await startServer({ dbPath: copy });
expectContainersResponse(await http.get(server.url(), { token: t1 }));
expectErrorResponse(await http.get(server.url(), { token: t2 }), 401);
expect((await server.stop()).exitCode).toBe(0);
const t3 = await cli.tokenNew('host on image DB', { env });
expect(readTokens(copy).map((t) => [t.token, t.description, t.active])).toEqual([
[t1, 'image one', 1],
[t2, 'image two', 0],
[t3, 'host on image DB', 1],
]);
expect(readOperations(copy).map((o) => o.token)).toEqual([t1, t1]);
assertDbInvariants(copy, { tokenCount: 3, operationCount: 2 });
});
});