Files
rootnode-academy/infra/scripts/drills/test-lab-host-loss.js
T
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

105 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
// Phase 4.1 §21 — Lab Host loss/rebuild drill. Proves disposable infrastructure is genuinely
// disposable: a student's Lab container is destroyed OUT FROM UNDER the Provisioner (no clean
// destroy call — simulating the whole rn-lab-host-01 VM disappearing), and the system must:
// - keep the Platform (API/Gateway) up throughout — it never depended on that one container
// - never present the now-gone environment as healthy/running once reconciliation runs
// - leave academic data (progress/attempts) completely untouched
// - let the student launch a brand-new Lab afterward and continue where they left off
//
// Run via run-lab-host-loss-drill.sh, which brings up platform/docker-compose.dev.yml first.
// No framework, matching project convention.
const assert = require('assert');
const { execFileSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
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 main() {
console.log('Phase 4.1 Lab Host loss/rebuild drill — slow, real k3s boots\n');
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(loginStatus, 200, 'owner login must succeed');
let studentId;
await test('roster: add a student', async () => {
const { body } = await admin.request('POST', '/api/admin/students', { display_name: 'Drill-LabHostLoss', cohort_id: null });
studentId = body.student_id;
});
const session = makeSession(GW);
await session.request('POST', '/api/login', { student_id: studentId });
let launch;
await test('launch: student gets a running Lab (real k3s boot)', async () => {
const { status, body } = await session.request('POST', '/api/environment/launch');
assert.equal(status, 200); assert.equal(body.status, 'running');
launch = body;
});
await test('progress: student completes a real scenario before the Lab Host is lost', async () => {
const res = await session.request('POST', `${launch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
assert.equal(res.status, 200);
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must show completed before we destroy anything');
});
const token = launch.launch_path.split('/')[2];
await test('§21 — simulate total Lab Host loss: kill the Lab container directly, bypassing the Provisioner entirely', async () => {
const containers = execFileSync('docker', ['ps', '--filter', 'name=rootnode-lab-', '--format', '{{.Names}}']).toString().trim().split('\n').filter(Boolean);
assert.ok(containers.length >= 1, 'expected at least one running Lab container to destroy');
for (const c of containers) execFileSync('docker', ['rm', '-f', c]);
console.log(` destroyed ${containers.length} Lab container(s) out from under the Provisioner`);
});
await test('§21 — Platform stays available throughout (it never depended on that container)', async () => {
const api = await fetch(`${API}/healthz`);
const gw = await fetch(`${GW}/healthz`);
assert.equal(api.status, 200);
assert.equal(gw.status, 200);
});
await test('§21 — academic progress is completely untouched by the infrastructure loss', async () => {
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed', 'progress must survive the Lab Host loss unchanged');
});
await test('§21 — the now-gone environment is NEVER presented as healthy once reconciliation runs (Provisioner restart, its real startup path)', async () => {
// cwd is already platform/ — run-lab-host-loss-drill.sh cd's there before invoking this script.
execFileSync('docker', ['compose', '-f', 'docker-compose.dev.yml', 'restart', 'provisioner'], { stdio: 'inherit' });
// Give the Provisioner a moment to finish its real reconcileOnStartup pass.
await new Promise((r) => setTimeout(r, 5000));
const res = await session.rawFetch(`/lab/${token}/`);
assert.ok([503, 404].includes(res.status), `expected the stale environment to be rejected (503/404), got ${res.status} — a healthy-looking response here would mean a destroyed environment is being silently presented as available`);
});
await test('§21 — a replacement Lab can be launched and boots successfully (the "replacement Lab Host" case, minus needing a literal second VM)', async () => {
const { status, body } = await session.request('POST', '/api/environment/launch');
assert.equal(status, 200); assert.equal(body.status, 'running');
assert.notEqual(body.launch_path, launch.launch_path, 'the replacement environment must be a genuinely new one, not the destroyed one still being served');
});
await test('§21 — previous academic progress is still there after the replacement Lab boots', async () => {
const progress = await session.request('GET', '/api/progress');
assert.equal(progress.body.details['pod-basics-mcq']?.status, 'completed');
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });