#!/usr/bin/env node // Phase 5A — student identity & remote classroom. Permanent regression test replacing the // throwaway smoke5a/smoke5b scripts used during development. Covers §31 (two-student + RBAC // matrix) and §32 (password security), plus a real classroom lifecycle smoke pass. Same // conventions as e2e-phase3a.js: assert-based, no framework, runs against a live // docker-compose.dev.yml stack, shells out for the restart-persistence test. // // 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-phase5a.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 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 5A student identity / remote classroom acceptance test\n'); const owner = makeSession(GW); await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.ok(owner.hasCookie('rn_instructor'), 'owner login must succeed before anything else can run'); // ── Fixtures: two cohorts, an assigned instructor, an unassigned instructor, a viewer, // two students (Alice/Bob) in cohort A. ──────────────────────────────────────────────── const cohortA = `p5a-cohort-a-${RUN_ID}`; const cohortB = `p5a-cohort-b-${RUN_ID}`; await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortA, name: 'P5A Cohort A' }); await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortB, name: 'P5A Cohort B' }); const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', { email: `p5a-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned Instructor', role: 'instructor', password: 'AssignedPass123!', }); const { body: unassignedInstr } = await owner.request('POST', '/api/admin/instructors', { email: `p5a-unassigned-${RUN_ID}@rootnode.test`, display_name: 'Unassigned Instructor', role: 'instructor', password: 'UnassignedPass123!', }); await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: assignedInstr.instructor_id, role_in_cohort: 'teacher' }); const { body: viewerInstr } = await owner.request('POST', '/api/admin/instructors', { email: `p5a-viewer-${RUN_ID}@rootnode.test`, display_name: 'Viewer Instructor', role: 'instructor', password: 'ViewerPass123!', }); await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: viewerInstr.instructor_id, role_in_cohort: 'viewer' }); const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', { display_name: 'Alice', cohort_id: cohortA, login_identifier: `p5a-alice-${RUN_ID}`, }); const { body: bobCreate } = await owner.request('POST', '/api/admin/students', { display_name: 'Bob', cohort_id: cohortA, login_identifier: `p5a-bob-${RUN_ID}`, }); const alicePassword = 'AlicePassword123!'; const bobPassword = 'BobPassword123!'; const alice = makeSession(GW); const bob = makeSession(GW); await test('activation: token sets password and logs the student in', async () => { const { status, body } = await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: alicePassword }); assert.equal(status, 200); assert.equal(body.student.display_name, 'Alice'); assert.ok(alice.hasCookie('rn_session')); }); await test('activation: the same token cannot be used twice (single-use)', async () => { const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'SomeOtherPass123!' }); assert.equal(status, 400); }); await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: bobPassword }); const { body: classroomCreate } = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'P5A Live Class' }); const classroomId = classroomCreate.classroom_session_id; // ── §32 Password security ─────────────────────────────────────────────────────────────── console.log('\nPassword security (§32)'); await test('correct identifier + password logs in', async () => { const s = makeSession(GW); const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword }); assert.equal(status, 200); }); await test('wrong password is rejected generically', async () => { const s = makeSession(GW); const { status, body } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' }); assert.equal(status, 401); assert.equal(body.error, 'invalid_credentials'); }); await test('unknown identifier fails with the SAME response as a wrong password (no enumeration)', async () => { const s1 = makeSession(GW), s2 = makeSession(GW); const unknown = await s1.request('POST', '/api/student/login', { identifier: `nobody-${RUN_ID}`, password: 'whatever12345' }); const wrongPw = await s2.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' }); assert.equal(unknown.status, wrongPw.status); assert.deepEqual(unknown.body, wrongPw.body); }); await test('deactivated account is blocked at login', async () => { await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' }); const s = makeSession(GW); const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword }); assert.equal(status, 403); await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' }); // restore for later tests }); await test('deactivation takes effect immediately on an EXISTING session, not just at next login', async () => { const s = makeSession(GW); await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword }); const before = await s.request('GET', '/api/student/profile'); assert.equal(before.status, 200); await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' }); const after = await s.request('GET', '/api/student/profile'); assert.notEqual(after.status, 200); // studentSession middleware re-checks status='active' every request await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' }); }); await test('rate limiting: repeated failures against the same identifier eventually 429s', async () => { const key = `p5a-ratelimit-${RUN_ID}`; let sawTooMany = false; for (let i = 0; i < 12 && !sawTooMany; i++) { const s = makeSession(GW); const { status } = await s.request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' }); if (status === 429) sawTooMany = true; } assert.ok(sawTooMany, 'expected a 429 within 12 attempts (LOGIN_MAX_ATTEMPTS=8)'); }); if (!SKIP_RESTART_TEST) { await test('rate limit state survives a Platform API restart (Postgres-backed, not in-memory)', async () => { const key = `p5a-ratelimit-restart-${RUN_ID}`; for (let i = 0; i < 9; i++) { await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' }); } execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' }); await new Promise((r) => setTimeout(r, 4000)); const { status } = await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' }); assert.equal(status, 429, 'the attempt count must have survived the restart'); }); } else { console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)'); } await test('change-password: wrong current password is rejected', async () => { const { status } = await alice.request('POST', '/api/student/change-password', { old_password: 'not-the-real-password', new_password: 'NewAlicePass123!' }); assert.equal(status, 401); }); await test('change-password: correct current password succeeds and revokes OTHER sessions', async () => { const otherSession = makeSession(GW); await otherSession.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword }); const { status } = await alice.request('POST', '/api/student/change-password', { old_password: alicePassword, new_password: 'NewAlicePass123!' }); assert.equal(status, 200); const { status: otherStatus } = await otherSession.request('GET', '/api/student/profile'); assert.notEqual(otherStatus, 200); // the OTHER session (not the one that made the change) is revoked const { status: selfStatus } = await alice.request('GET', '/api/student/profile'); assert.equal(selfStatus, 200); // the session that made the change is untouched }); await test('forgot-password: identical generic response for a real vs unknown identifier (anti-enumeration)', async () => { const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5a-alice-${RUN_ID}` }); const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}` }); assert.equal(real.body.message, fake.body.message); }); await test('admin-issued reset token: sets a new password and revokes ALL sessions', async () => { const { body } = await owner.request('POST', `/api/admin/students/${aliceCreate.student_id}/reset-password`); assert.ok(body.reset_token); const preCheck = await alice.request('GET', '/api/student/profile'); assert.notEqual(preCheck.status, 200); // reset-password revokes existing sessions immediately const fresh = makeSession(GW); const { status } = await fresh.request('POST', '/api/student/reset-password', { token: body.reset_token, password: 'ResetAlicePass123!' }); assert.equal(status, 200); const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'ResetAlicePass123!' }); assert.equal(login.status, 200); }); await test('CSRF: a mutating request without the header is rejected even with a valid session cookie', async () => { const s = makeSession(GW); await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword }); const res = await fetch(`${GW}/api/student/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${s.getCookie('rn_session')}` }, body: JSON.stringify({ old_password: bobPassword, new_password: 'ShouldNotWork123!' }), }); assert.notEqual(res.status, 200); }); // ── §31 Two-student + RBAC matrix ─────────────────────────────────────────────────────── console.log('\nTwo-student and RBAC matrix (§31)'); const bobSession = makeSession(GW); await bobSession.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword }); await test('a student session cannot reach ANY /api/admin/* route', async () => { const { status } = await bobSession.request('GET', '/api/admin/students'); assert.ok([401, 403].includes(status)); }); await test('a student profile request only ever returns that student\'s own identity', async () => { const { body } = await bobSession.request('GET', '/api/student/profile'); assert.equal(body.student.display_name, 'Bob'); }); await test('assigned instructor CAN view and manage cohort A (students, classroom)', async () => { const assigned = makeSession(GW); await assigned.request('POST', '/api/auth/instructor/login', { email: `p5a-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' }); const view = await assigned.request('GET', `/api/admin/cohorts/${cohortA}/students`); assert.equal(view.status, 200); const write = await assigned.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Assigned-created class' }); assert.equal(write.status, 201); }); await test('instructor NOT assigned to cohort A is DENIED (403) on its resources', async () => { const unassigned = makeSession(GW); await unassigned.request('POST', '/api/auth/instructor/login', { email: `p5a-unassigned-${RUN_ID}@rootnode.test`, password: 'UnassignedPass123!' }); const view = await unassigned.request('GET', `/api/admin/cohorts/${cohortA}/students`); assert.equal(view.status, 403); const dashboard = await unassigned.request('GET', `/api/admin/classroom-sessions/${classroomId}`); assert.equal(dashboard.status, 403); }); await test('viewer role_in_cohort CAN view but is DENIED mutation on cohort A', async () => { const viewer = makeSession(GW); await viewer.request('POST', '/api/auth/instructor/login', { email: `p5a-viewer-${RUN_ID}@rootnode.test`, password: 'ViewerPass123!' }); const view = await viewer.request('GET', `/api/admin/classroom-sessions/${classroomId}`); assert.equal(view.status, 200); const write = await viewer.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'should be denied' }); assert.equal(write.status, 403); }); // ── Classroom lifecycle smoke pass ────────────────────────────────────────────────────── console.log('\nClassroom lifecycle'); await test('classroom: start -> join -> heartbeat -> dashboard reflects presence -> end', async () => { const { status: startStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`); assert.equal(startStatus, 200); await bobSession.request('POST', `/api/classroom/${classroomId}/join`); await bobSession.request('POST', `/api/classroom/${classroomId}/heartbeat`); const { body: dash } = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}`); const bobRow = dash.students.find((s) => s.student_id === bobCreate.student_id); assert.ok(bobRow, 'Bob must appear as a participant after joining'); assert.equal(bobRow.presence, 'online'); const { status: endStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`); assert.equal(endStatus, 200); }); await test('classroom: only one LIVE classroom per cohort at a time', async () => { const c2 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Second class' }); await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/start`); const c3 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Third class' }); const { status } = await owner.request('POST', `/api/admin/classroom-sessions/${c3.body.classroom_session_id}/start`); assert.equal(status, 409); await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/end`); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });