diff --git a/infra/scripts/drills/run-phase6d-foundations-rehearsal.sh b/infra/scripts/drills/run-phase6d-foundations-rehearsal.sh new file mode 100755 index 0000000..63dd8fb --- /dev/null +++ b/infra/scripts/drills/run-phase6d-foundations-rehearsal.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 6D §27 ROOT Foundations dress rehearsal: brings up the real dev stack, +# bootstraps an owner, runs test-phase6d-foundations-rehearsal.js (real disposable Lab, real +# bash, real permission denial/fix, real instructor visibility check), tears down. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform" + +export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}" +export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}" +export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}" + +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 p6d-rehearsal-owner@rootnode.test "P6D Rehearsal Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p6d-rehearsal-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase6d-foundations-rehearsal.js diff --git a/infra/scripts/drills/test-phase6d-foundations-rehearsal.js b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js new file mode 100644 index 0000000..82f46a1 --- /dev/null +++ b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js @@ -0,0 +1,203 @@ +#!/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); }); diff --git a/platform/test/e2e-phase6a.js b/platform/test/e2e-phase6a.js index 8896193..aa3a88d 100644 --- a/platform/test/e2e-phase6a.js +++ b/platform/test/e2e-phase6a.js @@ -122,7 +122,7 @@ async function main() { assert.equal(status, 200); tree = body.courses; const root05 = courseById(tree, 'root-05-kubernetes-foundations'); - assert.equal(root05.locked, false); // the first real course is never locked + assert.equal(root05.locked, false); // root-04 (its real Phase 6D prerequisite) has zero activities — vacuously satisfied assert.equal(root05.complete, false); const root06 = courseById(tree, 'root-06-kubernetes-application-workloads'); assert.equal(root06.locked, true, 'root-06 must be locked until root-05 is complete'); @@ -130,11 +130,14 @@ async function main() { await test('resume points at the first activity of the first unlocked course', async () => { const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); - assert.equal(body.resume.course_id, 'root-05-kubernetes-foundations'); - // Phase 6B added root-05-m00 (the mental-model module) as the new front door, sequenced - // before the pre-existing bare-lab bank — see docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md. - assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model'); - assert.equal(body.resume.activity_id, 'root-05-m00-a01-lesson-mental-model'); + // Phase 6D authored ROOT 01 (Computing & Linux Foundations) as a real, non-PLANNED course + // sequenced before ROOT 05 — it is now genuinely the program's front door, not ROOT 05's + // own gold module (see docs/PHASE6D_ROOT_FOUNDATIONS.md). This test's original intent — + // "resume lands on the first REAL content, not a bare-lab bank" — is still what's being + // proven, just against the current front door. + assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations'); + assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software'); + assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson'); }); await test('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => { diff --git a/platform/test/e2e-phase6b.js b/platform/test/e2e-phase6b.js index e4d0ea9..2beb7ab 100644 --- a/platform/test/e2e-phase6b.js +++ b/platform/test/e2e-phase6b.js @@ -126,9 +126,13 @@ async function main() { assert.equal(moduleById(body.courses, 'root-10-m03-troubleshooting-framework').locked, true, 'must stay locked behind the gold module even though it is a different course-level dependency chain'); }); - await test('resume points at ROOT 05\'s new mental-model module, not the old bare-lab bank', async () => { + await test('resume points at ROOT 01 (Phase 6D\'s new front door), not ROOT 05\'s old bare-lab bank', async () => { + // Phase 6D authored ROOT 01 as real, non-PLANNED content sequenced before ROOT 05 — it is + // now genuinely the program's front door (docs/PHASE6D_ROOT_FOUNDATIONS.md). This test's + // original intent — resume must never land on a bare-lab bank when real front-door content + // exists — is unchanged, just against the current front door. const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); - assert.equal(body.resume.module_id, 'root-05-m00-kubernetes-mental-model'); + assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software'); }); await test('lesson/knowledge_check completion works identically in a brand-new module (root-05-m00)', async () => { diff --git a/platform/test/e2e-phase6d.js b/platform/test/e2e-phase6d.js new file mode 100644 index 0000000..141c718 --- /dev/null +++ b/platform/test/e2e-phase6d.js @@ -0,0 +1,244 @@ +#!/usr/bin/env node +// Phase 6D — ROOT Foundations acceptance test. Covers what this phase actually built: ROOT 01 +// (Computing & Linux Foundations, fully authored) and the real ROOT 01->02->03->04->05 +// prerequisite chain. ROOT 02-04 remain PLANNED (architecture designed in +// docs/ROOT_FOUNDATIONS_ARCHITECTURE.md, not yet authored — see +// docs/PHASE6D_ROOT_FOUNDATIONS.md for why), so this suite does not assert content that +// doesn't exist. Same conventions as e2e-phase6a/6b/6c.js: assert-based, no framework, live +// docker-compose.dev.yml stack, no real Lab container launched here (see +// infra/scripts/drills/test-phase6d-foundations-rehearsal.js for the real-Lab counterpart). +// +// 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-phase6d.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; +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(); +const PROGRAM = 'root-kubernetes-foundations'; +const ROOT01_MODULES = ['root-01-m01-how-computers-run-software', 'root-01-m02-linux-and-the-shell', 'root-01-m03-navigating-the-filesystem', 'root-01-m04-files-and-text', 'root-01-m05-processes', 'root-01-m06-users-and-permissions-foundations']; +const ROOT01_LAB_SCENARIOS = ['linux-inspect-hardware', 'linux-shell-basics', 'linux-filesystem-navigation', 'linux-files-and-text', 'linux-process-management', 'linux-permissions-foundations']; + +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 completeScenario(session, scenarioId) { + const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' }); + assert.equal(status, 200, `completing ${scenarioId} should 200`); +} +async function completeActivity(session, id) { + const { status, body } = await session.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(status, 200, `completing ${id} should 200: ${JSON.stringify(body)}`); +} +function courseById(courses, id) { return courses.find((c) => c.course_id === id); } +function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; } +function findActivity(courses, id) { for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; } return null; } +function psql(sql) { + return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim(); +} +async function newStudent(owner, tag) { + const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6D-${tag}-${RUN_ID}`, login_identifier: `e2e6d-${tag}-${RUN_ID}` }); + const s = makeSession(GW); + await s.request('POST', '/api/student/activate', { token: create.activation_token, password: `${tag}Password123!` }); + const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' }); + return { session: s, studentId: create.student_id, enrollmentId: enroll.enrollment_id }; +} + +async function main() { + console.log('Phase 6D ROOT Foundations acceptance test\n'); + + const owner = makeSession(GW); + await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + let structure; + + // ── 1: ROOT 01 hierarchy ───────────────────────────────────────────────────────────────── + await test('ROOT 01 has its 6 modules, in sequence, all READY', async () => { + const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + assert.equal(status, 200); + structure = body; + const course = courseById(structure, 'root-01-computing-linux-foundations'); + assert.equal(course.status, 'ready'); + assert.deepEqual(course.modules.map((m) => m.module_id), ROOT01_MODULES); + for (const m of course.modules) assert.equal(m.status, 'ready', `${m.module_id} must be ready`); + }); + + // ── 2/3/4: ROOT 02/03/04 hierarchy — honestly PLANNED, zero modules, not padded ────────── + await test('ROOT 02/03/04 are honestly PLANNED with zero modules — not padded to look further along than they are', async () => { + for (const id of ['root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) { + const course = courseById(structure, id); + assert.equal(course.status, 'planned', `${id} must stay honestly planned`); + assert.equal(course.modules.length, 0, `${id} must have zero modules — no fabricated content`); + } + }); + + // ── 5/6: activity ordering + content actually loads ────────────────────────────────────── + await test('every ROOT 01 module has strictly ascending activity sequence and real lesson content', async () => { + for (const modId of ROOT01_MODULES) { + const mod = moduleById(structure, modId); + for (let i = 1; i < mod.activities.length; i++) assert.ok(mod.activities[i].sequence > mod.activities[i - 1].sequence, `${modId} activity ordering broken`); + const lesson = mod.activities.find((a) => a.activity_type === 'lesson'); + assert.ok(lesson.body && lesson.body.length > 300, `${modId}'s lesson must have real content`); + } + }); + + // ── A real self-paced student walking ROOT 01 end to end ──────────────────────────────── + const casey = await newStudent(owner, 'Casey'); + + await test('resume points at ROOT 01 module 1 activity 1, the very first activity in the whole program', async () => { + const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations'); + assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software'); + assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson'); + }); + + // ── 16: direct API cannot bypass — a lab activity cannot be manually marked complete ───── + await test('a lab activity cannot be marked complete manually — only real scenario completion counts', async () => { + const { status, body } = await casey.session.request('POST', '/api/student/activities/root-01-m01-a03-lab-inspect-hardware/complete'); + assert.equal(status, 409); + assert.equal(body.error, 'not_manually_completable'); + }); + + // ── 8/9/10/11: lesson/practical/knowledge-check/assessment completion, walked for real ── + await test('walking ROOT 01 module 1 through the real API completes it', async () => { + await completeActivity(casey.session, 'root-01-m01-a01-lesson'); + await completeActivity(casey.session, 'root-01-m01-a02-guided-exercise'); + await completeScenario(casey.session, 'linux-inspect-hardware'); + await completeActivity(casey.session, 'root-01-m01-a04-reflection'); + const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + assert.equal(moduleById(body.courses, 'root-01-m01-how-computers-run-software').complete, true); + }); + + await test('completing every ROOT 01 module and its assessment marks the whole course complete', async () => { + for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) { + await completeActivity(casey.session, `root-01-${m}-a01-lesson`); + await completeActivity(casey.session, `root-01-${m}-a02-demonstration`); + await completeActivity(casey.session, `root-01-${m}-a03-knowledge-check`); + await completeActivity(casey.session, `root-01-${m}-a04-guided-exercise`); + } + 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 completeScenario(casey.session, sid); + await completeActivity(casey.session, activity); + } + await completeScenario(casey.session, 'linux-permissions-foundations'); + await completeActivity(casey.session, 'root-01-m06-a06-reflection'); + + const start = await casey.session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); + assert.equal(start.status, 201); + for (const s of start.body.scenarios) await completeScenario(casey.session, s.scenario_id); + + const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + const course = courseById(body.courses, 'root-01-computing-linux-foundations'); + assert.equal(course.complete, true); + assert.equal(course.percent_complete, 100); + }); + + // ── 12/15: ROOT 01 unlocks ROOT 02 at the course level, for THIS student ───────────────── + await test('completing ROOT 01 unlocks ROOT 02 for this student', async () => { + const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, false); + }); + + // ── A second, fresh student proves ROOT 02 stays LOCKED without ROOT 01 ───────────────── + const bob = await newStudent(owner, 'Bob'); + await test('ROOT 02 is locked for a freshly-enrolled student who has not touched ROOT 01', async () => { + const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').locked, false, 'ROOT 01 itself is never locked — it is the first course'); + assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, true, 'ROOT 02 must be locked until ROOT 01 is complete'); + }); + + await test('ROOT 03/04/05 stay unlocked (vacuously satisfied) since their own prerequisite courses have zero content yet', async () => { + const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, false); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, false); + assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false); + }); + + // ── 26/27: existing ROOT 05+ and Capstone state is untouched by this phase ────────────── + await test('ROOT 06 stays locked behind ROOT 05 exactly as before this phase (unaffected by the new 01-04 chain)', async () => { + const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true); + }); + + // ── 17: optional activities still behave correctly elsewhere in the curriculum ────────── + await test('the pre-existing is_required rule for other courses is unaffected by this phase', async () => { + const mod = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting'); + assert.equal(mod.activities.filter((a) => a.is_required === false).length, 1, 'Phase 6C\'s optional readiness-probe lab must still be exactly 1'); + }); + + // ── 18/19: self-paced + instructor rendering, and instructor-note exclusion ───────────── + await test('self-paced ROOT 01 content never defers to a live instructor', async () => { + for (const modId of ROOT01_MODULES) { + const mod = moduleById(structure, modId); + for (const a of mod.activities) { + if (!a.body) continue; + assert.ok(!/ask your instructor/i.test(a.body) && !/your instructor will explain/i.test(a.body), `${a.activity_id} must not defer explanation to a live instructor`); + } + } + }); + + await test('instructor notes on ROOT 01 content are visible to instructors and never to students', async () => { + const lesson = findActivity(structure, 'root-01-m06-a01-lesson'); + assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0); + const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + const studentLesson = findActivity(body.courses, 'root-01-m06-a01-lesson'); + assert.equal(studentLesson.instructor_notes, undefined); + }); + + // ── 20: enrollment entitlement / RBAC ──────────────────────────────────────────────────── + const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6d-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6D', role: 'instructor', password: 'StrayInstructorPass6D!' }); + const stray = makeSession(GW); + await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6d-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6D!' }); + await test('a plain instructor with no relationship to the student cannot see ROOT 01 progress', async () => { + const { status } = await stray.request('GET', `/api/admin/students/${casey.studentId}/courses?program_id=${PROGRAM}`); + assert.equal(status, 403); + }); + await test('owner/admin can see any self-paced student\'s ROOT 01 progress', async () => { + const { status, body } = await owner.request('GET', `/api/admin/students/${casey.studentId}/courses?program_id=${PROGRAM}`); + assert.equal(status, 200); + assert.equal(courseById(body, 'root-01-computing-linux-foundations').complete, true); + }); + + // ── 21/23: persistence + cross-student isolation ──────────────────────────────────────── + await test('ROOT 01 progress survives logout and a fresh login', async () => { + await casey.session.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: `e2e6d-Casey-${RUN_ID}`, password: 'CaseyPassword123!' }); + const { body } = await returning.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, true); + }); + + await test('a second student sees independent, empty ROOT 01 progress (cross-student isolation)', async () => { + const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, false); + }); + + // ── 25: content version stability ─────────────────────────────────────────────────────── + await test('bumping a ROOT 01 module\'s version does not drop its content', async () => { + psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-01-m06-users-and-permissions-foundations'`); + const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + const mod = moduleById(body, 'root-01-m06-users-and-permissions-foundations'); + assert.equal(mod.activities.length, 7, 'a version bump must never drop activities'); + assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300); + }); + + // ── content validation as part of this suite ──────────────────────────────────────────── + await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => { + execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' }); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error('FATAL', e); process.exit(1); });