#!/usr/bin/env node // Phase 1 integration test suite — runs against a live docker-compose.dev.yml stack. // Mirrors the manual verification run during implementation (see docs/PHASE1_PLATFORM_FOUNDATION.md // §8 and the final report in the Phase 1 PR/commit history). No framework — assert-based, per // project convention (ponytail: no test frameworks unless the project already has one; this // one doesn't). // // Usage: // cd platform/provisioner // docker compose -f docker-compose.dev.yml up -d // PROVISIONER_SHARED_SECRET= LAB_IMAGE_REF=rootnode-academy:test node test/run.js // // Defaults to the lightweight fake-lab test image (LAB_IMAGE_REF) for speed — the mechanics // under test here (network/container/firewall/registry lifecycle) don't depend on what's // running inside the container. The real Lab image (k3s) was validated separately by hand — // see the "cannot be fully automated here" note at the bottom of this file. const assert = require('assert'); const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); const BASE = process.env.PROVISIONER_URL || 'http://localhost:4100'; const KEY = process.env.PROVISIONER_SHARED_SECRET; if (!KEY) { console.error('Set PROVISIONER_SHARED_SECRET'); process.exit(1); } async function api(method, path, body) { const res = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Provisioner-Key': KEY }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = text; } return { status: res.status, body: json }; } async function iptablesRules(chain) { try { const { stdout } = await execFileAsync('docker', [ 'run', '--rm', '--network', 'host', '--cap-add=NET_ADMIN', '--cap-add=NET_RAW', 'alpine', 'sh', '-c', `apk add -q iptables >/dev/null 2>&1; iptables -S ${chain} 2>&1`, ]); return stdout; } catch (e) { return e.stdout || e.message; } } let passed = 0, failed = 0; async function test(name, fn) { try { await fn(); console.log(` ok — ${name}`); passed++; } catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; } } async function main() { console.log('Provisioner integration tests\n'); // 1. Health await test('GET /health reports ok with db and lab_hosts reachable', async () => { const { status, body } = await api('GET', '/health'); assert.equal(status, 200); assert.equal(body.ok, true); assert.equal(body.db, true); assert.ok(body.lab_hosts.length >= 1); }); // 2. Auth await test('unauthenticated request is rejected', async () => { const res = await fetch(`${BASE}/health`); assert.equal(res.status, 401); }); // 3. Provision -> running, unique network/container/token let envA, envB; await test('provision creates a running environment with a unique routing token', async () => { const { status, body } = await api('POST', '/environments', { student_id: 'test-student-A' }); assert.equal(status, 201); assert.equal(body.status, 'running'); assert.ok(body.routing_token && body.routing_token.length >= 32); envA = body; }); await test('a second student gets a distinct environment/network/token', async () => { const { status, body } = await api('POST', '/environments', { student_id: 'test-student-B' }); assert.equal(status, 201); assert.notEqual(body.environment_id, envA.environment_id); assert.notEqual(body.routing_token, envA.routing_token); envB = body; }); await test('duplicate provision for the same student is rejected (409)', async () => { const { status } = await api('POST', '/environments', { student_id: 'test-student-A' }); assert.equal(status, 409); }); await test('environments have distinct internal IPs on distinct subnets', async () => { const a = (await api('GET', `/environments/${envA.environment_id}`)).body; const b = (await api('GET', `/environments/${envB.environment_id}`)).body; assert.notEqual(a.internal_ip, b.internal_ip); assert.notEqual(a.network_subnet, b.network_subnet); }); // 4. Egress firewall ordering — the exact regression the original -I bug would have caused. await test('firewall chain permits the (RFC1918) proxy address before the RFC1918 blanket drop', async () => { const a = (await api('GET', `/environments/${envA.environment_id}`)).body; const shortId = envA.environment_id.replace(/-/g, '').slice(0, 12); const rules = await iptablesRules(`ROOTNODE-ENV-${shortId}`); const lines = rules.trim().split('\n').filter((l) => l.startsWith('-A')); const acceptIdx = lines.findIndex((l) => l.includes('ACCEPT') && l.includes('3128')); const dropIdx = lines.findIndex((l) => l.includes('DROP') && l.includes('172.16.0.0/12')); assert.ok(acceptIdx >= 0 && dropIdx >= 0, 'expected both an ACCEPT-to-proxy and a DROP-172.16 rule'); assert.ok(acceptIdx < dropIdx, 'ACCEPT must come before DROP — this is the bug the redesign fixed'); }); // 5. Isolation — the core security property. await test('student A cannot reach student B (student-to-student isolation)', async () => { const a = (await api('GET', `/environments/${envA.environment_id}`)).body; const b = (await api('GET', `/environments/${envB.environment_id}`)).body; const shortA = envA.environment_id.replace(/-/g, '').slice(0, 12); try { await execFileAsync('docker', ['exec', `rootnode-lab-${shortA}`, 'node', '-e', `const h=require('http');const r=h.get({host:'${b.internal_ip}',port:80,path:'/api/health',timeout:2000},()=>process.exit(1));r.on('error',()=>process.exit(0));r.on('timeout',()=>{r.destroy();process.exit(0)});`, ]); } catch (e) { // execFile throws on non-zero exit; our script exits 0 when correctly blocked. if (e.code !== 0) throw new Error(`expected blocked (exit 0), got exit ${e.code}`); } }); await test('student A cannot reach an arbitrary RFC1918 address (simulated trusted infra)', async () => { const shortA = envA.environment_id.replace(/-/g, '').slice(0, 12); try { await execFileAsync('docker', ['exec', `rootnode-lab-${shortA}`, 'node', '-e', `const h=require('http');const r=h.get({host:'10.1.2.3',port:80,path:'/',timeout:2000},()=>process.exit(1));r.on('error',()=>process.exit(0));r.on('timeout',()=>{r.destroy();process.exit(0)});`, ]); } catch (e) { if (e.code !== 0) throw new Error(`expected blocked (exit 0), got exit ${e.code}`); } }); // 6. Reset — same identity, fresh container. await test('reset preserves environment_id/routing_token but recreates the container', async () => { const before = (await api('GET', `/environments/${envA.environment_id}`)).body; const { status, body } = await api('POST', `/environments/${envA.environment_id}/reset`); assert.equal(status, 200); assert.equal(body.environment_id, envA.environment_id); assert.notEqual(body.container_id, before.container_id); }); // 7. Destroy — deterministic cleanup. await test('destroy removes container, network, and firewall chain', async () => { const shortB = envB.environment_id.replace(/-/g, '').slice(0, 12); const { status, body } = await api('DELETE', `/environments/${envB.environment_id}`); assert.equal(status, 200); assert.equal(body.status, 'destroyed'); const rules = await iptablesRules(`ROOTNODE-ENV-${shortB}`); assert.ok(/No chain|does not exist/i.test(rules), 'firewall chain should be gone after destroy'); }); // 8. Events — full audit trail, no secrets. await test('environment_events records the lifecycle and never contains the routing token', async () => { const { body: events } = await api('GET', `/environments/${envA.environment_id}/events`); const types = events.map((e) => e.event_type); for (const expected of ['provision_requested', 'host_selected', 'network_created', 'firewall_configured', 'container_created', 'ready']) { assert.ok(types.includes(expected), `missing event: ${expected}`); } const serialized = JSON.stringify(events); assert.ok(!serialized.includes(envA.routing_token) || envA.routing_token === undefined, 'routing token must never appear in event details'); }); // Cleanup await api('DELETE', `/environments/${envA.environment_id}`); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); }); // ── Not exercised by this script — see the Phase 1 final report for why ────────────────── // - Idle reaper timing (validated manually with a backdated last_active_at + real 60s wait; // automating that here would just make the suite slow for no extra confidence). // - Suspend/resume against the real Lab image / k3s specifically (experimental, decision #3 // — validated manually; correctness never depends on this). // - Remote TCP+TLS lab_host endpoint (validated manually against a Docker-in-Docker sidecar; // not wired into this script to avoid a heavy, flaky dependency in routine test runs). // - Reconciliation-on-restart (validated manually by killing a container out-of-band and // restarting the Provisioner; requires an out-of-band kill + process restart that doesn't // fit a single-process test script cleanly). // - Anything requiring the real Lab Host VM / Proxmox topology — see // docs/PHASE1_PLATFORM_FOUNDATION.md §8 tests 5, 7, 9, 12 for what's explicitly documented // as untestable on a single dev machine rather than faked.