import test from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createApiServer, readMemAvailable } from '../src/server.js'; import { loadConfig } from '../src/config.js'; import { validateJob } from '../src/validate.js'; const config = { ...loadConfig({ PREVIEW_RUNNER_TOKEN: 'geheim' }) }; const RID = '123e4567-e89b-42d3-a456-426614174000'; const submitted = []; const jobs = { submit: (j) => { submitted.push(j); return { runId: RID }; }, get: (id) => (id === RID ? { runId: RID, state: 'building' } : null), queueLength: () => 0, }; const stopped = []; const registered = []; const dev = { ensure: async (sub) => ({ sub, state: 'starting' }), register: async (sub, j) => { registered.push([sub, j]); return { state: 'sleeping', extra: 'x' }; }, stop: async (sub) => { stopped.push(sub); }, list: () => [], }; async function withServer(cfg, fn) { const srv = createApiServer({ config: cfg, jobs, dev, mem: () => 123 }); await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${srv.address().port}`; try { await fn(base); } finally { await new Promise((r) => srv.close(r)); } } const auth = { Authorization: 'Bearer geheim', 'Content-Type': 'application/json' }; const good = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' }; test('loadConfig: Vorgaben', () => { const c = loadConfig({}); assert.equal(c.baseHost, 'project.webklar.com'); assert.equal(c.workRoot, '/srv/webklar-preview-runner/work'); assert.equal(c.cacheRoot, '/srv/webklar-preview-runner/cache'); assert.equal(c.previewsRoot, '/var/www/previews'); assert.equal(c.apiPort, 8080); assert.equal(c.proxyPort, 8081); assert.equal(c.minFreeBytes, 1.5 * 1024 ** 3); assert.equal(c.buildTimeoutMs, 900000); assert.equal(c.devIdleMs, 1200000); assert.equal(c.devMax, 3); assert.equal(c.devReadyMs, 180000); assert.equal(c.token, ''); }); test('401 ohne/mit falschem Token und bei leerem konfiguriertem Token', async () => { await withServer(config, async (base) => { assert.equal((await fetch(`${base}/status`)).status, 401); assert.equal((await fetch(`${base}/status`, { headers: { Authorization: 'Bearer falsch' } })).status, 401); }); await withServer({ ...config, token: '' }, async (base) => { assert.equal((await fetch(`${base}/status`, { headers: { Authorization: 'Bearer ' } })).status, 401); }); }); test('GET /status', async () => { await withServer(config, async (base) => { const r = await fetch(`${base}/status`, { headers: auth }); assert.equal(r.status, 200); assert.deepEqual(await r.json(), { dev: [], known: [], queue: 0, memAvailable: 123 }); }); }); test('POST /jobs/build: 400 bei ungültiger Eingabe, 200 bei gültiger', async () => { await withServer(config, async (base) => { for (const bad of [{ sub: '../x' }, { repo: 'a/b;rm' }, { sha: 'xyz' }]) { const r = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, ...bad }) }); assert.equal(r.status, 400); } const ok = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify(good) }); assert.equal(ok.status, 200); assert.deepEqual(await ok.json(), { runId: RID }); assert.equal(submitted[0].sub, 'demo'); const nojson = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: '{kaputt' }); assert.equal(nojson.status, 400); }); }); test('GET /jobs/:runId, Dev-Start/Stop', async () => { await withServer(config, async (base) => { assert.equal((await fetch(`${base}/jobs/${RID}`, { headers: auth })).status, 200); assert.equal((await fetch(`${base}/jobs/223e4567-e89b-42d3-a456-426614174000`, { headers: auth })).status, 404); const s = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) }); assert.equal(s.status, 200); assert.equal((await fetch(`${base}/dev/..x/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) })).status, 400); const t = await fetch(`${base}/dev/demo/stop`, { method: 'POST', headers: auth }); assert.equal(t.status, 200); assert.deepEqual(stopped, ['demo']); }); }); test('Body über 256 KB ⇒ 413', async () => { await withServer(config, async (base) => { const r = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, x: 'a'.repeat(300 * 1024) }) }); assert.equal(r.status, 413); }); }); test('validateJob: env, index, mode', () => { assert.equal(validateJob({ ...good, mode: 'dev', index: 'src/a.html', env: { A_B: 'x' } }).ok, true); assert.equal(validateJob({ ...good, mode: 'off' }).ok, false); assert.equal(validateJob({ ...good, index: '../etc' }).ok, false); assert.equal(validateJob({ ...good, index: '/abs' }).ok, false); assert.equal(validateJob({ ...good, env: { 'a-b': 'x' } }).ok, false); assert.equal(validateJob({ ...good, env: { A: 'x'.repeat(4097) } }).ok, false); const many = Object.fromEntries(Array.from({ length: 31 }, (_, i) => [`V${i}`, '1'])); assert.equal(validateJob({ ...good, env: many }).ok, false); assert.equal(validateJob({ ...good, branch: 'a b' }).ok, false); assert.equal(validateJob(null).ok, false); }); test('R8: Repo/Branch-Härtung, Whitelist, Sub, Lauf-ID', async () => { for (const repo of ['../..', './.', '-x/y', 'a/..']) assert.equal(validateJob({ ...good, repo }).ok, false, repo); for (const branch of ['-x', '--upload-pack=x', 'a..b', 'x.lock', 'x/']) assert.equal(validateJob({ ...good, branch }).ok, false, branch); const v = validateJob({ ...good, devCmd: 'rm -rf /', image: 'evil' }); assert.equal(v.ok, true); assert.deepEqual(Object.keys(v.job).sort(), ['branch', 'env', 'mode', 'repo', 'sha', 'sub']); assert.equal(validateJob({ ...good, sub: 'demo\n' }).ok, false); await withServer(config, async (base) => { const r = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, sub: 'other' }) }); assert.equal(r.status, 400); assert.equal((await fetch(`${base}/jobs/not-a-uuid`, { headers: auth })).status, 400); }); }); test('readMemAvailable liest MemAvailable in Bytes', () => { const p = join(mkdtempSync(join(tmpdir(), 'mem-')), 'meminfo'); writeFileSync(p, 'MemTotal: 8000000 kB\nMemFree: 100000 kB\nMemAvailable: 2048000 kB\n'); assert.equal(readMemAvailable(p), 2048000 * 1024); }); test('PUT /dev/:sub: Auth, 400, 200 ⇒ register', async () => { await withServer(config, async (base) => { const put = (path, headers, body) => fetch(base + path, { method: 'PUT', headers, body: JSON.stringify(body) }); assert.equal((await put('/dev/demo', { 'Content-Type': 'application/json' }, good)).status, 401); assert.equal((await put('/dev/demo', auth, { ...good, sub: 'andere' })).status, 400); assert.equal((await put('/dev/demo', auth, { ...good, sha: 'zz' })).status, 400); assert.equal((await put('/dev/DEMO', auth, good)).status, 400); assert.equal(registered.length, 0); const r = await put('/dev/demo', auth, good); assert.equal(r.status, 200); assert.deepEqual(await r.json(), { state: 'sleeping' }); assert.equal(registered[0][0], 'demo'); assert.equal(registered[0][1].repo, 'JUSN/demo'); }); });