diff --git a/platform/provisioner/test/run.js b/platform/provisioner/test/run.js new file mode 100644 index 0000000..7cb7d24 --- /dev/null +++ b/platform/provisioner/test/run.js @@ -0,0 +1,198 @@ +#!/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.