platform/provisioner/test/run.js — 12 assert-based integration tests against a live docker-compose.dev.yml stack (no framework, matching the project's existing convention). Covers: health/auth, provision with unique network/container/token, duplicate-provision rejection, distinct subnets/IPs for concurrent students, the egress firewall ordering fix specifically (proxy ACCEPT before the RFC1918 DROP for the same range), student-to-student isolation, RFC1918 blocking, reset (same identity, fresh container), destroy (deterministic cleanup), and the audit trail (all expected events present, routing token never logged). All 12 pass against the default lightweight test image. Several properties were validated manually during implementation rather than automated here — idle reaper timing (needs a real threshold wait), remote TCP+TLS lab-host (needs a Docker-in-Docker sidecar, too heavy for routine runs), reconciliation-on-crash (needs an out-of-band container kill + process restart), and real-Lab-image k3s behavior (environment-specific, see the Phase 1 final report and platform/provisioner/README.md's known limitations) — each is noted at the bottom of the file with why it's not automated here. Co-Authored-By: Claude Sonnet 5 <[email protected]>
199 lines
9.6 KiB
JavaScript
199 lines
9.6 KiB
JavaScript
#!/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=<from .env> 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.
|