/** * App non-root execution test — locks in the [E00-S02-T05] non-root runtime * user for the workspace server application image. * * Acceptance criteria covered (each test fails without the committed state): * - "app runs as a non-root user" → the committed * `apps/server/Dockerfile` runtime stage declares a `USER` instruction * naming a non-root user (the official Node image's built-in `node` user, * uid/gid 1000). A static assertion requires the runtime stage to drop * root privileges, and the mutation probes below prove the assertion is * non-vacuous (removing the USER, or switching it back to root, breaks * the criterion). * - "the container does not run with root privileges" → the runtime USER * must not be root / uid 0 (static), and when a Docker daemon + Compose * plugin are available (CI/dev machines), the real-stack probe starts the * stack and inspects the running app container: `id -u` inside the * container reports a non-zero uid and `id -un` does not report `root`, * while the health endpoint still answers (the unprivileged app keeps * serving). * * Run: `node --test tests/non-root-user.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 app image definition under test. */ const DOCKERFILE_PATH = 'apps/server/Dockerfile'; // --------------------------------------------------------------------------- // Dockerfile structure helpers // --------------------------------------------------------------------------- /** * Extracts the runtime stage of the committed Dockerfile (from its * `FROM node:24.19.0-bookworm-slim AS runtime` line to the end of file — the * runtime stage is last). The USER must live in the runtime stage: the build * stage may run as root, only the container the app runs in must drop * privileges. */ function runtimeStageOf(dockerfile) { const from = dockerfile.indexOf('FROM node:24.19.0-bookworm-slim AS runtime'); assert.notEqual( from, -1, 'the Dockerfile must declare the runtime stage (FROM node:24.19.0-bookworm-slim AS runtime)', ); const tail = dockerfile.slice(from); const nextFrom = tail.indexOf('\nFROM ', 1); return nextFrom === -1 ? tail : tail.slice(0, nextFrom); } /** * Asserts the committed Dockerfile's runtime stage declares a `USER` * instruction naming a non-root user — the app runs as a non-root user and * the container does not run with root privileges. Fails fast on a missing or * root USER; the mutation probes below prove the assertions are non-vacuous. */ function assertNonRootUser(dockerfile) { const runtime = runtimeStageOf(dockerfile); assert.match( runtime, /^USER\s+\S+/m, 'the runtime stage must declare a USER instruction naming a non-root user ' + '(e.g. "USER node") so the app runs as a non-root user', ); assert.doesNotMatch( runtime, /^USER\s+(root|0)(\s|$)/m, 'the runtime USER must not be root or uid 0 — the container must not run with root privileges', ); } // --------------------------------------------------------------------------- // 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(); /** * Polls `docker compose exec app id -u` until the app container answers (the * app starts only after the db health gate, so `docker compose up -d` may * return before the container is exec-able). Returns the reported uid. */ function waitForAppUid(deadlineMs = 60_000) { const deadline = Date.now() + deadlineMs; let last = ''; while (Date.now() < deadline) { const probe = run('docker', ['compose', 'exec', '-T', 'app', 'id', '-u'], { cwd: REPO_ROOT, timeout: 15_000, }); last = `${probe.status}: ${probe.stdout?.trim()} ${probe.stderr?.trim()}`; if (probe.status === 0) return probe.stdout.trim(); run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry } throw new Error(`the app container did not answer "id -u" within ${deadlineMs}ms (last: "${last.trim()}")`); } // --------------------------------------------------------------------------- // Criterion tests // --------------------------------------------------------------------------- test('the app image runs as a non-root user (runtime stage declares a non-root USER)', () => { assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); assertNonRootUser(read(DOCKERFILE_PATH)); }); test('the runtime USER is the image\'s built-in non-root node user (uid/gid 1000)', () => { const runtime = runtimeStageOf(read(DOCKERFILE_PATH)); assert.match( runtime, /^USER\s+node\s*$/m, 'the runtime stage must run as the official Node image\'s non-root "node" user (USER node)', ); }); test('the running app container does not run with root privileges (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 { const uid = waitForAppUid(); assert.notEqual( uid, '0', `the app container must run as a non-root user ("id -u" inside the container must not be 0; got "${uid}")`, ); const name = run('docker', ['compose', 'exec', '-T', 'app', 'id', '-un'], { cwd: REPO_ROOT, timeout: 15_000, }); assert.equal( name.status, 0, `"id -un" inside the app container must succeed:\n${(name.stdout || '')}\n${(name.stderr || '')}`.trim(), ); assert.notEqual( name.stdout.trim(), 'root', `the app container must not run as root ("id -un" must not report "root"; got "${name.stdout.trim()}")`, ); // Regression guard: the unprivileged app still serves the health endpoint // (the T05 privilege drop must not break startup). Poll with timeout — no // flaky sleeps. 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 while running unprivileged (last probe: "${healthOutput.trim()}")`, ); assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); } finally { run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); } }); // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- test('removing the USER instruction makes the non-root criterion fail (mutation probe)', () => { const dockerfile = read(DOCKERFILE_PATH); const withoutUser = dockerfile.replace(/^USER node\s*$/m, ''); assert.notEqual(withoutUser, dockerfile, 'the mutation must actually remove the USER instruction'); assert.throws(() => assertNonRootUser(withoutUser), /USER instruction/); }); test('switching the runtime USER back to root makes the non-root criterion fail (mutation probe)', () => { const dockerfile = read(DOCKERFILE_PATH); const asRoot = dockerfile.replace(/^USER node\s*$/m, 'USER root'); assert.notEqual(asRoot, dockerfile, 'the mutation must actually switch the USER back to root'); assert.throws(() => assertNonRootUser(asRoot), /root/); }); test('a runtime stage without any USER fails the non-root criterion (mutation probe)', () => { const runtime = runtimeStageOf(read(DOCKERFILE_PATH)).replace(/^USER node\s*$/m, ''); assert.throws(() => assertNonRootUser(runtime), /USER instruction/); });