/** * App read-only root filesystem test — locks in the [E00-S02-T06] read-only * app container root filesystem for the workspace. * * Acceptance criteria covered (each test fails without the committed state): * - "app root filesystem is read-only" → the committed `compose.yaml` `app` * service declares `read_only: true`, so the container root filesystem is * mounted read-only and any write to it is denied. A static assertion * requires the flag; mutation probes removing it (or flipping it to * false) fail; the Docker-gated real-stack probe additionally proves it * on a live container — `docker inspect` reports `ReadonlyRootfs: true` * and `touch /…` on the root filesystem is denied. * - "writable paths are limited to declared mounts and tmpfs" → the `app` * service declares its writable paths explicitly: a `tmpfs` at `/tmp` and * no writable volume/bind mounts (any mount on the app service must be * read-only, `:ro`), so with the root filesystem read-only the declared * `/tmp` tmpfs is the only writable path. Static assertions require the * `tmpfs` entry and reject writable mounts (mutation probes cover both * directions); the real-stack probe writes to `/tmp` (succeeds — the * declared tmpfs is writable) and re-checks the health endpoint answers * HTTP 200 as a regression guard (read-only + tmpfs must not break * startup). * * Run: `node --test tests/readonly-rootfs.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) */ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); /** The committed Compose file under test. */ const COMPOSE_PATH = 'compose.yaml'; // --------------------------------------------------------------------------- // Compose structure helpers (block-scoped to the app service) // --------------------------------------------------------------------------- /** * Extracts the committed `app` service block from compose.yaml — every line * from the `app:` key (indent 2) to the next line with a smaller indent. All * T06 assertions are scoped to this block so a `read_only`/`tmpfs` elsewhere * (e.g. on the `db` service) can never satisfy them. */ function appServiceBlock(composeText) { const lines = composeText.split(/\r?\n/); const start = lines.findIndex((line) => /^ app:\s*$/.test(line)); assert.notEqual(start, -1, 'compose.yaml must declare an "app" service (indent-2 "app:" key)'); const block = []; for (let i = start + 1; i < lines.length && /^ /.test(lines[i]); i += 1) { block.push(lines[i]); } return block.join('\n'); } /** * Asserts the app service declares `read_only: true`, so the container root * filesystem is mounted read-only ("app root filesystem is read-only"). */ function assertReadOnlyRootfs(composeText) { const block = appServiceBlock(composeText); assert.match( block, /^ read_only:\s*true\s*$/m, 'the "app" service must declare "read_only: true" so the container root filesystem is read-only', ); } /** * Asserts the app service declares its writable paths explicitly: a `tmpfs` * mount at `/tmp` ("writable paths are limited to declared mounts and * tmpfs"). With the root filesystem read-only, the declared tmpfs is what the * app is allowed to write to. */ function assertWritableTmpfs(composeText) { const block = appServiceBlock(composeText); assert.match( block, /^ tmpfs:\s*$/m, 'the "app" service must declare a tmpfs (writable paths are limited to declared mounts and tmpfs)', ); assert.match( block, /^ - \/tmp\s*$/m, 'the "app" service must declare a tmpfs at "/tmp" (the app\'s writable temp path)', ); } /** * Asserts the app service declares no *writable* volume/bind mounts: any * mount on the app service must be read-only (`:ro`), so the declared tmpfs * is the only writable path. The committed state has no mounts at all. */ function assertNoWritableMounts(composeText) { const block = appServiceBlock(composeText); const lines = block.split('\n'); let inVolumes = false; for (const line of lines) { if (/^ volumes:\s*$/.test(line)) { inVolumes = true; } else if (inVolumes) { if (/^ /.test(line)) { assert.match( line, /:ro\s*$/, `a writable mount ("${line.trim()}") on the app service would extend writable paths beyond the declared tmpfs — app mounts must be read-only (:ro)`, ); } else { inVolumes = false; } } } } // --------------------------------------------------------------------------- // Docker probe helpers (integration tests skip cleanly without Docker) // --------------------------------------------------------------------------- function run(cmd, args, opts = {}) { return spawnSync(cmd, args, { encoding: 'utf8', timeout: 600_000, ...opts, }); } /** True when the `docker` CLI with the Compose plugin is on PATH. */ function dockerComposeAvailable() { try { return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0; } catch { return false; } } /** True when a reachable Docker daemon exists. */ function dockerDaemonAvailable() { try { return run('docker', ['info'], { timeout: 15_000 }).status === 0; } catch { return false; } } const DOCKER_COMPOSE = dockerComposeAvailable(); const DOCKER_DAEMON = dockerDaemonAvailable(); // --------------------------------------------------------------------------- // Tests — the committed state that makes the app root filesystem read-only // --------------------------------------------------------------------------- test('compose.yaml exists and the app service declares a read-only root filesystem (read_only: true)', () => { assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`); assertReadOnlyRootfs(read(COMPOSE_PATH)); }); test('the app service declares a writable tmpfs at /tmp (writable paths are declared)', () => { assertWritableTmpfs(read(COMPOSE_PATH)); }); test('the app service declares no writable volume/bind mounts (writable paths are limited to the declared tmpfs)', () => { assertNoWritableMounts(read(COMPOSE_PATH)); }); // --------------------------------------------------------------------------- // Real-stack probe — run the acceptance sequence when Docker is present // --------------------------------------------------------------------------- test('the running app container has a read-only root filesystem and a writable declared tmpfs (real-stack probe)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT }); assert.equal( up.status, 0, `"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), ); try { // The app starts only after the db health gate, so exec may fail while // the stack is still booting — poll until the app accepts exec. let ready = false; for (let attempt = 0; attempt < 60 && !ready; attempt += 1) { const probe = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', 'true'], { cwd: REPO_ROOT, timeout: 15_000, }); if (probe.status === 0) ready = true; else run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // still booting — retry } assert.ok(ready, 'the app container must accept "docker compose exec" once the stack is up'); // Acceptance 1 — "app root filesystem is read-only": Docker reports the // container was created with a read-only root filesystem, and a write to // the root filesystem is denied. const containerId = run('docker', ['compose', 'ps', '-q', 'app'], { cwd: REPO_ROOT, timeout: 15_000 }); assert.equal( containerId.status, 0, `"docker compose ps -q app" must exit 0:\n${(containerId.stderr || containerId.stdout || '').trim()}`, ); const id = containerId.stdout.trim().split('\n')[0]; assert.ok(id, '"docker compose ps -q app" must return the app container id'); const inspect = run('docker', ['inspect', '--format', '{{.HostConfig.ReadonlyRootfs}}', id], { timeout: 15_000, }); assert.equal( inspect.status, 0, `"docker inspect" must succeed:\n${(inspect.stdout || '')}\n${(inspect.stderr || '')}`.trim(), ); assert.equal( inspect.stdout.trim(), 'true', `the app container must be created with a read-only root filesystem (HostConfig.ReadonlyRootfs must be true; got "${inspect.stdout.trim()}")`, ); const writeRoot = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', 'if touch /t06-rootfs-probe 2>/dev/null; then echo WROTE; else echo DENIED; fi', ], { cwd: REPO_ROOT, timeout: 15_000 }); assert.equal( writeRoot.status, 0, `the root-filesystem write probe must run:\n${(writeRoot.stdout || '')}\n${(writeRoot.stderr || '')}`.trim(), ); assert.match( writeRoot.stdout, /DENIED/, `a write to the app root filesystem must be denied (read-only root filesystem; got "${writeRoot.stdout.trim()}")`, ); // Acceptance 2 — "writable paths are limited to declared mounts and // tmpfs": the declared /tmp tmpfs is writable. const writeTmp = run('docker', ['compose', 'exec', '-T', 'app', 'sh', '-c', 'touch /tmp/t06-tmpfs-probe && echo WROTE && rm /tmp/t06-tmpfs-probe', ], { cwd: REPO_ROOT, timeout: 15_000 }); assert.equal( writeTmp.status, 0, `the tmpfs write probe must succeed:\n${(writeTmp.stdout || '')}\n${(writeTmp.stderr || '')}`.trim(), ); assert.match( writeTmp.stdout, /WROTE/, `the declared /tmp tmpfs must be writable (writable paths are limited to declared mounts and tmpfs; got "${writeTmp.stdout.trim()}")`, ); // Regression guard: the read-only rootfs + tmpfs must not break startup — // the health endpoint still answers HTTP 200 with a healthy body. let healthOutput = ''; let healthOk = false; for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) { const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', ` fetch('http://127.0.0.1:3000/health') .then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); }) .catch(() => process.exit(2)); `], { cwd: REPO_ROOT, timeout: 15_000 }); const output = String(probe.stdout ?? '') + String(probe.stderr ?? ''); if (probe.status === 0) { healthOk = true; healthOutput = output; } else if (probe.status === 1) { healthOutput = output; // answered but not 2xx — fail fast break; } else { run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry } } assert.ok( healthOk, `GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`, ); assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); assert.match( healthOutput, /"status":"ok"/, `GET /health must report a healthy application (got: "${healthOutput.trim()}")`, ); } finally { run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); } }); // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- test('removing read_only makes the read-only criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace(/^ read_only: true\n/m, ''); assert.notEqual(mutated, text, 'the mutation must actually remove the read_only line'); assert.throws(() => assertReadOnlyRootfs(mutated), /read_only/); }); test('switching read_only to false makes the read-only criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace(/^ read_only: true\n/m, ' read_only: false\n'); assert.notEqual(mutated, text, 'the mutation must actually flip read_only to false'); assert.throws(() => assertReadOnlyRootfs(mutated), /read_only/); }); test('removing the /tmp tmpfs makes the writable-path criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace(/^ - \/tmp\n/m, ''); assert.notEqual(mutated, text, 'the mutation must actually remove the /tmp tmpfs entry'); assert.throws(() => assertWritableTmpfs(mutated), /tmpfs/); }); test('removing the whole tmpfs block makes the writable-path criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace(/^ tmpfs:\n - \/tmp\n/m, ''); assert.notEqual(mutated, text, 'the mutation must actually remove the tmpfs block'); assert.throws(() => assertWritableTmpfs(mutated), /tmpfs/); }); test('adding a writable volume mount makes the writable-path criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace( /^ tmpfs:\n/m, ' volumes:\n - app-data:/data\n tmpfs:\n', ); assert.notEqual(mutated, text, 'the mutation must actually add a writable mount to the app service'); assert.throws(() => assertNoWritableMounts(mutated), /writable mount/); }); test('a read_only flag on the db service cannot satisfy the app criterion (mutation probe)', () => { const text = read(COMPOSE_PATH); const mutated = text.replace(/^ read_only: true\n/m, ''); const withDbOnly = mutated.replace( /^ # T04: persist the database in the named `db-data` volume/m, ' read_only: true\n # T04: persist the database in the named `db-data` volume', ); assert.notEqual(withDbOnly, text, 'the mutation must move read_only onto the db service only'); assert.throws(() => assertReadOnlyRootfs(withDbOnly), /read_only/); });