#!/usr/bin/env node // Phase 3A — identity and instructor access. Covers session persistence (including a real // Platform API restart), per-instructor auth (no shared ADMIN_PASSWORD), RBAC across // owner/admin/instructor, cohort-scoped instructor access, CSRF enforcement, login rate // limiting, audit logging, and a repeat of the Phase 2B classroom workflow with a real // instructor account instead of a shared password. // // Runs against a live platform/docker-compose.dev.yml stack, and shells out to `docker compose // restart api` for the restart-persistence test — so it must run from a checkout where that // compose file's `api` service is actually running (see Usage). 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-phase3a.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const API = process.env.PLATFORM_API_URL || 'http://localhost:4300'; // direct — a few checks (restart) need to bypass the Gateway's proxy caching const OWNER_EMAIL = process.env.OWNER_EMAIL; const OWNER_PASSWORD = process.env.OWNER_PASSWORD; const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1'; 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); } const RUN_ID = Date.now(); 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 3A identity/RBAC acceptance test — slow, real k3s boots + a real API restart\n'); const owner = makeSession(GW); await test('instructor auth: no account enumeration (unknown email and wrong password look identical)', async () => { const unknown = await owner.request('POST', '/api/auth/instructor/login', { email: `nobody-${RUN_ID}@rootnode.test`, password: 'whatever12345' }); const wrongPw = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong-password' }); assert.equal(unknown.status, 401); assert.equal(wrongPw.status, 401); assert.deepEqual(unknown.body, wrongPw.body); }); await test('instructor auth: valid login issues a session + CSRF cookie', async () => { const { status } = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(status, 200); assert.ok(owner.hasCookie('rn_instructor')); assert.ok(owner.hasCookie('rn_csrf_i')); }); await test('CSRF: mutating request without the header is rejected', async () => { const res = await fetch(`${GW}/api/admin/cohorts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` }, body: JSON.stringify({ cohort_id: `csrf-should-fail-${RUN_ID}`, name: 'nope' }), }); assert.equal(res.status, 403); }); await test('CSRF: mutating request WITH the header succeeds', async () => { const { status } = await owner.request('POST', '/api/admin/cohorts', { cohort_id: `csrf-ok-${RUN_ID}`, name: 'CSRF OK' }); assert.equal(status, 201); }); await test('rate limiting: repeated bad logins for one identity get throttled', async () => { const probe = makeSession(GW); let sawThrottle = false; for (let i = 0; i < 9; i++) { const { status } = await probe.request('POST', '/api/auth/instructor/login', { email: `throttle-${RUN_ID}@rootnode.test`, password: 'wrong' }); if (status === 429) sawThrottle = true; } assert.ok(sawThrottle, 'expected a 429 within 9 rapid attempts'); }); let adminId, instructorId; await test('owner: create an admin-role and an instructor-role account', async () => { const a = await owner.request('POST', '/api/admin/instructors', { email: `admin-${RUN_ID}@rootnode.test`, display_name: 'E2E Admin', role: 'admin', password: 'AdminPassword123' }); assert.equal(a.status, 201); adminId = a.body.instructor_id; const i = await owner.request('POST', '/api/admin/instructors', { email: `teach-${RUN_ID}@rootnode.test`, display_name: 'E2E Teacher', role: 'instructor', password: 'TeacherPassword123' }); assert.equal(i.status, 201); instructorId = i.body.instructor_id; }); const teacher = makeSession(GW); await test('RBAC: instructor role rejected from owner-only routes', async () => { await teacher.request('POST', '/api/auth/instructor/login', { email: `teach-${RUN_ID}@rootnode.test`, password: 'TeacherPassword123' }); const { status } = await teacher.request('GET', '/api/admin/instructors'); assert.equal(status, 403); }); await test('RBAC: instructor role rejected from admin/owner cohort-creation route', async () => { const { status } = await teacher.request('POST', '/api/admin/cohorts', { cohort_id: `teacher-should-fail-${RUN_ID}`, name: 'nope' }); assert.equal(status, 403); }); const admin = makeSession(GW); await test('RBAC: admin role can create cohorts (below owner, above instructor)', async () => { await admin.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }); const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: `admin-ok-${RUN_ID}`, name: 'Admin OK' }); assert.equal(status, 201); }); await test('RBAC: admin role rejected from owner-only instructor management', async () => { const { status } = await admin.request('GET', '/api/admin/instructors'); assert.equal(status, 403); }); const scopedCohort = `scoped-${RUN_ID}`; const unscopedCohort = `unscoped-${RUN_ID}`; await test('cohort scoping: owner creates two cohorts, assigns the instructor to only one', async () => { assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: scopedCohort, name: 'Scoped' })).status, 201); assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: unscopedCohort, name: 'Unscoped' })).status, 201); assert.equal((await owner.request('POST', `/api/admin/cohorts/${scopedCohort}/instructors`, { instructor_id: instructorId })).status, 200); }); await test('cohort scoping: instructor sees the assigned cohort, not the other', async () => { const { body } = await teacher.request('GET', '/api/admin/cohorts'); const ids = body.map((c) => c.cohort_id); assert.ok(ids.includes(scopedCohort)); assert.ok(!ids.includes(unscopedCohort)); }); await test('cohort scoping: instructor allowed on the assigned cohort, 403 on the other', async () => { assert.equal((await teacher.request('GET', `/api/admin/cohorts/${scopedCohort}/students`)).status, 200); assert.equal((await teacher.request('GET', `/api/admin/cohorts/${unscopedCohort}/students`)).status, 403); }); await test('owner-safety: cannot deactivate the last active owner', async () => { const { body: whoami } = await owner.request('GET', '/api/auth/session'); const { status, body } = await owner.request('PATCH', `/api/admin/instructors/${whoami.instructor.instructor_id}`, { status: 'inactive' }); assert.equal(status, 400); assert.equal(body.error, 'last_owner'); }); await test('role/status changes take effect on the very next request, no separate revoke needed', async () => { assert.equal((await teacher.request('GET', '/api/admin/dashboard')).status, 200); // works before deactivation await owner.request('PATCH', `/api/admin/instructors/${instructorId}`, { status: 'inactive' }); const { status } = await teacher.request('GET', '/api/admin/dashboard'); assert.equal(status, 401); }); await test('password change: old password rejected afterward, other sessions revoked', async () => { const second = makeSession(GW); await second.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }); await admin.request('POST', '/api/auth/change-password', { old_password: 'AdminPassword123', new_password: 'NewAdminPassword456' }); const stillOld = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }) }); assert.equal(stillOld.status, 401); const newWorks = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'NewAdminPassword456' }) }); assert.equal(newWorks.status, 200); // The OTHER session (logged in before the change) should now be revoked. const { status } = await second.request('GET', '/api/auth/session'); assert.equal(status, 401); }); await test('audit log: security events were actually recorded', async () => { const { body } = await owner.request('GET', '/api/admin/audit-events'); const actions = body.map((e) => e.action); assert.ok(actions.includes('login')); assert.ok(actions.includes('authz_denied')); assert.ok(actions.includes('instructor_update')); assert.ok(body.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material'); }); // ── Student session hardening ────────────────────────────────────────────────────────── let studentId; await test('roster: create a student for session tests', async () => { const { status, body } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-Session-${RUN_ID}` }); assert.equal(status, 201); studentId = body.student_id; }); const student = makeSession(GW); await test('student session: cannot call admin routes', async () => { await student.request('POST', '/api/login', { student_id: studentId }); const { status } = await student.request('GET', '/api/admin/dashboard'); assert.equal(status, 401); }); await test('student session: logout revokes, curriculum then rejected', async () => { assert.equal((await student.request('GET', '/api/curriculum/path')).status, 200); await student.request('POST', '/api/logout'); assert.equal((await student.request('GET', '/api/curriculum/path')).status, 401); }); // ── Session persistence across a real restart ────────────────────────────────────────── if (!SKIP_RESTART_TEST) { await test('session persistence: instructor AND student sessions survive a Platform API restart', async () => { const persistOwner = makeSession(GW); await persistOwner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); const persistStudent = makeSession(GW); await persistStudent.request('POST', '/api/login', { student_id: studentId }); execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' }); // Give the container a moment to re-apply migrations and start listening again. let up = false; for (let i = 0; i < 30 && !up; i++) { await new Promise((r) => setTimeout(r, 1000)); try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ } } assert.ok(up, 'Platform API did not come back up in time'); assert.equal((await persistOwner.request('GET', '/api/auth/session')).status, 200); assert.equal((await persistStudent.request('GET', '/api/curriculum/path')).status, 200); }); } else { console.log(' (skipped) session persistence: restart test — SKIP_RESTART_TEST=1'); } console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });