#!/usr/bin/env node // Phase 2B classroom workflow — the acceptance test from // docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md, updated for Phase 3A's per-instructor accounts: // 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 // (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD) // OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2b.js const assert = require('assert'); const { makeSession } = require('./session-helper'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; 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 (bootstrap one first with platform/api/bootstrap-owner.js)'); process.exit(1); } const COHORT_ID = `e2e-cohort-${Date.now()}`; 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 (Phase 3A auth) — slow, real k3s boots\n'); const admin = makeSession(GW); const other = makeSession(GW); await test('instructor auth: wrong password rejected, correct password issues a session', async () => { const bad = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong' }); assert.equal(bad.status, 401); const good = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_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(GW); const bob = makeSession(GW); 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 () => { 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); });