Files
dpsapi/e2e/tests/image/image-docker-socket.spec.ts
bryanpedini 581a672fcf 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.
2026-10-06 10:52:25 +02:00

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 });
});