#!/usr/bin/env node // Phase 6B — ROOT_ curriculum authoring & coverage expansion acceptance test. Covers the NEW // courses/modules/content this phase added (ROOT 05-10/12/Capstone), the two genuinely new // mechanisms it introduced (is_required optional-activity handling, and the capstone // server-side unlock fix), and confirms the underlying Phase 6A engine (completion derivation, // locking, RBAC, isolation, persistence, draft-hiding) generalizes correctly to the new content // rather than re-proving every property from scratch against every module (already proven once, // thoroughly, in e2e-phase6a.js — see that file for the exhaustive per-mechanism coverage). // Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab // container launched here (infra/scripts/drills/test-phase6b-root-course-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-phase6b.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); const { completeRootFoundations01Through04 } = require('./foundations-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'; // The real ROOT 05 Foundation scenarios (m00 + m01) — completing all of these is what actually // unlocks ROOT 06 at the course level (courses.prerequisites), same real content // e2e-phase6a.js/e2e-phase5b.js already drive completion against. const ROOT05_ALL = ['namespaces-basics', 'pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq']; 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 main() { console.log('Phase 6B ROOT_ curriculum expansion acceptance test\n'); const owner = makeSession(GW); await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); // ── 1-8: hierarchy for every course this phase touched ────────────────────────────────── let structure; await test('ROOT 05-10, 12, and Capstone all have the expected new modules, in sequence', async () => { const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); assert.equal(status, 200); structure = body; const expectations = { 'root-05-kubernetes-foundations': ['root-05-m00-kubernetes-mental-model', 'root-05-m01-pods-and-kubectl-basics'], 'root-06-kubernetes-application-workloads': ['root-06-m02-deployments-scaling-updates', 'root-06-m03-jobs-cronjobs-daemonsets', 'root-06-m01-deployments-and-workloads'], 'root-07-kubernetes-networking-config': ['root-07-m03-services-and-service-discovery', 'root-07-m04-ingress-networkpolicy-foundations', 'root-07-m01-services-and-networking', 'root-07-m02-configuration-and-secrets'], 'root-08-kubernetes-storage-scheduling': ['root-08-m01-storage-fundamentals', 'root-08-m02-scheduling-resource-governance'], 'root-09-kubernetes-security': ['root-09-m01-security-model-and-securitycontext', 'root-09-m02-serviceaccounts-and-rbac', 'root-09-m03-network-and-supplychain-security', 'root-09-m04-cluster-hardening-and-runtime'], 'root-10-kubernetes-troubleshooting-observability': ['root-10-m01-health-probes-and-troubleshooting', 'root-10-m03-troubleshooting-framework', 'root-10-m02-general-troubleshooting'], 'root-12-real-ops': ['root-12-m01-real-ops-incidents'], 'root-capstone': ['root-capstone-m02-capstone-readiness', 'root-capstone-m01-operate-and-recover'], }; for (const [courseId, moduleIds] of Object.entries(expectations)) { const course = courseById(structure, courseId); assert.ok(course, `${courseId} must exist`); const actualIds = course.modules.map((m) => m.module_id); assert.deepEqual(actualIds, moduleIds, `${courseId} module order mismatch: ${JSON.stringify(actualIds)}`); } }); await test('ROOT 08 and ROOT 09 moved from PLANNED to PARTIAL — real content exists now', async () => { assert.equal(courseById(structure, 'root-08-kubernetes-storage-scheduling').status, 'partial'); assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial'); }); await test('the gold-standard Health Probes module\'s required chain is unchanged (Phase 6C added one optional lab)', async () => { const gold = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting'); assert.equal(gold.status, 'ready'); assert.equal(gold.activities.length, 7); assert.equal(gold.activities.filter((a) => a.is_required !== false).length, 6, 'the original 6 required activities must be untouched'); }); await test('activity ordering within every new module is strictly ascending', async () => { for (const newModuleId of ['root-05-m00-kubernetes-mental-model', 'root-06-m02-deployments-scaling-updates', 'root-07-m03-services-and-service-discovery', 'root-09-m01-security-model-and-securitycontext']) { const mod = moduleById(structure, newModuleId); for (let i = 1; i < mod.activities.length; i++) assert.ok(mod.activities[i].sequence > mod.activities[i - 1].sequence, `${newModuleId} activity ordering broken`); } }); await test('ROOT 12\'s new framework lesson is first and the new reflection is last, existing labs preserved in between', async () => { const mod = moduleById(structure, 'root-12-m01-real-ops-incidents'); const types = mod.activities.map((a) => a.activity_type); assert.deepEqual(types, ['lesson', 'lab', 'lab', 'lab', 'lab', 'reflection']); const scenarioIds = mod.activities.filter((a) => a.activity_type === 'lab').map((a) => a.scenario_id); assert.deepEqual(scenarioIds, ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']); }); // ── A real self-paced student walking the new content ─────────────────────────────────── const { body: caseyCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Casey-${RUN_ID}`, login_identifier: `e2e6b-casey-${RUN_ID}` }); let casey = makeSession(GW); await casey.request('POST', '/api/student/activate', { token: caseyCreate.activation_token, password: 'CaseyPassword123!' }); const { body: enrollCreate } = await owner.request('POST', '/api/admin/enrollments', { student_id: caseyCreate.student_id, program_id: PROGRAM, status: 'active' }); const enrollmentId = enrollCreate.enrollment_id; await test('ROOT 06/09/10-framework are locked for a freshly-enrolled student', async () => { const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true); assert.equal(moduleById(body.courses, 'root-09-m01-security-model-and-securitycontext').locked, true); 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 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-01-m01-how-computers-run-software'); }); await test('completing the real ROOT 01-04 foundations sequence unlocks ROOT 05 (Phase 6E test setup, not this file\'s own subject)', async () => { await completeRootFoundations01Through04(casey); const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false); }); await test('lesson/knowledge_check completion works identically in a brand-new module (root-05-m00)', async () => { for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces']) { const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`); assert.equal(status, 200); } await completeScenario(casey, 'namespaces-basics'); const { status } = await casey.request('POST', '/api/student/activities/root-05-m00-a06-reflection/complete'); assert.equal(status, 200); const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true); }); await test('completing every ROOT 05 scenario (m00 + m01) unlocks ROOT 06 at the course level', async () => { for (const sid of ROOT05_ALL) await completeScenario(casey, sid); // Phase 6C added a bookending lesson + reflection around m01's 20-task bank — both required // manual markers, so m01 (and therefore the ROOT 05 course) isn't complete without them. for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) { await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`); } const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').complete, true); assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, false); assert.equal(moduleById(body.courses, 'root-06-m01-deployments-and-workloads').locked, true, 'the bare bank stays gated behind the new m02 specifically'); }); await test('a new scenario wired this phase (job-task, never in Phase 6A) completes an activity correctly', async () => { await completeScenario(casey, 'job-task'); const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(findActivity(body.courses, 'root-06-m03-a03-lab-job-task').complete, true); }); // ── is_required: optional activities count toward percent but never block "complete" ──── await test('an optional activity being incomplete does not block its module from being complete', async () => { // root-09 is still locked for Casey (needs root-06-m02 complete). Prove the is_required // rule directly against the seeded data instead: root-09-m01 has 12 activities, 3 of which // (a10/a11/a12) are is_required=false. Confirm the schema/shape is correct via the admin // structure view (studentId=null, so nothing is actually complete — this is a pure // structural check of the is_required flag itself). const mod = moduleById(structure, 'root-09-m01-security-model-and-securitycontext'); const optional = mod.activities.filter((a) => a.is_required === false); const required = mod.activities.filter((a) => a.is_required !== false); assert.equal(optional.length, 3, 'expected exactly 3 optional activities in root-09-m01'); assert.equal(required.length, 9); }); await test('completing every REQUIRED activity in root-09-m01 marks it complete even with optional activities left undone', async () => { // Unlock root-09 for Casey: complete the rest of root-06 (m02 + m03's remaining scenarios). for (const sid of ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback']) { await completeScenario(casey, sid); } await completeScenario(casey, 'cronjob-task'); for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy']) { await casey.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`); } await casey.request('POST', '/api/student/activities/root-06-m02-a06-reflection/complete'); // Phase 6C upgraded root-06-m03 to the full gold pattern (added a01b/a02b/a05) — all four // manual-marker activities, plus a05's own reflection, are required for root-06 the COURSE // to actually be complete (root-09's real prerequisite), not just its original two. for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive', 'a05-reflection']) { const { status } = await casey.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`); assert.equal(status, 200, `completing root-06-m03-${suffix} should 200`); } let tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses; assert.equal(courseById(tree, 'root-06-kubernetes-application-workloads').complete, true, 'root-06 must be fully complete before root-09 unlocks'); assert.equal(moduleById(tree, 'root-09-m01-security-model-and-securitycontext').locked, false, 'root-09 should now be unlocked'); // Complete only the REQUIRED activities in root-09-m01, deliberately skipping the 3 optional ones. for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan']) { await casey.request('POST', `/api/student/activities/root-09-m01-${suffix}/complete`); } for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await completeScenario(casey, sid); await casey.request('POST', '/api/student/activities/root-09-m01-a09-reflection/complete'); tree = (await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`)).body.courses; const mod = moduleById(tree, 'root-09-m01-security-model-and-securitycontext'); assert.equal(mod.complete, true, 'module must be complete once all REQUIRED activities are done, regardless of the 3 optional ones'); assert.equal(mod.percent_complete, Math.round((9 / 12) * 100), 'percent_complete must still reflect ALL activities, optional included'); assert.equal(findActivity(tree, 'root-09-m01-a10-lab-seccomp-guided').complete, false, 'the optional activity itself is correctly still incomplete'); }); // ── Persistence, isolation, RBAC, draft-hiding — proven once against NEW content ──────── await test('progress on new content survives logout and a fresh login', async () => { await casey.request('POST', '/api/logout'); casey = makeSession(GW); await casey.request('POST', '/api/student/login', { identifier: `e2e6b-casey-${RUN_ID}`, password: 'CaseyPassword123!' }); const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, true); }); const { body: isolatedCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Isolated-${RUN_ID}`, login_identifier: `e2e6b-isolated-${RUN_ID}` }); const isolated = makeSession(GW); await isolated.request('POST', '/api/student/activate', { token: isolatedCreate.activation_token, password: 'IsolatedPassword123!' }); const { body: isolatedEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: isolatedCreate.student_id, program_id: PROGRAM, status: 'active' }); await test('a second student sees independent progress on the new courses (cross-student isolation)', async () => { const { body } = await isolated.request('GET', `/api/student/programs/${isolatedEnroll.enrollment_id}/courses`); assert.equal(moduleById(body.courses, 'root-05-m00-kubernetes-mental-model').complete, false); assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true); }); await test('a draft activity in a new module is hidden from students but visible to instructors', async () => { psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`); try { const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); const mod = moduleById(studentView.courses, 'root-08-m01-storage-fundamentals'); assert.ok(!mod.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals')); const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const modAdmin = moduleById(adminView, 'root-08-m01-storage-fundamentals'); assert.ok(modAdmin.activities.some((a) => a.activity_id === 'root-08-m01-a01-lesson-storage-fundamentals')); } finally { psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`); } }); await test('instructor notes on new content are visible to instructors and never to students', async () => { const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const lesson = findActivity(adminView, 'root-09-m01-a01-lesson-security-model'); assert.ok(lesson.instructor_notes && lesson.instructor_notes.includes('Instructor Notes')); const { body: studentView } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); const studentLesson = findActivity(studentView.courses, 'root-09-m01-a01-lesson-security-model'); assert.equal(studentLesson.instructor_notes, undefined, 'a student response must never include this key at all'); }); const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6b-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor', role: 'instructor', password: 'StrayInstructorPass123!' }); const strayInstructorSession = makeSession(GW); await strayInstructorSession.request('POST', '/api/auth/instructor/login', { email: `e2e6b-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass123!' }); await test('a plain instructor with no relationship to the student is forbidden from their new-course progress (self-paced, no cohort)', async () => { const { status } = await strayInstructorSession.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`); assert.equal(status, 403); }); await test('owner/admin can see any self-paced student\'s progress on the new courses', async () => { const { status, body } = await owner.request('GET', `/api/admin/students/${caseyCreate.student_id}/courses?program_id=${PROGRAM}`); assert.equal(status, 200); assert.equal(moduleById(body, 'root-05-m00-kubernetes-mental-model').complete, true); }); // ── Capstone: the server-side unlock fix — the central Phase 6B security fix ─────────── const { body: bypassCreate } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6B-Bypass-${RUN_ID}`, login_identifier: `e2e6b-bypass-${RUN_ID}` }); const bypass = makeSession(GW); await bypass.request('POST', '/api/student/activate', { token: bypassCreate.activation_token, password: 'BypassPassword123!' }); const { body: bypassEnroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: bypassCreate.student_id, program_id: PROGRAM, status: 'active' }); await test('direct POST /api/assessments/rootnode-capstone/start is rejected before prerequisites are complete (the fix)', async () => { const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start'); assert.equal(status, 403); assert.equal(body.error, 'locked'); assert.ok(Array.isArray(body.blocking_stage_ids) && body.blocking_stage_ids.length > 0); }); await test('an ordinary exam with no unlock_rule is completely unaffected by the fix', async () => { const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-quick-assessment/start'); assert.equal(status, 201); assert.ok(body.scenarios.length > 0); // Clean up the active session so it doesn't block later assertions for this student. for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id); }); await test('completing the real ROOT 01-04 foundations sequence plus ROOT 05\'s manual markers unlocks ROOT 06 for bypass', async () => { // Phase 6E test setup, not this file's own subject — see e2e-phase6e.js for real coverage. await completeRootFoundations01Through04(bypass); for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces']) { const { status } = await bypass.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`); assert.equal(status, 200, `completing root-05-m00-${suffix} should 200`); } // root-05-m00's own lab activity (a05) is scenario-derived, not a manual marker — and // root-05-m01's real prerequisite is root-05-m00 COMPLETE, so this can't be skipped the // way casey's equivalent flow doesn't skip it either. await completeScenario(bypass, 'namespaces-basics'); { const { status } = await bypass.request('POST', '/api/student/activities/root-05-m00-a06-reflection/complete'); assert.equal(status, 200); } for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) { const { status, body } = await bypass.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`); assert.equal(status, 200, `completing root-05-m01-${suffix} should 200: ${JSON.stringify(body)}`); } }); await test('a legitimately unlocked capstone start is still accepted (the fix does not over-block)', async () => { const ALL_STAGES = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selectors-mcq', 'init-containers-mcq', 'namespaces-basics', 'multi-container-pod-basics', 'pod-labeling-basics', 'jsonpath-basics', 'dry-run-manifest-basics', 'delete-by-label-basics', 'annotate-resource-basics', 'init-container-task-basics', 'restart-policy-mcq', 'kubectl-output-format-mcq', 'kubectl-explain-basics', 'kubectl-patch-basics', 'field-selector-basics', 'sort-by-basics', 'kubectl-diff-basics', 'static-pods-mcq', 'deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'services-mcq', 'expose-service-basics', 'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline', 'extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq', 'rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']; for (const sid of ALL_STAGES) await completeScenario(bypass, sid); const { status, body } = await bypass.request('POST', '/api/assessments/rootnode-capstone/start'); assert.equal(status, 201, `expected the now-unlocked capstone to start: ${JSON.stringify(body)}`); for (const s of body.scenarios) await completeScenario(bypass, s.scenario_id); const check = await bypass.request('GET', '/api/capstone'); assert.equal(check.body.status, 'completed'); }); await test('the capstone-derived completion is reflected in the course/module content model too', async () => { const { body } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`); const capstoneActivity = findActivity(body.courses, 'root-capstone-m01-a01-capstone'); assert.equal(capstoneActivity.complete, true); }); await test('the Capstone Readiness module itself is completable independent of the real assessment', async () => { // root-capstone's real prerequisite is root-12-real-ops complete, which needs root-10 // complete (root-06 complete first) — bypass already has every relevant LAB scenario done // from the ALL_STAGES completion above; only the manual-marker activities (lessons/ // reflections/knowledge-checks) for root-06/root-10-m01/m03/root-12-m01 remain, now // genuinely enforced even via direct API. // ALL_STAGES (above) predates root-06-m02's a05b-lab-deployment-rollback activity and never // included its scenario — completed explicitly here, same gap e2e-phase6a.js's casey flow // already accounts for. await completeScenario(bypass, 'rolling-update-task'); await completeScenario(bypass, 'deployment-rollback'); for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy', 'a06-reflection']) { const { status } = await bypass.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`); assert.equal(status, 200, `completing root-06-m02-${suffix} should 200`); } await completeScenario(bypass, 'job-task'); await completeScenario(bypass, 'cronjob-task'); for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive', 'a05-reflection']) { const { status } = await bypass.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`); assert.equal(status, 200, `completing root-06-m03-${suffix} should 200`); } for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe', 'a06-reflection-real-ops-takeaway']) { const { status } = await bypass.request('POST', `/api/student/activities/root-10-m01-${suffix}/complete`); assert.equal(status, 200, `completing root-10-m01-${suffix} should 200`); } for (const suffix of ['a01-lesson-framework', 'a02-demonstration-crashloop', 'a03-knowledge-check', 'a03b-guided-exercise-endpoint-mismatch']) { const { status } = await bypass.request('POST', `/api/student/activities/root-10-m03-${suffix}/complete`); assert.equal(status, 200, `completing root-10-m03-${suffix} should 200`); } await completeScenario(bypass, 'crashloop-fix'); await completeScenario(bypass, 'endpoint-fix-task'); // root-10-m02 (general-troubleshooting) is also part of root-10 the COURSE's completion // total — ALL_STAGES (above) covers 5 of its 6 scenarios but predates container-logging-mcq. await completeScenario(bypass, 'container-logging-mcq'); await bypass.request('POST', '/api/student/activities/root-10-m03-a06-reflection/complete'); await bypass.request('POST', '/api/student/activities/root-12-m01-a00-lesson-incident-response/complete'); await bypass.request('POST', '/api/student/activities/root-12-m01-a99-reflection-post-incident/complete'); const { body: chk } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`); assert.equal(courseById(chk.courses, 'root-12-real-ops').complete, true, 'root-12 must be complete before the capstone course unlocks'); for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) { const { status } = await bypass.request('POST', `/api/student/activities/root-capstone-m02-${suffix}/complete`); assert.equal(status, 200, `completing root-capstone-m02-${suffix} should 200`); } const { body } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`); assert.equal(moduleById(body.courses, 'root-capstone-m02-capstone-readiness').complete, true); }); // ── Content version stability ───────────────────────────────────────────────────────────── await test('bumping a new module\'s version does not break or drop its content', async () => { psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-09-m01-security-model-and-securitycontext'`); const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const mod = moduleById(body, 'root-09-m01-security-model-and-securitycontext'); assert.equal(mod.activities.length, 12, 'a version bump must never drop activities'); assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300); }); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error('FATAL', e); process.exit(1); });