From f5379afd5cfe7cd2a06686e9f58e0a30151e3206 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Wed, 26 Aug 2026 13:00:17 -0500 Subject: [PATCH] test(phase6d): acceptance suite, real-Lab rehearsal, and 6A/6B resume-point fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../run-phase6d-foundations-rehearsal.sh | 34 +++ .../test-phase6d-foundations-rehearsal.js | 203 +++++++++++++++ platform/test/e2e-phase6a.js | 15 +- platform/test/e2e-phase6b.js | 8 +- platform/test/e2e-phase6d.js | 244 ++++++++++++++++++ 5 files changed, 496 insertions(+), 8 deletions(-) create mode 100755 infra/scripts/drills/run-phase6d-foundations-rehearsal.sh create mode 100644 infra/scripts/drills/test-phase6d-foundations-rehearsal.js create mode 100644 platform/test/e2e-phase6d.js 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); });