diff --git a/platform/test/e2e-phase2a.js b/platform/test/e2e-phase2a.js new file mode 100644 index 0000000..f07104f --- /dev/null +++ b/platform/test/e2e-phase2a.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// Phase 2A end-to-end test — runs against a live platform/docker-compose.dev.yml stack +// (postgres + egress-proxy + provisioner + identity + gateway). Exercises the full student +// flow through the Gateway, exactly as a browser would, using two students concurrently to +// verify isolation. This is slow (each lab provision is a real k3s cold start, ~1-3 min) and +// deliberately not run on every commit — see platform/README.md for when to run it. +// +// No framework, matching platform/provisioner/test/run.js's convention. Requires: +// cd platform && docker compose -f docker-compose.dev.yml up -d +// node test/e2e-phase2a.js +// +// This captures what was verified by hand during Phase 2A implementation (see +// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8) as a repeatable regression script — it is not +// a substitute for the manual real-Lab-image verification (node-pty, terminal, kubectl, +// scenario validation) documented there, which needs a browser/WebSocket client and isn't +// automated here. + +const assert = require('assert'); + +const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; +const COHORT = `e2e-test-${Date.now()}`; + +async function api(method, path, { studentId, body } = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (studentId) headers['X-Student-Id'] = studentId; + const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); + 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 2A end-to-end tests (this is slow — real k3s boots)\n'); + + let alice, bob; + await test('roster: add two students', async () => { + const a = await api('POST', '/api/roster', { body: { display_name: 'E2E-Alice', cohort: COHORT } }); + const b = await api('POST', '/api/roster', { body: { display_name: 'E2E-Bob', cohort: COHORT } }); + assert.equal(a.status, 200); assert.equal(b.status, 200); + alice = a.body.student_id; bob = b.body.student_id; + assert.notEqual(alice, bob); + }); + + await test('roster: cohort listing includes the new cohort', async () => { + const { body } = await api('GET', `/api/roster?cohort=${COHORT}`); + assert.equal(body.length, 2); + }); + + await test('login: both students can log in', async () => { + const a = await api('POST', '/api/login', { body: { student_id: alice } }); + const b = await api('POST', '/api/login', { body: { student_id: bob } }); + assert.equal(a.status, 200); assert.equal(b.status, 200); + }); + + await test('curriculum: path loads with the real RootNode stages, in order', async () => { + const { body } = await api('GET', '/api/curriculum/path', { studentId: alice }); + const ids = body.stages.map((s) => s.id); + assert.deepEqual(ids, ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']); + assert.equal(body.stages.find((s) => s.id === 'capstone').locked, true); + }); + + await test('environment: status is none before launch', async () => { + const { body } = await api('GET', '/api/environment/status', { studentId: alice }); + assert.equal(body.status, 'none'); + }); + + let aliceLaunch, bobLaunch; + await test('launch: Alice gets a running environment with a launch_path (real k3s boot, be patient)', async () => { + const { status, body } = await api('POST', '/api/environment/launch', { studentId: alice }); + assert.equal(status, 200); + assert.equal(body.status, 'running'); + assert.ok(body.launch_path?.startsWith('/lab/')); + aliceLaunch = body; + }); + + await test('launch: Bob gets a DIFFERENT running environment', async () => { + const { status, body } = await api('POST', '/api/environment/launch', { studentId: bob }); + assert.equal(status, 200); + assert.equal(body.status, 'running'); + assert.notEqual(body.launch_path, aliceLaunch.launch_path); + bobLaunch = body; + }); + + await test('launch: relaunching for Alice reuses the same environment (idempotent)', async () => { + const { body } = await api('POST', '/api/environment/launch', { studentId: alice }); + assert.equal(body.launch_path, aliceLaunch.launch_path); + }); + + await test('gateway: Alice can reach her own lab', async () => { + const res = await fetch(`${GW}${aliceLaunch.launch_path}`); + assert.equal(res.status, 200); + }); + + await test('gateway: an invalid token is rejected (404)', async () => { + const res = await fetch(`${GW}/lab/not-a-real-token-at-all/`); + assert.equal(res.status, 404); + }); + + await test('gateway: Alice\'s URL with Bob\'s sid is rejected (cross-student ownership check)', async () => { + const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${bob}`); + assert.equal(res.status, 403); + }); + + await test('gateway: Alice\'s URL with her own sid succeeds', async () => { + const res = await fetch(`${GW}${aliceLaunch.launch_path}?sid=${alice}`); + assert.equal(res.status, 200); + }); + + await test('progress relay: solving a scenario in Alice\'s lab shows up in her curriculum', async () => { + const scenarioPath = `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`; + await api('POST', scenarioPath, { studentId: alice, body: { selected: 'b' } }); + await new Promise((r) => setTimeout(r, 1500)); // relay is fire-and-forget on the Gateway side + const { body } = await api('GET', '/api/curriculum/path', { studentId: alice }); + const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); + assert.equal(scenario.status, 'completed'); + }); + + await test('progress relay: Bob\'s curriculum is unaffected by Alice\'s answer', async () => { + const { body } = await api('GET', '/api/curriculum/path', { studentId: bob }); + const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); + assert.notEqual(scenario.status, 'completed'); + }); + + await test('reset: Alice\'s lab keeps the same launch_path (stable URL) and her progress survives', async () => { + const { status, body } = await api('POST', '/api/environment/reset', { studentId: alice }); + assert.equal(status, 200); + assert.equal(body.launch_path, aliceLaunch.launch_path); + const { body: curriculum } = await api('GET', '/api/curriculum/path', { studentId: alice }); + const scenario = curriculum.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); + assert.equal(scenario.status, 'completed'); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + console.log('\nNot covered here (needs a browser/WebSocket client — verified manually, see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md §8):'); + console.log(' - real Lab image: node-pty load, terminal WS, kubectl execution'); + console.log(' - idle reaper timing (needs a real threshold wait, or DB manipulation — see the doc)'); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });