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:
1 parent
0a6023e942
commit
581a672fcf
9 files changed
+1858
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user