diff --git a/platform/test/e2e-phase2b.js b/platform/test/e2e-phase2b.js new file mode 100644 index 0000000..ab7940d --- /dev/null +++ b/platform/test/e2e-phase2b.js @@ -0,0 +1,154 @@ +#!/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); });