#!/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'; // Phase 6F prepended a required orientation module (root-01-m00-welcome-to-root) — see // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. const ROOT01_MODULES = ['root-01-m00-welcome-to-root', '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 existed honestly PLANNED at the end of Phase 6D, and are real, authored courses as of Phase 6E', async () => { // Phase 6D deliberately built only ROOT 01 and left 02-04 honestly empty — Phase 6E (see // docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md) authored them for real. This assertion is // updated to reflect that intentional, documented progression rather than re-asserting a // fact Phase 6E was explicitly built to change. 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, 'ready', `${id} should now be ready (Phase 6E)`); assert.ok(course.modules.length > 0, `${id} should now have real modules (Phase 6E)`); } }); // ── 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 0 activity 1, the very first activity in the whole program', async () => { // Phase 6F added a required orientation module (root-01-m00-welcome-to-root) in front of // what was previously the first module — see docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. 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-m00-welcome-to-root'); assert.equal(body.resume.activity_id, 'root-01-m00-a01-lesson-welcome'); }); await test('walking the ROOT_ V1 orientation module completes it and unlocks module 1', async () => { await completeActivity(casey.session, 'root-01-m00-a01-lesson-welcome'); await completeActivity(casey.session, 'root-01-m00-a02-lesson-disposable-lab'); await completeActivity(casey.session, 'root-01-m00-a03-knowledge-check'); const { body } = await casey.session.request('GET', `/api/student/programs/${casey.enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-01-m00-welcome-to-root').complete, true); assert.equal(moduleById(body.courses, 'root-01-m01-how-computers-run-software').locked, false); }); // ── 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 () => { // ROOT 01's own modules carry real intra-course prerequisites (m02 requires m01 complete, // etc.) — each module must be completed fully, including its lab and reflection, before the // next module unlocks. Completing every module's early activities first and circling back // for labs/reflections later (the original order here) trips the module-lock check. const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' }; 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`); await completeScenario(casey.session, labs[m]); await completeActivity(casey.session, `root-01-${m}-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 are now genuinely locked for a fresh student (Phase 6E gave 02/03/04 real content)', async () => { // At the end of Phase 6D this was true via vacuous satisfaction (02-04 had zero activities). // Phase 6E authored real content for 02-04, so the SAME chain now genuinely gates for real — // this assertion is updated to prove that real enforcement, not the vacuous case it // originally documented. const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, true); assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true); assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, true); }); // ── 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); });