feat(auth): add server-controlled student and admin sessions
Replaces the Phase 2A pilot ?sid= query param with an HttpOnly, SameSite=Lax session cookie. Gateway now derives student identity from the session (via Platform API's /api/session/whoami) instead of trusting a client-suppliable id, and checks ownership on every proxied HTTP and WebSocket Lab request, not just the initial navigation. Fixes a routing bug found during testing: the fallback route was using 'does this session own a running environment' to decide whether an unprefixed /api/* call belonged to the Lab UI or the dashboard — true in both places. Replaced with an explicit LAB_OWNED_PATHS allowlist of the exact unprefixed paths the existing Lab UI actually calls. e2e-phase2a.js rewritten to use a cookie jar instead of headers/query params, with explicit impersonation-rejection tests added. 10/10 passing.
This commit is contained in:
@@ -1,32 +1,38 @@
|
||||
#!/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.
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
|
||||
// framework, matching project convention.
|
||||
//
|
||||
// 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.
|
||||
// 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()}`;
|
||||
|
||||
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 };
|
||||
// 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;
|
||||
@@ -36,110 +42,83 @@ async function test(name, fn) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 2A end-to-end tests (this is slow — real k3s boots)\n');
|
||||
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', 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);
|
||||
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);
|
||||
});
|
||||
|
||||
await test('roster: cohort listing includes the new cohort', async () => {
|
||||
const { body } = await api('GET', `/api/roster?cohort=${COHORT}`);
|
||||
assert.equal(body.length, 2);
|
||||
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('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');
|
||||
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 with a launch_path (real k3s boot, be patient)', async () => {
|
||||
const { status, body } = await api('POST', '/api/environment/launch', { studentId: alice });
|
||||
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');
|
||||
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');
|
||||
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('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}`);
|
||||
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('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}`);
|
||||
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('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('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 () => {
|
||||
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 });
|
||||
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('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);
|
||||
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 api('GET', '/api/curriculum/path', { studentId: alice });
|
||||
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`);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user