New e2e-phase3a.js (21 cases): no account enumeration, CSRF positive/negative, login rate limiting, RBAC across all three roles, cohort-scoped instructor access (visibility + route access), last-owner protection, live role/status re-evaluation (no separate revoke needed), password change (old password rejected after, other sessions revoked), audit log content (including a no-secrets-in-metadata assertion), student session RBAC/logout, and session persistence across a REAL docker compose restart api mid-test. New session-helper.js — a shared cookie-jar test helper (Node's fetch doesn't persist cookies across calls) that also auto-attaches X-CSRF-Token from whichever rn_csrf_* cookie it's holding, replacing the single-cookie-string trackers e2e-phase2a.js and e2e-phase2b.js each had their own copy of. e2e-phase2a.js and e2e-phase2b.js updated for the new auth model: admin login is now a real bootstrapped OWNER_EMAIL/OWNER_PASSWORD account instead of the old shared ADMIN_PASSWORD. All cases still passing: 10/10 and 12/12 respectively.
115 lines
5.5 KiB
JavaScript
115 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 2A student-flow regression, updated for Phase 3A's persistent session + per-instructor
|
|
// auth model. Originally used X-Student-Id headers and a ?sid= query param — both mechanisms
|
|
// are long gone; this version exercises the same flow through the real rn_session cookie, with
|
|
// the admin roster-add step now going through a real instructor account instead of a shared
|
|
// ADMIN_PASSWORD.
|
|
//
|
|
// 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
|
|
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
|
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2a.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);
|
|
}
|
|
|
|
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 3A sessions/auth) — slow, real k3s boots\n');
|
|
|
|
const admin = makeSession(GW);
|
|
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
assert.equal(loginStatus, 200, 'owner login must succeed — did you bootstrap one?');
|
|
|
|
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(GW);
|
|
const bobSession = makeSession(GW);
|
|
|
|
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); });
|