import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** * Behavioral tests for scripts/docker-entrypoint.sh privilege handling. * * The entrypoint must support two deployment shapes with one image: * - Docker Compose: container starts as root, remaps the node user to * USER_UID/USER_GID and drops privileges via gosu. * - Kubernetes restricted PodSecurity / OpenShift arbitrary UIDs: the * container starts non-root, where neither the remap nor gosu can work, * so the command must be exec'd directly (with a warning on mismatch). * * The system commands (id, usermod, groupmod, chown, gosu) are stubbed via * PATH so the branching logic runs unmodified on any host. */ const ENTRYPOINT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "docker-entrypoint.sh"); let stubDir: string; let logFile: string; function writeStub(name: string, body: string) { const path = join(stubDir, name); writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); } function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid?: number }) { writeStub( "id", [ `if [ "$1" = "-u" ] && [ "$2" = "node" ]; then echo ${ids.nodeUid ?? 1000};`, `elif [ "$1" = "-g" ] && [ "$2" = "node" ]; then echo ${ids.nodeGid ?? 1000};`, `elif [ "$1" = "-u" ]; then echo ${ids.uid};`, `elif [ "$1" = "-g" ]; then echo ${ids.gid};`, `else echo 0; fi`, ].join("\n"), ); for (const cmd of ["usermod", "groupmod", "chown"]) { writeStub(cmd, `echo "${cmd} $*" >> "${logFile}"`); } writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`); } async function runEntrypoint(env: Record = {}) { const result = await execFileAsync("sh", [ENTRYPOINT, "echo", "ENTRYPOINT-CMD-RAN"], { env: { PATH: `${stubDir}:${process.env.PATH}`, ...env }, }); const calls = existsSync(logFile) ? readFileSync(logFile, "utf8") : ""; return { stdout: result.stdout, stderr: result.stderr, calls }; } beforeEach(() => { stubDir = mkdtempSync(join(tmpdir(), "entrypoint-stubs-")); logFile = join(stubDir, "calls.log"); }); afterEach(() => { rmSync(stubDir, { recursive: true, force: true }); }); describe("docker-entrypoint.sh", () => { it("keeps the root-start gosu flow with default UID/GID (Docker Compose)", async () => { installStubs({ uid: 0, gid: 0 }); const { stdout, calls } = await runEntrypoint(); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); expect(calls).not.toContain("usermod"); expect(calls).not.toContain("chown"); }); it("remaps the node user and chowns /paperclip before gosu when root requests a different UID/GID", async () => { installStubs({ uid: 0, gid: 0 }); const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001" }); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(calls).toContain("usermod -o -u 1001 node"); expect(calls).toContain("groupmod -o -g 1001 node"); expect(calls).toContain("chown -R node:node /paperclip"); expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); }); it("execs directly and silently when already running as the requested user (restricted PodSecurity)", async () => { installStubs({ uid: 1000, gid: 1000 }); const { stdout, stderr, calls } = await runEntrypoint(); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(stderr).toBe(""); expect(calls).toBe(""); }); it("execs directly with a warning for an arbitrary non-root UID (OpenShift-style)", async () => { installStubs({ uid: 1234, gid: 1234 }); const { stdout, stderr, calls } = await runEntrypoint(); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(stderr).toContain("running unprivileged as 1234:1234; cannot remap to requested 1000:1000"); expect(calls).toBe(""); }); it("execs directly with a warning on a non-root GID mismatch", async () => { installStubs({ uid: 1000, gid: 1001 }); const { stdout, stderr, calls } = await runEntrypoint(); expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); expect(stderr).toContain("running unprivileged as 1000:1001; cannot remap to requested 1000:1000"); expect(calls).toBe(""); }); });