Files
rootnode-academy/infra/scripts/drills/test-phase5a-rehearsal.js
T
ced4568 bfa16d9162 test(phase5a): permanent regression suite + dress rehearsal drill
- platform/test/e2e-phase5a.js (21 checks): the two-student RBAC matrix (student can't reach
  any /api/admin/* route, assigned/unassigned/viewer instructor access) and the full password
  security checklist (correct/wrong/unknown-identifier/deactivated/immediate-deactivation/
  rate-limit/rate-limit-survives-a-real-restart/change-password/session-revocation/
  forgot-password anti-enumeration/admin-issued reset/CSRF), plus a classroom lifecycle smoke
  pass. Replaces the throwaway smoke5a/smoke5b scripts used during development.
- infra/scripts/drills/test-phase5a-rehearsal.js (15 checks): 5 pilot students with real
  seeded pre-existing progress/attempt/achievement history, a live classroom, help requests,
  announcements, a REAL Platform API restart mid-rehearsal proving classroom/session/presence
  recovery, then converting two students mid-rehearsal and confirming zero data was orphaned.
- Both run against a live docker-compose.dev.yml stack over real HTTP, not mocks.
2026-08-25 01:20:02 -05:00

179 lines
10 KiB
JavaScript
Executable File

#!/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); });