#!/usr/bin/env node // Phase 6C — curriculum completion, assessment bank, and ROOT 11 scope acceptance test. Covers // what THIS phase actually changed: the six upgraded PARTIAL modules, the new security // assessment bank, the ROOT 11 orientation module, and the one newly-wired optional-practice // activity — not re-proving the underlying completion/locking/RBAC engine from scratch (already // proven exhaustively in e2e-phase6a.js and re-proven against new content in e2e-phase6b.js). // Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab // container launched here (infra/scripts/drills/phase6c-security-config-rehearsal.js is the // real-k3s 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-phase6c.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'; 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`); } 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-6C-${tag}-${RUN_ID}`, login_identifier: `e2e6c-${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 6C curriculum completion / assessment bank / ROOT 11 acceptance test\n'); const owner = makeSession(GW); await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); let structure; // ── 1: course tree correctness — every module Phase 6C touched, in the expected order ──── await test('every module Phase 6C upgraded exists with the expected full activity-type sequence', async () => { const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); assert.equal(status, 200); structure = body; const expected = { 'root-06-m03-jobs-cronjobs-daemonsets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'], 'root-07-m04-ingress-networkpolicy-foundations': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'], 'root-07-m02-configuration-and-secrets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'lab', 'lab', 'lab', 'reflection'], 'root-10-m03-troubleshooting-framework': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'], 'root-05-m01-pods-and-kubectl-basics': ['lesson', ...Array(20).fill('lab'), 'reflection'], 'root-11-m01-devops-platform-thinking': ['lesson', 'knowledge_check', 'reflection'], }; for (const [modId, types] of Object.entries(expected)) { const mod = moduleById(structure, modId); assert.ok(mod, `${modId} must exist`); assert.deepEqual(mod.activities.map((a) => a.activity_type), types, `${modId} activity chain mismatch`); assert.equal(mod.status, modId === 'root-11-m01-devops-platform-thinking' ? 'partial' : 'ready', `${modId} status mismatch`); } }); await test('root-09-m02/m03 kept every original lab activity_id and scenario_id while gaining teaching activities', async () => { const m02 = moduleById(structure, 'root-09-m02-serviceaccounts-and-rbac'); const labs = m02.activities.filter((a) => a.activity_type === 'lab').map((a) => a.activity_id); assert.deepEqual(labs, ['root-09-m02-a03-lab-serviceaccount-pod', 'root-09-m02-a04-lab-automount-token', 'root-09-m02-a05-lab-rbac-role', 'root-09-m02-a06-lab-rbac-clusterrole', 'root-09-m02-a07-lab-rbac-least-privilege', 'root-09-m02-a08-lab-rbac-clusterrole-independent']); }); await test('the three documented-exception modules were promoted to READY with no new activities added', async () => { assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').activities.length, 6, 'real-ops incidents must stay at 6 — no KC/guided added by design'); assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').activities.length, 1, 'capstone module IS the exam, no bookending added'); assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').status, 'ready'); assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').status, 'ready'); }); await test('the survey-depth security module (root-09-m04) is READY and carries the new assessment activity last', async () => { const mod = moduleById(structure, 'root-09-m04-cluster-hardening-and-runtime'); assert.equal(mod.status, 'ready'); const last = mod.activities[mod.activities.length - 1]; assert.equal(last.activity_type, 'assessment'); assert.equal(last.assessment_definition_id, 'rootnode-security-assessment'); }); // ── 3/18: required/optional — the newly-wired optional readiness-probe lab ─────────────── await test('the new optional readiness-probe lab does not block root-10-m01 completion when skipped', async () => { const student = await newStudent(owner, 'OptSkip'); // Complete only the REQUIRED activities in root-10-m01, deliberately skipping the optional lab. for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) { const { status } = await student.session.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`); assert.equal(status, 200); } await completeScenario(student.session, 'rootnode-probe-port-mismatch'); await student.session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete'); const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); const mod = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting'); assert.equal(mod.complete, true, 'module must be complete without the optional lab'); const optionalActivity = findActivity(body.courses, 'root-10-m01-a05b-lab-readiness-probe-optional'); assert.equal(optionalActivity.is_required, false); assert.equal(optionalActivity.complete, false, 'the optional lab itself is correctly still incomplete'); }); // ── 4/12: new module activity chains + no fake completion from viewing ────────────────── await test('viewing the course tree never marks a lesson complete on its own (no fake completion)', async () => { const student = await newStudent(owner, 'NoFake'); await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); const mod = moduleById(body.courses, 'root-05-m01-pods-and-kubectl-basics'); assert.equal(mod.complete, false); assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, false); }); // ── 14: idempotency — completing the same activity twice never errors or double-counts ── await test('completing the same activity twice is idempotent', async () => { const student = await newStudent(owner, 'Idem'); const first = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete'); assert.equal(first.status, 200); const second = await student.session.request('POST', '/api/student/activities/root-05-m01-a00-lesson-kubectl-fluency/complete'); assert.ok([200, 409].includes(second.status), `second completion should 200 or 409, got ${second.status}`); const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, true); }); // ── 6/17: instructor notes exclusion + RBAC on NEW Phase 6C content ────────────────────── await test('instructor notes on new Phase 6C content are visible to instructors and never to students', async () => { const lesson = findActivity(structure, 'root-07-m02-a00-lesson-configuration'); assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0); const student = await newStudent(owner, 'NotesCheck'); const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); const studentLesson = findActivity(body.courses, 'root-07-m02-a00-lesson-configuration'); assert.equal(studentLesson.instructor_notes, undefined); }); const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6c-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6C', role: 'instructor', password: 'StrayInstructorPass6C!' }); const stray = makeSession(GW); await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6c-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6C!' }); await test('a plain instructor with no relationship to the student cannot see their Phase 6C module progress', async () => { const student = await newStudent(owner, 'RbacCheck'); const { status } = await stray.request('GET', `/api/admin/students/${student.studentId}/courses?program_id=${PROGRAM}`); assert.equal(status, 403); }); // ── 7/19: assessment bank — definition, server-authoritative draw, cross-student isolation ─ await test('the security assessment pool_spec matches the documented design (3 required + 6 random)', async () => { const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-security-assessment'`)); assert.deepEqual(spec.required, ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']); const randomCount = spec.random.reduce((sum, g) => sum + g.count, 0); assert.equal(randomCount, 6); }); await test('two independent students each get their own 9-scenario draw, and it is server-authoritative', async () => { const alice = await newStudent(owner, 'SecAliceA'); const bob = await newStudent(owner, 'SecBobA'); const aStart = await alice.session.request('POST', '/api/assessments/rootnode-security-assessment/start'); const bStart = await bob.session.request('POST', '/api/assessments/rootnode-security-assessment/start'); assert.equal(aStart.status, 201); assert.equal(bStart.status, 201); assert.equal(aStart.body.scenarios.length, 9); assert.equal(bStart.body.scenarios.length, 9); for (const s of ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']) { assert.ok(aStart.body.scenarios.some((x) => x.scenario_id === s), `alice draw missing required scenario ${s}`); } assert.notEqual(aStart.body.assessment_session_id, bStart.body.assessment_session_id, 'each student must get an independent session'); // A student cannot read another student's assessment session (cross-student isolation). const crossRead = await bob.session.request('GET', `/api/assessments/${aStart.body.assessment_session_id}`); assert.ok([403, 404].includes(crossRead.status), `bob must not be able to read alice's session, got ${crossRead.status}`); }); await test('completing the security assessment marks its activity complete in the course tree', async () => { const student = await newStudent(owner, 'SecComplete'); const { body: start } = await student.session.request('POST', '/api/assessments/rootnode-security-assessment/start'); for (const s of start.scenarios) await completeScenario(student.session, s.scenario_id); const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); assert.equal(findActivity(body.courses, 'root-09-m04-a06-assessment-security').complete, true); }); // ── 10: capstone gate — confirm the known architectural fact (ROOT 08/09 don't gate it) ── await test('the capstone unlock gate is unaffected by ROOT 09 (security) being incomplete — documented architecture, not a bug', async () => { const student = await newStudent(owner, 'CapstoneGate'); const { status, body } = await student.session.request('POST', '/api/assessments/rootnode-capstone/start'); assert.equal(status, 403); assert.equal(body.error, 'locked'); assert.ok(!body.blocking_stage_ids.some((id) => String(id).includes('security')), 'security must never appear as a capstone-blocking stage'); }); // ── 11: ROOT 11 renders honestly per its actual partial/lesson-only status ─────────────── await test('ROOT 11 renders as partial with a lesson-only module and no fabricated Lab activity', async () => { const course = courseById(structure, 'root-11-devops-platform-engineering'); assert.equal(course.status, 'partial'); const mod = moduleById(structure, 'root-11-m01-devops-platform-thinking'); assert.equal(mod.status, 'partial'); assert.ok(!mod.activities.some((a) => a.activity_type === 'lab'), 'ROOT 11 must not present a fake hands-on lab'); }); await test('a student can complete ROOT 11 module 1 end-to-end (lesson-only path)', async () => { const student = await newStudent(owner, 'Root11'); for (const suffix of ['a01-lesson-devops-platform-thinking', 'a02-knowledge-check', 'a03-reflection']) { const { status } = await student.session.request('POST', `/api/student/activities/root-11-m01-${suffix}/complete`); assert.equal(status, 200); } const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-11-m01-devops-platform-thinking').complete, true); }); // ── 16: version snapshot integrity on a NEW Phase 6C module ────────────────────────────── await test('bumping a Phase 6C-upgraded module\'s version does not drop its content', async () => { psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-07-m02-configuration-and-secrets'`); const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const mod = moduleById(body, 'root-07-m02-configuration-and-secrets'); assert.equal(mod.activities.length, 10); assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300); }); // ── 24: content validation script itself passes 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); });