#!/usr/bin/env node // Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture. // Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as // of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact // same flow through the real rn_session cookie instead, since that's how it actually works now. // // Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No // framework, matching project convention. // // Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js const assert = require('assert'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const COHORT = `e2e-test-${Date.now()}`; // Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a // browser does, so each "session" here explicitly carries its own Set-Cookie forward. 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 }; }, rawFetch(path) { return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} }); }, }; } 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 student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n'); const admin = makeSession(); await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD }); let alice, bob; await test('roster: add two students via admin', async () => { const a = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Alice-2A', cohort_id: null }); const b = await admin.request('POST', '/api/admin/students', { display_name: 'E2E-Bob-2A', cohort_id: null }); alice = a.body.student_id; bob = b.body.student_id; assert.notEqual(alice, bob); }); const aliceSession = makeSession(); const bobSession = makeSession(); await test('login: student session cookie is issued and required for curriculum', async () => { const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice }); assert.equal(status, 200); const noSession = await fetch(`${GW}/api/curriculum/path`); assert.equal(noSession.status, 401); // anonymous rejection const { status: withSession } = await aliceSession.request('GET', '/api/curriculum/path'); assert.equal(withSession, 200); }); await test('curriculum: real RootNode stages, in order', async () => { const { body } = await aliceSession.request('GET', '/api/curriculum/path'); assert.deepEqual(body.stages.map((s) => s.id), ['foundation', 'workloads', 'networking', 'configuration', 'troubleshooting', 'real-ops', 'capstone']); }); let aliceLaunch, bobLaunch; await test('launch: Alice gets a running environment (real k3s boot, be patient)', async () => { const { status, body } = await aliceSession.request('POST', '/api/environment/launch'); assert.equal(status, 200); assert.equal(body.status, 'running'); aliceLaunch = body; }); await test('launch: Bob gets a different environment', async () => { await bobSession.request('POST', '/api/login', { student_id: bob }); const { body } = await bobSession.request('POST', '/api/environment/launch'); assert.notEqual(body.launch_path, aliceLaunch.launch_path); bobLaunch = body; }); await test('gateway: Alice reaches her own lab via session (no ?sid= needed anymore)', async () => { const res = await aliceSession.rawFetch(aliceLaunch.launch_path); assert.equal(res.status, 200); }); await test('security: Alice cannot reach Bob\'s lab even with her OWN valid session (impersonation)', async () => { const res = await aliceSession.rawFetch(bobLaunch.launch_path); assert.equal(res.status, 403); }); await test('security: no session at all is rejected, not just wrong session', async () => { const res = await fetch(`${GW}${aliceLaunch.launch_path}`); assert.equal(res.status, 401); }); await test('progress relay: solving a scenario in Alice\'s lab shows up in her curriculum', async () => { await aliceSession.request('POST', `${aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' }); await new Promise((r) => setTimeout(r, 1500)); const { body } = await aliceSession.request('GET', '/api/curriculum/path'); const scenario = body.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq'); assert.equal(scenario.status, 'completed'); }); await test('reset: stable launch_path, progress survives', async () => { const { body } = await aliceSession.request('POST', '/api/environment/reset'); assert.equal(body.launch_path, aliceLaunch.launch_path); const { body: curriculum } = await aliceSession.request('GET', '/api/curriculum/path'); 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`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });