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.
195 lines
9.1 KiB
TypeScript
195 lines
9.1 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* PLAN §12 matrix "Docker socket available, inaccessible, temporarily failing, and returning
|
|
* empty/non-empty container sets" inside the image: socket not mounted, FakeDocker socket mounted
|
|
* (empty, 1000 containers, Engine error, flaky then recovery, connection dropped), permission
|
|
* denied for a non-root container user, and the real Engine reflecting containers the suite starts
|
|
* and stops. The FakeDocker socket is bind-mounted, so its inode is pinned: modes are switched
|
|
* live, the socket is never recreated.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import {
|
|
test,
|
|
expect,
|
|
assertDbInvariants,
|
|
expectContainersResponse,
|
|
expectErrorResponse,
|
|
expectNoInternalDetails,
|
|
parseLogs,
|
|
readOperations,
|
|
type ContainerSummary,
|
|
type HttpClient,
|
|
type ImageContainer,
|
|
type RealDocker,
|
|
type TestContainer,
|
|
} from '../../harness';
|
|
import { chownDir, expectedOrigins, ownerOf } from './image-helpers';
|
|
|
|
test('socket not mounted at all: server starts, authenticated requests → 500 generic body, still audited', async ({ image, http }) => {
|
|
const c = await image.start({ docker: 'none', logLevel: 'DEBUG', suffix: 'nosock' });
|
|
const token = (await c.exec(['token', 'new', 'no socket'])).stdout.trim();
|
|
const pw = await http.get(c.url(), { token });
|
|
expectErrorResponse(pw, 500);
|
|
expectNoInternalDetails(pw.body);
|
|
const cu = await http.curl({ url: c.url(), token });
|
|
expect(cu.exitCode).toBe(0);
|
|
expectErrorResponse(cu, 500);
|
|
expectErrorResponse(await http.get(c.url()), 401);
|
|
const exit = await c.stop();
|
|
expect(exit.exitCode).toBe(0);
|
|
const logs = parseLogs(exit.stderr);
|
|
const errors = logs.filter({ level: 'ERROR', msg: 'docker request failed' });
|
|
expect(errors).toHaveLength(2);
|
|
expect(errors[0].attrs.error, 'diagnostic stays in the log').toMatch(/no such file or directory/);
|
|
expect(logs.filter({ msg: 'audit row inserted' })).toHaveLength(2);
|
|
const snap = await c.snapshotDb();
|
|
expect(readOperations(snap).map((o) => o.token), 'audited before the Docker call failed').toEqual([token, token]);
|
|
assertDbInvariants(snap, { tokenCount: 1, operationCount: 2 });
|
|
});
|
|
|
|
test('FakeDocker socket: empty → [], 1000 containers, Engine errors → 500, flaky then recovery', async ({ image, http }) => {
|
|
const c = await image.start({ suffix: 'fake' });
|
|
const f = c.fakeDocker!;
|
|
const token = (await c.exec(['token', 'new'])).stdout.trim();
|
|
let authenticated = 0;
|
|
const get = async () => {
|
|
authenticated++;
|
|
return http.get(c.url(), { token });
|
|
};
|
|
|
|
await test.step('empty', async () => {
|
|
f.empty();
|
|
const r = await get();
|
|
expect(expectContainersResponse(r)).toEqual([]);
|
|
expect(r.body).toBe('[]');
|
|
const cu = await http.curl({ url: c.url(), token });
|
|
authenticated++;
|
|
expect(cu.body).toBe('[]');
|
|
});
|
|
|
|
await test.step('many: 1000 summaries, complete and in order', async () => {
|
|
f.many(1000);
|
|
const ids = f.containers.map((x) => x.Id);
|
|
const got = expectContainersResponse<ContainerSummary>(await get());
|
|
expect(got.map((x) => x.Id)).toEqual(ids);
|
|
expect(got[999].Names).toEqual(['/fake-999']);
|
|
});
|
|
|
|
await test.step('Engine 500 / dropped connection / malformed JSON → 500 without details', async () => {
|
|
for (const apply of [() => f.error500('boom: /var/lib/docker secret path'), () => f.destroy(), () => f.badJson('{"x"')]) {
|
|
apply();
|
|
const r = await get();
|
|
expectErrorResponse(r, 500);
|
|
expectNoInternalDetails(r.body);
|
|
}
|
|
});
|
|
|
|
await test.step('flaky (2 failures) then recovery', async () => {
|
|
f.many(3).flaky(2);
|
|
const statuses: number[] = [];
|
|
for (let i = 0; i < 4; i++) statuses.push((await get()).status);
|
|
expect(statuses).toEqual([500, 500, 200, 200]);
|
|
});
|
|
|
|
// One Engine call per authenticated request, except the dropped-connection case: Go's HTTP
|
|
// transport transparently retries an idempotent GET once when a reused keep-alive connection is
|
|
// closed before any response byte (as observed; transport behaviour, not app logic).
|
|
expect(f.listCalls(), 'Engine list calls').toBeGreaterThanOrEqual(authenticated);
|
|
expect(f.listCalls(), 'Engine list calls (at most one transport retry)').toBeLessThanOrEqual(authenticated + 1);
|
|
expect(f.stats.other, 'no other Engine endpoints').toBe(0);
|
|
const exit = await c.stop();
|
|
expect(exit.exitCode).toBe(0);
|
|
const logs = parseLogs(exit.stderr);
|
|
expect(logs.filter({ level: 'ERROR' }).map((r) => r.msg)).toEqual(Array(5).fill('docker request failed'));
|
|
expect(logs.find({ level: 'ERROR' })!.attrs.error).toContain('boom');
|
|
assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: authenticated });
|
|
});
|
|
|
|
test('permission denied: --user 65534:65534 cannot open the mounted socket → 500, audited; data dir owned by that uid', async ({ image, http, ws, testKey, newFakeDocker }) => {
|
|
// FakeDocker socket owned by the host user, mode 0600: uid 65534 has no access.
|
|
const fake = await newFakeDocker();
|
|
fs.chmodSync(fake.socketPath, 0o600);
|
|
fake.many(2);
|
|
const variants: Array<{ name: string; docker: 'real' | typeof fake }> = [
|
|
{ name: 'FakeDocker socket (0600, other owner)', docker: fake },
|
|
// the real socket is root:docker 0660 — uid 65534 with no supplementary groups cannot connect
|
|
{ name: 'real Engine socket', docker: 'real' },
|
|
];
|
|
const containers: ImageContainer[] = [];
|
|
for (const [i, v] of variants.entries()) {
|
|
// /data must be writable by uid 65534: hand a fresh dir to it (0700) through a helper container
|
|
const dataDir = ws.mkdir(`data-nobody-${i}`);
|
|
await chownDir(testKey, dataDir, '65534:65534', '700');
|
|
containers.push(await image.start({ user: '65534:65534', docker: v.docker, dataDir, logLevel: 'DEBUG', suffix: `nobody${i}` }));
|
|
}
|
|
for (const [i, c] of containers.entries()) {
|
|
await test.step(variants[i].name, async () => {
|
|
const created = await c.exec(['token', 'new', 'as nobody']);
|
|
expect(created.exitCode, created.stderr).toBe(0);
|
|
const token = created.stdout.trim();
|
|
expect(await ownerOf(testKey, c.dataDir, 'dpsapi.db'), 'DB created by the container user').toBe('65534:65534');
|
|
const r = await http.get(c.url(), { token });
|
|
expectErrorResponse(r, 500);
|
|
expectNoInternalDetails(r.body);
|
|
expectErrorResponse(await http.get(c.url()), 401);
|
|
const exit = await c.stop();
|
|
expect(exit.exitCode).toBe(0);
|
|
const err = parseLogs(exit.stderr).filter({ level: 'ERROR', msg: 'docker request failed' });
|
|
expect(err).toHaveLength(1);
|
|
expect(err[0].attrs.error).toMatch(/permission denied/);
|
|
expect(parseLogs(exit.stderr).find({ msg: 'server listening' })!.attrs.addr, 'non-root still binds :80 (Docker sets ip_unprivileged_port_start=0)').toBe('0.0.0.0:80');
|
|
assertDbInvariants(await c.snapshotDb(), { tokenCount: 1, operationCount: 1 });
|
|
});
|
|
}
|
|
expect(fake.listCalls(), 'nothing reached the FakeDocker').toBe(0);
|
|
expect(fake.stats.total).toBe(0);
|
|
});
|
|
|
|
interface ApiContainer {
|
|
Id: string;
|
|
Names: string[];
|
|
Labels: Record<string, string>;
|
|
State: string;
|
|
}
|
|
|
|
async function ownViaApi(http: HttpClient, c: ImageContainer, token: string, docker: RealDocker): Promise<string[]> {
|
|
const pw = expectContainersResponse<ApiContainer>(await http.get(c.url(), { token }));
|
|
const cu = await http.curl({ url: c.url(), token });
|
|
expect(cu.exitCode).toBe(0);
|
|
const own = (l: ApiContainer[]) => l.filter((x) => x.Labels?.['dpsapi.e2e.test'] === docker.testKey).map((x) => x.Id).sort();
|
|
expect(own(expectContainersResponse<ApiContainer>(cu)), 'curl and Playwright agree').toEqual(own(pw));
|
|
expect(pw.filter((x) => x.State !== 'running'), 'running containers only').toEqual([]);
|
|
return own(pw);
|
|
}
|
|
|
|
test('real Engine socket: containers started, stopped, restarted and removed by the suite are reflected', async ({ image, docker, http }) => {
|
|
test.setTimeout(120_000);
|
|
const c = await image.start({ docker: 'real', suffix: 'realsock' });
|
|
const token = (await c.exec(['token', 'new'])).stdout.trim();
|
|
const expectState = async (expected: TestContainer[], label: string) => {
|
|
const api = await ownViaApi(http, c, token, docker);
|
|
expect(api, `${label}: API = docker ps for our label`).toEqual((await docker.runningIds()).sort());
|
|
expect(api, `${label}: expected set (+ the dpsapi container)`).toEqual([...expected.map((x) => x.id), c.id].sort());
|
|
};
|
|
await expectState([], 'only dpsapi');
|
|
const [x, y, z] = await docker.runMany(3, (i) => ({ suffix: `r${i}`, labels: { 'com.example.i': String(i) } }));
|
|
await expectState([x, y, z], 'three started');
|
|
await docker.stop(y, 1);
|
|
await expectState([x, z], 'one stopped');
|
|
await docker.start(y);
|
|
await expectState([x, y, z], 'restarted');
|
|
await Promise.all([docker.remove(x), docker.remove(z)]);
|
|
await expectState([y], 'two removed');
|
|
await docker.remove(y);
|
|
await expectState([], 'all removed');
|
|
const origins = await expectedOrigins(c);
|
|
expect((await c.stop()).exitCode).toBe(0);
|
|
const snap = await c.snapshotDb();
|
|
const ops = readOperations(snap);
|
|
expect(ops).toHaveLength(12);
|
|
for (const o of ops) expect(origins).toContain(o.origin);
|
|
assertDbInvariants(snap, { tokenCount: 1, operationCount: 12 });
|
|
});
|