diff --git a/infra/scripts/drills/run-phase5a-rehearsal.sh b/infra/scripts/drills/run-phase5a-rehearsal.sh new file mode 100755 index 0000000..4345820 --- /dev/null +++ b/infra/scripts/drills/run-phase5a-rehearsal.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 5A §30/§33 dress rehearsal: brings up the real dev stack, bootstraps +# an owner, runs test-phase5a-rehearsal.js, tears down. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform" + +export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}" + +docker compose -f docker-compose.dev.yml up -d --build + +cleanup() { + echo "==> Tearing down" + docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Waiting for the stack" +for _ in $(seq 1 60); do + curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break + sleep 2 +done + +echo "==> Bootstrapping the rehearsal owner" +( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \ + node bootstrap-owner.js p5a-rehearsal-owner@rootnode.test "P5A Rehearsal Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p5a-rehearsal-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase5a-rehearsal.js diff --git a/infra/scripts/drills/test-phase5a-rehearsal.js b/infra/scripts/drills/test-phase5a-rehearsal.js new file mode 100755 index 0000000..926ed38 --- /dev/null +++ b/infra/scripts/drills/test-phase5a-rehearsal.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous +// narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the +// real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real +// progress/attempt/achievement history, joins a live classroom, sends a help request, and the +// Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two +// of the five are then converted to real credentialed accounts and their entire prior history +// (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual +// migration-safety check §33 asks for, run against a live stack rather than asserted in the +// abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js). +// +// Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first. + +const assert = require('assert'); +const { execSync } = require('child_process'); +const { makeSession } = require('../../../platform/test/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'); process.exit(1); } +const RUN_ID = Date.now(); +const COHORT_ID = `p5a-rehearsal-${RUN_ID}`; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); console.log(` ok — ${name}`); passed++; } + catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; } +} + +// Seeds one realistic progress/attempt/achievement record for a pilot student directly in +// Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full +// Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js. +function seedHistory(studentId) { + const sql = ` + INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at) + VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days'); + INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count) + VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4); + INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id) + VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations'); + `; + execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`, + { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }); +} + +async function main() { + console.log('Phase 5A student identity / remote classroom dress rehearsal\n'); + + const instructor = makeSession(GW); + await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + + const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez']; + const students = {}; // name -> student_id + const sessions = {}; // name -> pilot login session + + await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => { + await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' }); + for (const name of names) { + const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID }); + assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`); + students[name] = body.student_id; + } + }); + + await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => { + for (const name of names) { + sessions[name] = makeSession(GW); + const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] }); + assert.equal(status, 200); + } + }); + + await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => { + seedHistory(students['Reh-Ada']); + seedHistory(students['Reh-Bo']); + }); + + let classroomId; + await step('instructor: create and start a live classroom for the cohort', async () => { + const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' }); + classroomId = body.classroom_session_id; + const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`); + assert.equal(status, 200); + }); + + await step('all 5 students join the classroom and heartbeat', async () => { + for (const name of names) { + const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`); + assert.equal(status, 200); + await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`); + } + }); + + await step('Reh-Cy sends a help request; instructor acknowledges it', async () => { + const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' }); + assert.equal(status, 201); + const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); + const req = dash.help_requests.find((h) => h.status === 'open'); + assert.ok(req); + await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`); + }); + + await step('instructor posts an announcement and sets current activity', async () => { + await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' }); + await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' }); + }); + + // ── The restart: the whole point of §30's "prove recovery" requirement. ───────────────── + await step('Platform API restarts mid-rehearsal', () => { + execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }); + }); + await new Promise((r) => setTimeout(r, 4000)); + + await step('after restart: the classroom is still live and every session still works, with no re-login', async () => { + for (const name of names) { + const { status } = await sessions[name].request('GET', `/api/classroom/live`); + assert.equal(status, 200); + } + const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); + assert.equal(dash.classroom.status, 'live'); + assert.equal(dash.students.length, 5); + assert.equal(dash.announcements.length, 1); + }); + + await step('after restart: heartbeats and help requests still work normally', async () => { + const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`); + assert.equal(status, 200); + }); + + // ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ─────────────── + let adaActivation, boActivation; + await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => { + const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` }); + const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` }); + assert.equal(a.status, 200); assert.equal(b.status, 200); + adaActivation = a.body.activation_token; boActivation = b.body.activation_token; + }); + + await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => { + const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] }); + assert.equal(adaRetry.status, 409); + const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] }); + assert.equal(cyStillWorks.status, 200); + }); + + let adaNewSession, boNewSession; + await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => { + adaNewSession = makeSession(GW); + boNewSession = makeSession(GW); + const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' }); + const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' }); + assert.equal(a.status, 200); assert.equal(b.status, 200); + }); + + await step('every bit of prior history survived the conversion — nothing orphaned', async () => { + const adaProgress = await adaNewSession.request('GET', '/api/progress'); + assert.equal(adaProgress.status, 200); + assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login'); + assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed'); + + const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`); + const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']); + assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created'); + + const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`); + assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion'); + }); + + await step('cleanup: end the classroom', async () => { + await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/platform/test/e2e-phase5a.js b/platform/test/e2e-phase5a.js new file mode 100644 index 0000000..0201f0e --- /dev/null +++ b/platform/test/e2e-phase5a.js @@ -0,0 +1,268 @@ +#!/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); });