Files
PersonalBlog/tests/non-root-user.test.mjs
implementer 6e8ba388a6
CI / Frozen lockfile install (pull_request) Successful in 51s
test: lock in non-root execution criteria (E00-S02-T05)
tests/non-root-user.test.mjs locks in both acceptance criteria: a static
assertion that the Dockerfile runtime stage declares a non-root USER (not
root/uid 0, 'USER node' exactly), non-vacuous mutation probes, and a
Docker-gated real-stack probe that starts the stack and asserts 'id -u' and
'id -un' inside the running app container report a non-root user, with the
health endpoint still answering as a regression guard.
2026-08-29 00:41:16 +00:00

238 lines
9.5 KiB
JavaScript

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