Files
rootnode-academy/infra/scripts/drills/test-phase6d-foundations-rehearsal.js
T
ced4568andClaude Sonnet 5 f5379afd5c test(phase6d): acceptance suite, real-Lab rehearsal, and 6A/6B resume-point fixes
Adds platform/test/e2e-phase6d.js (20 checks): ROOT 01 hierarchy, ROOT 02-04 honestly empty
(not padded), activity ordering, lab-cannot-be-manually-completed, full course completion via
lesson/lab/assessment, the prerequisite chain proven on two students (one who completed ROOT 01,
one fresh), existing ROOT 05+/Capstone state unaffected, is_required unaffected, self-paced
no-instructor-deferral check, instructor-notes exclusion, RBAC, logout/login persistence,
cross-student isolation, version-bump stability, and content validation. 20/20 pass against both
a warm and a freshly-reset dev stack.

Adds infra/scripts/drills/{run,test}-phase6d-foundations-rehearsal — the real disposable-Lab
dress rehearsal: login -> enroll -> lesson/guided-exercise -> real Lab launch -> real bash
hardware inspection (nproc/free/df, written to a real file) -> real validation -> a genuine
permission denial (a second Linux user actually denied via `su -`) then a real chmod fix ->
remaining modules -> assessment -> course completion -> ROOT 02 unlock -> logout/login
persistence -> instructor visibility of the resulting learning state. 11/11 pass against a real
Lab container, no mocking. (First run caught a real triple-escaping bug in the hardware-report
step's inline heredoc — fixed by writing the script to a file and docker-cp-ing it in instead.)

Fixes two stale hardcoded assumptions in e2e-phase6a.js and e2e-phase6b.js that this phase's own
ROOT 01 addition broke: both suites asserted `resume` landed on ROOT 05's gold module as "the
first real content," which was true before this phase and is now ROOT 01. Both suites' original
intent (resume must never land on a bare-lab bank) is preserved, just against the current front
door. Both suites re-verified green (21/21 and 24/24) against a fresh stack.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 13:00:17 -05:00

204 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Phase 6D §27 — the ROOT Foundations dress rehearsal. One continuous realistic student
// narrative through ROOT 01 (Computing & Linux Foundations, fully authored this phase) using a
// REAL disposable student Lab end to end: login -> program -> course -> lesson -> guided
// exercise -> real shell interaction -> validated filesystem/process/permission activities ->
// progress persistence -> assessment -> ROOT 02 unlock state, then an instructor narrative
// confirming the resulting learning state is visible. ROOT 02-04 are not rehearsed here since
// they remain unauthored this phase (see docs/PHASE6D_ROOT_FOUNDATIONS.md).
//
// Unlike the ROOT 05+ rehearsals, this one never touches kubectl or asserts anything about
// Kubernetes — ROOT 01 is Linux/shell content, and the assertions below only ever exercise
// real bash/coreutils commands. (The Lab container's own boot sequence still starts k3s before
// signaling ready, same as every other course's Lab — that's an existing image-level fact this
// phase didn't change, not something ROOT 01's content depends on.)
//
// Run via run-phase6d-foundations-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 PROGRAM = 'root-kubernetes-foundations';
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++; }
}
function moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
function courseFrom(tree, id) { return tree.find((c) => c.course_id === id); }
async function main() {
console.log('Phase 6D ROOT Foundations dress rehearsal — real disposable Lab, no mocking\n');
const owner = makeSession(GW);
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const { body: created } = await owner.request('POST', '/api/admin/students', {
display_name: 'Rehearsal 6D Student', login_identifier: `p6d-foundations-${RUN_ID}`,
});
const session = makeSession(GW);
let enrollmentId, envId;
await step('student activates credentials and logs in', async () => {
const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' });
assert.equal(status, 200);
});
await step('an instructor enrolls the student in the ROOT_ program', async () => {
const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' });
assert.equal(status, 201);
enrollmentId = body.enrollment_id;
});
await step('resume points at ROOT 01\'s very first activity', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations');
assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson');
});
await step('student reads ROOT 01 module 1\'s lesson and guided exercise', async () => {
for (const suffix of ['a01-lesson', 'a02-guided-exercise']) {
const { status } = await session.request('POST', `/api/student/activities/root-01-m01-${suffix}/complete`);
assert.equal(status, 200);
}
});
await step('student launches a REAL disposable Lab', async () => {
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 200);
assert.equal(launch.body.status, 'running');
const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running');
envId = envRow.environment_id;
});
let labToken;
await step('student runs the linux-inspect-hardware Lab using REAL shell commands inside the real Lab container', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
const mod = moduleFrom(body.courses, 'root-01-m01-how-computers-run-software');
const lab = mod.activities.find((a) => a.scenario_id === 'linux-inspect-hardware');
assert.equal(lab.complete, false);
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1];
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/setup`);
assert.equal(setup.status, 200);
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
// Real bash, run inside the student's actual disposable Lab container — exactly the
// commands the module's lesson/hints teach, not a simulated pass. Written to a script FILE
// on the host and copied in, rather than an inline heredoc, to sidestep JS-template/shell/
// heredoc triple-escaping entirely — a real, reproducible failure hit on the first run of
// this rehearsal (see the fix noted in docs/PHASE6D_ROOT_FOUNDATIONS.md).
const fs = require('fs');
const scriptPath = `/tmp/phase6d-hardware-report-${envId}.sh`;
fs.writeFileSync(scriptPath, [
'#!/bin/bash',
'set -e',
'mkdir -p /srv/hardware-lab',
'CPUS=$(nproc)',
"MEM_MB=$(free -m | awk '/Mem:/ {print $2}')",
'DISK_GB=$(df -BG / | awk \'NR==2{gsub("G","",$2); print $2}\')',
'printf "CPUS=%s\\nMEM_MB=%s\\nDISK_GB=%s\\n" "$CPUS" "$MEM_MB" "$DISK_GB" > /srv/hardware-lab/report.txt',
].join('\n'));
execSync(`docker cp ${scriptPath} ${containerName}:/tmp/hardware-report.sh && docker exec ${containerName} bash /tmp/hardware-report.sh`, { stdio: 'pipe' });
fs.unlinkSync(scriptPath);
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-inspect-hardware/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'linux-inspect-hardware', status: 'completed', environment_id: envId });
assert.equal(record.status, 200);
});
await step('student runs the linux-permissions-foundations Lab, including a real permission denial and fix', async () => {
const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-permissions-foundations/setup`);
assert.equal(setup.status, 200);
const containerName = execSync(
`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
// Confirm the real denial exists before fixing it — this is the actual pedagogical point.
let denied = false;
try { execSync(`docker exec ${containerName} su - labuser -c "cat /srv/perm-lab/data.txt"`, { stdio: 'pipe' }); }
catch (e) { denied = /Permission denied/.test(e.stderr ? e.stderr.toString() : ''); }
assert.equal(denied, true, 'expected a REAL permission denial before the fix, not an assumption');
execSync(`docker exec ${containerName} chmod 644 /srv/perm-lab/data.txt`, { stdio: 'pipe' });
const afterFix = execSync(`docker exec ${containerName} su - labuser -c "cat /srv/perm-lab/data.txt"`, { stdio: 'pipe' }).toString().trim();
assert.equal(afterFix, 'sensitive', 'labuser must actually be able to read the file after the real chmod fix');
const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-permissions-foundations/validate`);
assert.equal(validate.status, 200);
assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`);
const record = await session.request('POST', '/api/progress/record', { scenario_id: 'linux-permissions-foundations', status: 'completed', environment_id: envId });
assert.equal(record.status, 200);
});
await step('completing the remaining ROOT 01 activities finishes the course and unlocks ROOT 02', async () => {
await session.request('POST', '/api/student/activities/root-01-m01-a04-reflection/complete');
for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) {
for (const suffix of ['a01-lesson', 'a02-demonstration', 'a03-knowledge-check', 'a04-guided-exercise']) {
await session.request('POST', `/api/student/activities/root-01-${m}-${suffix}/complete`);
}
}
for (const [sid, activity] of [['linux-shell-basics', 'root-01-m02-a06-reflection'], ['linux-filesystem-navigation', 'root-01-m03-a06-reflection'], ['linux-files-and-text', 'root-01-m04-a06-reflection'], ['linux-process-management', 'root-01-m05-a06-reflection']]) {
await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId });
await session.request('POST', `/api/student/activities/${activity}/complete`);
}
await session.request('POST', '/api/student/activities/root-01-m06-a06-reflection/complete');
const start = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start');
assert.equal(start.status, 201);
for (const s of start.body.scenarios) {
const r = await session.request('POST', '/api/progress/record', { scenario_id: s.scenario_id, status: 'completed', environment_id: envId });
assert.equal(r.status, 200);
}
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseFrom(body.courses, 'root-01-computing-linux-foundations').complete, true);
assert.equal(courseFrom(body.courses, 'root-02-linux-administration-networking').locked, false, 'ROOT 02 must unlock now (though it has no content of its own yet)');
});
await step('logout, login — ROOT 01 progress survives all of it', async () => {
await session.request('POST', '/api/logout');
const returning = makeSession(GW);
await returning.request('POST', '/api/student/login', { identifier: `p6d-foundations-${RUN_ID}`, password: 'RehearsalPass123!' });
const { body } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseFrom(body.courses, 'root-01-computing-linux-foundations').complete, true, 'progress must survive a real logout/login');
});
// ── Instructor narrative — confirming the resulting learning state is visible ───────────
await step('instructor inspects the ROOT 01 module structure and its instructor notes', async () => {
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
assert.equal(status, 200);
const mod = moduleFrom(body, 'root-01-m06-users-and-permissions-foundations');
assert.equal(mod.activities.length, 7);
assert.ok(mod.activities[0].instructor_notes, 'instructor must see instructor notes the student view never does');
});
await step('instructor sees this specific student\'s real, completed ROOT 01 progress', async () => {
const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`);
assert.equal(status, 200);
assert.equal(courseFrom(body, 'root-01-computing-linux-foundations').complete, true);
});
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
main().catch((e) => { console.error(e); process.exit(1); });