import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync, statSync } from 'node:fs'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { buildRunArgs, devRunArgs, createDocker, spawnDocker } from '../src/docker.js'; const base = { image: 'node:20', name: 'preview-build-1', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', workRoot: '/srv/w', cacheRoot: '/srv/c', cmd: ['npm', 'ci'] }; const pair = (args, flag, val) => args.some((a, i) => a === flag && args[i + 1] === val); test('buildRunArgs: komplettes Sicherheitsprofil', () => { const a = buildRunArgs(base); assert.deepEqual(a.slice(0, 2), ['run', '--rm']); assert.ok(a.includes('--init')); for (const [f, v] of [['--memory', '1g'], ['--memory-swap', '1g'], ['--name', 'preview-build-1'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) { assert.ok(pair(a, f, v), `${f} ${v}`); } assert.equal(a.filter((x) => x === '-v').length, 2); assert.deepEqual(a.slice(-3), ['node:20', 'npm', 'ci']); }); test('buildRunArgs: Variablenwerte nie in args, ungültige Werte/Namen abgelehnt', () => { const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert' } }); assert.ok(!a.join(' ').includes('geheim-wert')); assert.throws(() => buildRunArgs({ ...base, env: { 'a b': '1' } })); assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\0y' } })); assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\ny' } })); assert.throws(() => buildRunArgs({ ...base, env: { HOME: 'x' } })); }); test('buildRunArgs: Name Pflicht, Memory/Image Allowlist, Network fest', () => { assert.throws(() => buildRunArgs({ ...base, name: undefined })); assert.throws(() => buildRunArgs({ ...base, name: '-x' })); assert.throws(() => buildRunArgs({ ...base, memory: '64g' })); assert.throws(() => buildRunArgs({ ...base, image: 'evil/img' })); assert.ok(buildRunArgs({ ...base, image: 'oven/bun:1', memory: '512m' })); const b2 = buildRunArgs({ ...base, memory: '2g' }); assert.ok(pair(b2, '--memory', '2g') && pair(b2, '--memory-swap', '2g')); assert.ok(pair(buildRunArgs({ ...base, network: 'host' }), '--network', 'preview-sandbox')); }); test('Pfade: absolut, unter Wurzel, keine Sonderzeichen', () => { for (const workDir of ['rel/x', '/etc', '/srv/w/../x', '/srv/w/a:b', '/srv/w/a,b', '/srv/w/a\nb', '/srv/wx/y', '/srv/w/a\0b', '/srv/w/x/../../etc']) { assert.throws(() => buildRunArgs({ ...base, workDir }), workDir); } assert.throws(() => buildRunArgs({ ...base, cacheDir: '/srv/w/x' })); assert.throws(() => buildRunArgs({ ...base, workRoot: undefined })); }); test('devRunArgs: detached, Name, Label, ohne --rm, 512m', () => { const a = devRunArgs({ ...base, sub: 'demo-1', memory: undefined, cmd: ['npm', 'run', 'dev'] }); assert.ok(!a.includes('--rm')); assert.ok(a.includes('-d')); assert.ok(pair(a, '--name', 'preview-dev-demo-1')); assert.ok(pair(a, '--label', 'webklar.preview=demo-1')); assert.ok(pair(a, '--restart', 'no')); assert.ok(pair(a, '--memory', '512m')); assert.ok(pair(a, '--cap-drop', 'ALL') && pair(a, '--user', '1000:1000')); assert.deepEqual(a.slice(-4), ['node:20', 'npm', 'run', 'dev']); }); test('devRunArgs: Subdomain wird validiert', () => { for (const sub of ['', 'A', 'a/b', '-x;', 'x'.repeat(64), undefined]) assert.throws(() => devRunArgs({ ...base, sub }), sub); }); // Fake-Kindprozess function fakeChild() { const c = new EventEmitter(); c.stdout = new PassThrough(); c.stderr = new PassThrough(); c.killed = []; c.kill = (s) => { c.killed.push(s); }; return c; } test('run: streamt Zeilen, CLI-Umgebung nur minimalBase, Werte nur in env-file (0600, danach gelöscht)', async () => { const calls = []; let content; let mode; let filePath; const child = fakeChild(); const exec = (args, opts) => { calls.push({ args, opts }); const i = args.indexOf('--env-file'); filePath = args[i + 1]; content = readFileSync(filePath, 'utf8'); mode = statSync(filePath).mode & 0o777; return child; }; const docker = createDocker({ exec, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } }); const lines = []; const p = docker.run(['run', '--rm', 'img'], { env: { K: 'v', DOCKER_HOST: 'x', PATH: 'y' }, onLine: (l) => lines.push(l) }); child.stdout.write('eins\nzw'); child.stderr.write('fehler\n'); child.stdout.write('ei\n'); child.stdout.end(); child.stderr.end(); await new Promise((r) => setImmediate(r)); child.emit('close', 3); assert.deepEqual(await p, { code: 3 }); assert.deepEqual(lines.sort(), ['eins', 'fehler', 'zwei']); assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x' }); assert.equal(content, 'K=v\nDOCKER_HOST=x\nPATH=y\n'); assert.equal(mode, 0o600); assert.ok(!calls[0].args.join(' ').includes('=v')); assert.equal(calls[0].args[0], 'run'); assert.equal(existsSync(filePath), false); }); test('run: NUL/Newline im Wert ⇒ Fehler vor dem Start', async () => { let n = 0; const docker = createDocker({ exec: () => { n++; return fakeChild(); } }); await assert.rejects(docker.run(['run'], { env: { A: 'x\0' } })); await assert.rejects(docker.run(['run'], { env: { A: 'x\ny' } })); assert.equal(n, 0); }); test('run: Zeitlimit ⇒ Kill, docker rm -f, code 124', async () => { const calls = []; const child = fakeChild(); const docker = createDocker({ rmCapMs: 30, exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } }); const p = docker.run(['run', '--name', 'job-9', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 20 }); const r = await p; assert.deepEqual(r, { code: 124 }); assert.deepEqual(child.killed, ['SIGKILL']); assert.deepEqual(calls[1], ['rm', '-f', 'job-9']); }); test('Zeitlimit: Name nur aus Optionsteil, ohne Namen kein rm', async () => { const calls = []; const docker = createDocker({ rmCapMs: 10, exec: (args) => { calls.push(args); return fakeChild(); } }); assert.deepEqual(await docker.run(['run', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 10 }), { code: 124 }); assert.equal(calls.length, 1); }); test('ps: nur stdout, nur gültige Namen', async () => { const exec = () => { const c = fakeChild(); setImmediate(() => { c.stderr.write('Warnung: foo\n'); c.stdout.write('preview-dev-a\n'); c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); return c; }; assert.deepEqual(await createDocker({ exec }).ps('webklar.preview'), ['preview-dev-a']); }); test('lineSplitter: überlange Zeilen werden gekappt', async () => { const child = fakeChild(); const docker = createDocker({ exec: () => child }); const lines = []; const p = docker.run(['ps'], { onLine: (l) => lines.push(l) }); child.stdout.write('a'.repeat(200000) + '\n'); child.stdout.end(); child.stderr.end(); await new Promise((r) => setImmediate(r)); child.emit('close', 0); await p; assert.ok(lines[0].length <= 65536); }); test('ps/stop/rm', async () => { const calls = []; const exec = (args) => { calls.push(args); const c = fakeChild(); setImmediate(() => { c.stdout.write('preview-dev-a\npreview-dev-b\n\n'); c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); return c; }; const docker = createDocker({ exec }); assert.deepEqual(await docker.ps('webklar.preview'), ['preview-dev-a', 'preview-dev-b']); assert.deepEqual(calls[0], ['ps', '-a', '--filter', 'label=webklar.preview', '--format', '{{.Names}}']); await docker.stop('preview-dev-a'); await docker.rm('preview-dev-a'); assert.deepEqual(calls[1], ['rm', '-f', 'preview-dev-a']); assert.deepEqual(calls[2], ['rm', '-f', 'preview-dev-a']); assert.equal(typeof spawnDocker, 'function'); }); test('rm/stop: ungültiger Name wird abgelehnt', async () => { const docker = createDocker({ exec: () => { throw new Error('nicht aufrufen'); } }); await assert.rejects(docker.rm('--all')); await assert.rejects(docker.stop('a b')); }); test('Zeitlimit: close direkt nach kill ändert Ergebnis nicht, rm -f wird vor Auflösung abgewartet', async () => { const events = []; const child = fakeChild(); child.kill = (s) => { child.killed.push(s); setImmediate(() => child.emit('close', null)); }; const rmChild = fakeChild(); let calls = 0; const docker = createDocker({ rmCapMs: 1000, exec: (args) => { if (calls++ === 0) return child; events.push('rm-start'); setTimeout(() => { events.push('rm-done'); rmChild.emit('close', 0); }, 40); return rmChild; } }); const r = await docker.run(['run', '--name', 'job-2', 'node:20', 'x'], { timeoutMs: 10 }); events.push('resolved'); assert.deepEqual(r, { code: 124 }); assert.deepEqual(events, ['rm-start', 'rm-done', 'resolved']); }); test('spawn-Fehler ⇒ env-file wird entfernt, run lehnt ab', async () => { let filePath; const child = fakeChild(); const docker = createDocker({ exec: (args) => { filePath = args[args.indexOf('--env-file') + 1]; return child; } }); const p = docker.run(['run', 'node:20'], { env: { K: 'v' } }); assert.equal(existsSync(filePath), true); child.emit('error', new Error('ENOENT')); await assert.rejects(p, /ENOENT/); assert.equal(existsSync(filePath), false); }); test('Zeitlimit mit env-file: Datei wird entfernt', async () => { let filePath; const child = fakeChild(); child.kill = () => setImmediate(() => child.emit('close', null)); const docker = createDocker({ rmCapMs: 10, exec: (args) => { if (args[0] === 'run') { filePath = args[args.indexOf('--env-file') + 1]; return child; } return fakeChild(); } }); assert.deepEqual(await docker.run(['run', '--name', 'j', 'node:20'], { env: { K: 'v' }, timeoutMs: 10 }), { code: 124 }); assert.equal(existsSync(filePath), false); }); test('ps: mit -a, damit beendete Container aufgeräumt werden', async () => { const calls = []; const exec = (args) => { calls.push(args); const c = fakeChild(); setImmediate(() => { c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); return c; }; await createDocker({ exec }).ps('webklar.preview'); assert.deepEqual(calls[0].slice(0, 2), ['ps', '-a']); }); test('running: inspect true/false/fehlend', async () => { const mk = (out, code) => () => { const c = fakeChild(); setImmediate(() => { if (out) c.stdout.write(out); c.stdout.end(); c.stderr.end(); c.emit('close', code); }); return c; }; assert.equal(await createDocker({ exec: mk('true\n', 0) }).running('preview-dev-a'), true); assert.equal(await createDocker({ exec: mk('false\n', 0) }).running('preview-dev-a'), false); assert.equal(await createDocker({ exec: mk('', 1) }).running('preview-dev-a'), false); });