Files
ced4568 f3c356951c test(preprod): two-host routing simulation, drill suite, CI security scanning
- infra/scripts/simulate-two-host/: real two-Docker-network simulation
  (Gateway genuinely isolated from any Lab bridge network) proving the
  Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the
  Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router
  access).
- infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback,
  CI-fails-closed (6 required failure modes), fake cohort dress rehearsal
  (14/14, one continuous narrative through the real app).
- infra/ci/gitea-workflows/build-and-test.yml: new security-scan job
  (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source),
  severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks,
  MEDIUM/LOW reported only). Gates build-images alongside regression.

Found and fixed two real pre-existing bugs via the CI-fails-closed drill:
staging-smoke-test.sh and production-smoke-test.sh both had a classic
set -e gotcha (curl ... && echo "OK" as one statement - a failing command
that isn't last in an && chain doesn't trigger -e) that let an
unreachable/broken endpoint fall through silently instead of failing the
smoke test.

platform/test/e2e-phase3a.js updated for the new paginated
/api/admin/audit-events response shape (Phase 4.1 pagination work).
2026-08-24 20:16:00 -05:00

213 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Phase 4.1 §6-9 — the real, unmocked proof that remote Lab routing works. Run via
// run-simulation.sh, which brings up docker-compose.simulate.yml first. No framework, per
// project convention (see platform/provisioner/test/run.js).
//
// Proves, against the real simulated stack:
// §6/7 Gateway cannot reach a Lab's Docker-internal IP directly, but reaches it correctly
// through the Lab Host Router — both HTTP and WebSocket terminal traffic, including a
// real `kubectl get nodes` inside the Lab.
// §8/9 Alice/Bob isolation, garbage tokens, destroyed environments, anonymous requests, and
// that the Router itself is unreachable without the Gateway's mTLS client identity —
// all through the real path, not asserted against mocks.
const assert = require('assert');
const https = require('https');
const { execFileSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4210';
const PROVISIONER_URL = process.env.PROVISIONER_URL || 'http://localhost:4101';
const PROVISIONER_KEY = process.env.PROVISIONER_SHARED_SECRET;
const GATEWAY_CONTAINER = process.env.GATEWAY_CONTAINER || 'rootnode-sim-gateway-1';
const WS_URL = GW.replace(/^http/, 'ws');
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.stack || e.message}`); failed++; }
}
async function provisionerCall(method, path) {
const res = await fetch(`${PROVISIONER_URL}${path}`, { method, headers: { 'X-Provisioner-Key': PROVISIONER_KEY } });
const body = await res.json().catch(() => null);
return { status: res.status, body };
}
// The "browser never learns internal_ip" invariant holds everywhere else in the app — this
// test is the one deliberate, documented exception: it needs the real Docker-internal address
// as ground truth to prove the Gateway genuinely CANNOT reach it, which requires calling the
// Provisioner's internal API directly (the same privilege level an operator/CI job has, never
// a student's).
async function internalAddressForToken(token) {
const { body } = await provisionerCall('GET', `/environments/by-token/${token}`);
return body;
}
// bootstrap-owner.js isn't copied into the api image (it's an operator-run one-off script, not
// a runtime dependency — same reason platform/test regression runs hit this before, see
// docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md's regression notes). Run it from the host against
// the sim Postgres's published port instead of `docker exec`ing into the container.
function bootstrapOwner(email, name, password) {
execFileSync('node', ['bootstrap-owner.js', email, name, password], {
cwd: require('path').join(__dirname, '../../../platform/api'),
env: { ...process.env, PLATFORM_DB_URL: 'postgres://postgres:postgres@localhost:5434/rootnode_platform_sim' },
stdio: 'inherit',
});
}
// Raw TCP connect attempt from WITHIN the gateway container's own network namespace — proves
// isolation from the gateway's actual point of view, not the test driver's (which runs on the
// host and has a completely different, irrelevant reachability picture).
function gatewayCanReach(ip, port, timeoutMs = 2500) {
const script = `
const net = require('net');
const s = net.createConnection({ host: '${ip}', port: ${port}, timeout: ${timeoutMs} });
s.on('connect', () => { console.log('REACHABLE'); s.destroy(); process.exit(0); });
s.on('timeout', () => { console.log('UNREACHABLE(timeout)'); process.exit(0); });
s.on('error', (e) => { console.log('UNREACHABLE(' + e.code + ')'); process.exit(0); });
`;
const out = execFileSync('docker', ['exec', GATEWAY_CONTAINER, 'node', '-e', script]).toString().trim();
return out.startsWith('REACHABLE');
}
function connectRouterWithoutClientCert(host, port, timeoutMs = 2500) {
return new Promise((resolve) => {
const req = https.request({ host, port, method: 'GET', rejectUnauthorized: false, timeout: timeoutMs }, (res) => {
resolve({ ok: true, status: res.statusCode });
});
req.on('error', (e) => resolve({ ok: false, error: e.code || e.message }));
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
req.end();
});
}
function openTerminal(launchPath, cookieHeader) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
let buffer = '';
const timer = setTimeout(() => { ws.close(); reject(new Error(`terminal timed out, buffer so far: ${buffer.slice(-400)}`)); }, 30000);
ws.addEventListener('open', () => ws.send('kubectl get nodes\n'));
ws.addEventListener('message', (ev) => {
buffer += ev.data.toString();
if (/Ready\s/.test(buffer) || /node\.?\d*\s+Ready/i.test(buffer)) {
clearTimeout(timer);
ws.close();
resolve(buffer);
}
});
ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`ws error: ${e.message || e}`)); });
});
}
function expectWsRejected(launchPath, cookieHeader, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${WS_URL}${launchPath}shell-ws`, { headers: { Cookie: cookieHeader } });
const timer = setTimeout(() => { ws.close(); reject(new Error('expected the WS to be rejected, but it stayed open')); }, timeoutMs);
ws.addEventListener('open', () => { clearTimeout(timer); ws.close(); reject(new Error('expected the WS upgrade to be rejected, but it succeeded')); });
ws.addEventListener('close', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); resolve(); });
});
}
async function main() {
console.log('Phase 4.1 two-host remote-routing simulation — slow, real k3s boots\n');
bootstrapOwner('[email protected]', 'Sim Owner', 'SimOwnerPass!2026');
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: '[email protected]', password: 'SimOwnerPass!2026' });
assert.equal(loginStatus, 200, 'owner login must succeed through the Gateway');
let alice, bob;
await test('roster: add Alice and Bob via admin (through the Gateway)', async () => {
const a = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Alice', cohort_id: null });
const b = await admin.request('POST', '/api/admin/students', { display_name: 'Sim-Bob', cohort_id: null });
alice = a.body.student_id; bob = b.body.student_id;
assert.notEqual(alice, bob);
});
const aliceSession = makeSession(GW);
const bobSession = makeSession(GW);
await aliceSession.request('POST', '/api/login', { student_id: alice });
await bobSession.request('POST', '/api/login', { student_id: bob });
let aliceLaunch, bobLaunch, aliceEnv;
await test('launch: Alice and Bob each get a running environment on the simulated remote lab host', async () => {
const a = await aliceSession.request('POST', '/api/environment/launch');
const b = await bobSession.request('POST', '/api/environment/launch');
assert.equal(a.status, 200); assert.equal(a.body.status, 'running');
assert.equal(b.status, 200); assert.equal(b.body.status, 'running');
aliceLaunch = a.body; bobLaunch = b.body;
const aliceToken = aliceLaunch.launch_path.split('/')[2];
aliceEnv = await internalAddressForToken(aliceToken);
assert.equal(aliceEnv.lab_host_routing_mode, 'router', 'sim-lab-host-01 must be configured as routing_mode: router for this test to mean anything');
});
await test('§6/7 — the Gateway CANNOT reach the Lab\'s Docker-internal IP directly', async () => {
const reachable = gatewayCanReach(aliceEnv.internal_ip, aliceEnv.internal_port);
assert.equal(reachable, false, `gateway container reached ${aliceEnv.internal_ip}:${aliceEnv.internal_port} directly — the network isolation this whole phase depends on is not real`);
});
await test('§6/7 — but the Gateway DOES reach the Lab through the Lab Host Router (HTTP)', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
const text = await res.text();
assert.ok(text.length > 0, 'expected real HTML/asset content back from the Lab, routed through the Router');
});
await test('§8 — Alice -> her own Lab: ALLOW', async () => {
const res = await aliceSession.rawFetch(aliceLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Bob -> his own Lab: ALLOW', async () => {
const res = await bobSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 200);
});
await test('§8 — Alice -> Bob\'s Lab: DENY', async () => {
const res = await aliceSession.rawFetch(bobLaunch.launch_path);
assert.equal(res.status, 403);
});
await test('§8 — garbage token: DENY', async () => {
const res = await aliceSession.rawFetch('/lab/not-a-real-token-at-all/');
assert.equal(res.status, 404);
});
await test('§8 — anonymous Gateway request (no session at all): DENY', async () => {
const res = await fetch(`${GW}${aliceLaunch.launch_path}`);
assert.equal(res.status, 401);
});
await test('§8/9 — direct public access to the Lab Host Router: NOT AVAILABLE (mTLS required, no client cert presented)', async () => {
const routerUrl = new URL(aliceEnv.lab_host_router_endpoint);
const result = await connectRouterWithoutClientCert(routerUrl.hostname, routerUrl.port);
assert.equal(result.ok, false, 'the Router accepted a connection with no client certificate at all — mTLS is not actually being enforced');
});
await test('§7 — WebSocket terminal connects and a real `kubectl get nodes` succeeds, through the Router', async () => {
const output = await openTerminal(bobLaunch.launch_path, `rn_session=${bobSession.getCookie('rn_session')}`);
assert.ok(/Ready/i.test(output), `expected a Ready node in kubectl output, got: ${output.slice(-300)}`);
});
await test('§8 — Alice cannot open Bob\'s terminal WebSocket even with a valid session (her own)', async () => {
await expectWsRejected(bobLaunch.launch_path, `rn_session=${aliceSession.getCookie('rn_session')}`);
});
await test('§8/9 — destroyed environment: routing token stops working, both at the Gateway and at the Router directly', async () => {
const aliceToken = aliceLaunch.launch_path.split('/')[2];
await provisionerCall('DELETE', `/environments/${aliceEnv.environment_id}`);
// Give the Gateway's short-TTL env-cache a moment to expire so this proves real
// destroyed-environment handling, not just a stale cache hit.
await new Promise((r) => setTimeout(r, 6000));
const res = await aliceSession.rawFetch(`/lab/${aliceToken}/`);
assert.ok([404, 503].includes(res.status), `expected the destroyed environment to be rejected, got ${res.status}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });