#!/usr/bin/env node // Phase 2B classroom workflow — the actual acceptance test from // docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: instructor creates a cohort, adds three students, // assigns curriculum, prepares Labs for the whole cohort, two students complete different // scenarios, instructor resets one student's Lab, instructor destroys all cohort Labs after // class — progress survives every step. // // Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots, // concurrently). No framework, matching project convention. // // Usage: cd platform && docker compose -f docker-compose.dev.yml up -d // ADMIN_PASSWORD= node test/e2e-phase2b.js const assert = require('assert'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); } const COHORT_ID = `e2e-cohort-${Date.now()}`; function makeSession() { let cookie = ''; return { async request(method, path, body) { const headers = { 'Content-Type': 'application/json' }; if (cookie) headers.Cookie = cookie; const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); const setCookie = res.headers.get('set-cookie'); if (setCookie) cookie = setCookie.split(';')[0]; const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = text; } return { status: res.status, body: json }; }, }; } 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('Phase 2B classroom workflow acceptance test — slow, real k3s boots\n'); const admin = makeSession(); const other = makeSession(); await test('admin auth: wrong password rejected, correct password issues a session', async () => { const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' }); assert.equal(bad.status, 401); const good = await admin.request('POST', '/api/admin/login', { password: ADMIN_PASSWORD }); assert.equal(good.status, 200); }); await test('security: a student session cannot call admin routes', async () => { const res = await fetch(`${GW}/api/admin/dashboard`); assert.equal(res.status, 401); }); await test('admin: create cohort', async () => { const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E Cohort', tier: 'root' }); assert.equal(status, 201); }); let students = []; await test('admin: add three students to the cohort', async () => { for (const name of ['E2E-Alice', 'E2E-Bob', 'E2E-Carlos']) { const { status, body } = await admin.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID }); assert.equal(status, 201); students.push(body.student_id); } const { body: roster } = await admin.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`); assert.equal(roster.length, 3); }); await test('admin: assign RootNode curriculum to the cohort', async () => { const { status } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/curriculum`, { curriculum_path: 'rootnode-path' }); assert.equal(status, 200); }); let prepResults; await test('admin: Prepare Labs provisions all three concurrently — 3/3 READY', async () => { const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`); assert.equal(status, 200); prepResults = body.results; assert.equal(prepResults.length, 3); assert.ok(prepResults.every((r) => r.status === 'running'), `expected all running, got: ${JSON.stringify(prepResults)}`); }); const [aliceId, bobId] = students; const alice = makeSession(); const bob = makeSession(); await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => { await alice.request('POST', '/api/login', { student_id: aliceId }); await bob.request('POST', '/api/login', { student_id: bobId }); const start = Date.now(); const { body: aliceEnv } = await alice.request('POST', '/api/environment/launch'); const { body: bobEnv } = await bob.request('POST', '/api/environment/launch'); const elapsedMs = Date.now() - start; assert.equal(aliceEnv.status, 'running'); assert.equal(bobEnv.status, 'running'); assert.notEqual(aliceEnv.launch_path, bobEnv.launch_path); // Reusing an already-prepared lab should be near-instant, not another full k3s boot. assert.ok(elapsedMs < 15000, `expected instant reuse, took ${elapsedMs}ms`); global.__aliceLaunch = aliceEnv; global.__bobLaunch = bobEnv; }); await test('students: Alice and Bob complete DIFFERENT scenarios', async () => { await alice.request('POST', `${global.__aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' }); await bob.request('POST', `${global.__bobLaunch.launch_path}api/scenarios/kubectl-essentials-mcq/answer`, { selected: 'd' }); await new Promise((r) => setTimeout(r, 1500)); }); await test('admin: dashboard reflects student completions', async () => { const { body } = await admin.request('GET', '/api/admin/dashboard'); assert.equal(body.labs.running, 3); assert.ok(body.recent_completions.some((c) => c.student_id === aliceId)); }); let aliceEnvId; await test('admin: reset Alice\'s Lab — academic progress remains intact', async () => { const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`); aliceEnvId = envs.find((e) => e.status !== 'destroyed').environment_id; const { status } = await admin.request('POST', `/api/admin/environments/${aliceEnvId}/reset`); assert.equal(status, 200); const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`); const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); assert.equal(scenario.status, 'completed'); }); await test('admin: destroy all cohort Labs after class — progress remains intact for everyone', async () => { const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/destroy-labs`); assert.equal(status, 200); assert.ok(body.results.every((r) => r.status === 'destroyed')); const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`); const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); assert.equal(scenario.status, 'completed'); }); await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => { // Sanity check via the admin environment list — this doesn't re-run the Provisioner's own // iptables tests (see platform/provisioner/test/run.js for those), just confirms nothing // in the Phase 2B layer introduced a published port. const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`); assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80)); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });