From 2864fe03dcc9fb9ac9f0c4f54b6a6f2a3876211f Mon Sep 17 00:00:00 2001 From: ced4568 Date: Wed, 26 Aug 2026 18:33:21 -0500 Subject: [PATCH] test(phase6f): add V1 pilot acceptance suite; fix regression from real curriculum changes - New platform/test/e2e-phase6f.js (17 tests): V1 manifest/exclusion correctness, the full V1 prerequisite graph, ROOT 01-04 re-verification, the orientation module's unlock behavior, optional-activity handling, the strengthened Capstone gate in both directions, instructor-notes RBAC on locked (not just draft) content, isolation, and an explicit age-independence check. - foundations-helper.js: completeThroughRoot06/completeThroughRoot10 updated for the new practice-bank intro-lesson activities; new completeThroughCapstoneEligible() helper (ROOT 07-m03/m04 + full ROOT 09 + ROOT 12) shared by e2e-phase6f.js and both new drills. - e2e-phase6a/6b/6d/6e.js: resume-pointer assertions updated for the new orientation module; e2e-phase6a.js's and e2e-phase6b.js's own inline ROOT 06/07/09/10/12 completion sequences updated for the new intro-lesson activities and the new root-09 Capstone dependency; e2e-phase6b.js's stale ROOT 09 status assertion and its draft-activity test (previously targeting the now fully-excluded ROOT 08) corrected. - test-phase6d/6e-foundations-rehearsal.js: same orientation-module fix applied to the real-Lab dress rehearsals. All of 6A (23/23) / 6B (26/26) / 6C (17/17) / 6D (21/21) / 6E (23/23) / 6F (17/17) verified green on independent fresh stacks. See docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. Co-Authored-By: Claude Sonnet 5 --- .../test-phase6d-foundations-rehearsal.js | 11 +- .../test-phase6e-foundations-rehearsal.js | 3 + platform/test/e2e-phase6a.js | 21 +- platform/test/e2e-phase6b.js | 96 +++++++- platform/test/e2e-phase6d.js | 21 +- platform/test/e2e-phase6e.js | 10 +- platform/test/e2e-phase6f.js | 215 ++++++++++++++++++ platform/test/foundations-helper.js | 42 +++- 8 files changed, 397 insertions(+), 22 deletions(-) create mode 100644 platform/test/e2e-phase6f.js diff --git a/infra/scripts/drills/test-phase6d-foundations-rehearsal.js b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js index ee7dac9..01637c7 100644 --- a/infra/scripts/drills/test-phase6d-foundations-rehearsal.js +++ b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js @@ -59,9 +59,18 @@ async function main() { }); await step('resume points at ROOT 01\'s very first activity', async () => { + // Phase 6F added a required orientation module in front of root-01-m01 — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. 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'); + assert.equal(body.resume.activity_id, 'root-01-m00-a01-lesson-welcome'); + }); + + await step('student completes the ROOT_ V1 orientation module', async () => { + for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) { + const { status } = await session.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(status, 200); + } }); await step('student reads ROOT 01 module 1\'s lesson and guided exercise', async () => { diff --git a/infra/scripts/drills/test-phase6e-foundations-rehearsal.js b/infra/scripts/drills/test-phase6e-foundations-rehearsal.js index 2367508..d7601e7 100644 --- a/infra/scripts/drills/test-phase6e-foundations-rehearsal.js +++ b/infra/scripts/drills/test-phase6e-foundations-rehearsal.js @@ -78,6 +78,9 @@ async function main() { // ── ROOT 01 (already READY since Phase 6D) — complete quickly via real scenario endpoint ── await step('ROOT 01 completes via the real Lab scenario-validation API', async () => { + // Phase 6F added a required orientation module gating root-01-m01 — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. + for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) await completeActivity(session, id); for (const suffix of ['a01-lesson', 'a02-guided-exercise']) await completeActivity(session, `root-01-m01-${suffix}`); await completeScenario(session, 'linux-inspect-hardware', envId); await completeActivity(session, 'root-01-m01-a04-reflection'); diff --git a/platform/test/e2e-phase6a.js b/platform/test/e2e-phase6a.js index 797c479..bf39248 100644 --- a/platform/test/e2e-phase6a.js +++ b/platform/test/e2e-phase6a.js @@ -89,7 +89,10 @@ async function main() { const plannedWithModules = structure.filter((c) => c.status === 'planned' && c.modules.length > 0); assert.equal(plannedWithModules.length, 0, 'a PLANNED course must have zero modules, not placeholder content'); const gold = courseById(structure, 'root-10-kubernetes-troubleshooting-observability'); - assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules + // Phase 6F gave root-10-m02 (the bare practice bank) real framing (docs/ + // PHASE6F_ROOT_V1_PILOT_READINESS.md), so all three modules are now genuinely authored and + // the course honestly earns 'ready' — no fabrication, just a real content-completeness fix. + assert.equal(gold.status, 'ready'); }); await test('the gold-standard module is fully READY with 7 ordered activities and real content', async () => { @@ -143,10 +146,12 @@ async function main() { // 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. + // proven, just against the current front door. Phase 6F then added a required orientation + // module (root-01-m00-welcome-to-root) in front of that — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. 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'); + 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('a student cannot mark a lab activity complete manually — only real scenario completion counts', async () => { @@ -193,6 +198,14 @@ async function main() { await completeScenario(casey, 'rolling-update-task'); await completeScenario(casey, 'deployment-rollback'); await casey.request('POST', '/api/student/activities/root-06-m02-a06-reflection/complete'); + // Phase 6F gave root-06-m01 (a bare practice bank) a real intro-lesson activity — required, + // and not part of WORKLOADS's scenario list. root-06-m01's own prerequisite is root-06-m02 + // complete, so this must come after root-06-m02's reflection above, not before it — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. + { + const { status } = await casey.request('POST', '/api/student/activities/root-06-m01-a00-lab-bank-intro/complete'); + assert.equal(status, 200, 'completing root-06-m01-a00-lab-bank-intro should 200'); + } for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive']) { await casey.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`); } diff --git a/platform/test/e2e-phase6b.js b/platform/test/e2e-phase6b.js index 7f263cb..55c915f 100644 --- a/platform/test/e2e-phase6b.js +++ b/platform/test/e2e-phase6b.js @@ -86,9 +86,13 @@ async function main() { } }); - await test('ROOT 08 and ROOT 09 moved from PLANNED to PARTIAL — real content exists now', async () => { + await test('ROOT 08 stays PARTIAL, ROOT 09 reached READY — real content exists now', async () => { + // Phase 6F (Step 0's audit) found ROOT 09's course-level status had drifted stale — all 4 + // of its modules were already individually 'ready' — and corrected it; ROOT 08 is + // unaffected and additionally excluded from V1 (visibility='draft') this same phase — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. assert.equal(courseById(structure, 'root-08-kubernetes-storage-scheduling').status, 'partial'); - assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial'); + assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'ready'); }); await test('the gold-standard Health Probes module\'s required chain is unchanged (Phase 6C added one optional lab)', async () => { @@ -131,9 +135,11 @@ async function main() { // 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. + // exists — is unchanged, just against the current front door. Phase 6F then added a + // required orientation module in front of root-01-m01 — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); - assert.equal(body.resume.module_id, 'root-01-m01-how-computers-run-software'); + assert.equal(body.resume.module_id, 'root-01-m00-welcome-to-root'); }); await test('completing the real ROOT 01-04 foundations sequence unlocks ROOT 05 (Phase 6E test setup, not this file\'s own subject)', async () => { @@ -197,6 +203,13 @@ async function main() { 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 6F gave root-06-m01 (a bare practice bank) a real intro-lesson activity — required, + // and its own prerequisite is root-06-m02 complete, so this must come right after — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. + { + const { status } = await casey.request('POST', '/api/student/activities/root-06-m01-a00-lab-bank-intro/complete'); + assert.equal(status, 200, 'completing root-06-m01-a00-lab-bank-intro should 200'); + } // 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. @@ -242,16 +255,20 @@ async function main() { }); 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'`); + // Phase 6F excluded ROOT 08 from V1 entirely (visibility='draft' on the whole course) — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md — so it can no longer demonstrate per-activity + // draft filtering (the whole module is already invisible to students regardless). Using + // root-09-m01 instead, still a real Phase 6B module and still published in V1. + psql(`UPDATE module_activities SET visibility = 'draft' WHERE activity_id = 'root-09-m01-a01-lesson-security-model'`); 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 mod = moduleById(studentView.courses, 'root-09-m01-security-model-and-securitycontext'); + assert.ok(!mod.activities.some((a) => a.activity_id === 'root-09-m01-a01-lesson-security-model')); 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')); + const modAdmin = moduleById(adminView, 'root-09-m01-security-model-and-securitycontext'); + assert.ok(modAdmin.activities.some((a) => a.activity_id === 'root-09-m01-a01-lesson-security-model')); } finally { - psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-08-m01-a01-lesson-storage-fundamentals'`); + psql(`UPDATE module_activities SET visibility = 'published' WHERE activity_id = 'root-09-m01-a01-lesson-security-model'`); } }); @@ -355,6 +372,13 @@ async function main() { 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`); } + // Phase 6F gave root-06-m01 (a bare practice bank) a real intro-lesson activity — required, + // and its own prerequisite is root-06-m02 complete, so it comes right after — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. + { + const { status } = await bypass.request('POST', '/api/student/activities/root-06-m01-a00-lab-bank-intro/complete'); + assert.equal(status, 200, 'completing root-06-m01-a00-lab-bank-intro 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']) { @@ -375,11 +399,63 @@ async function main() { // 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'); + // Phase 6F gave root-10-m02 (a bare practice bank) a real intro-lesson activity — required, + // and its own prerequisite is root-10-m03 complete (just finished above). Without this, + // root-10 the COURSE never reaches complete=true, which keeps root-12 the COURSE locked + // (its own prerequisite) even though root-12-m01 the MODULE's prerequisite (root-10-m01) is + // already satisfied — see docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. + { + const { status } = await bypass.request('POST', '/api/student/activities/root-10-m02-a00-lab-bank-intro/complete'); + assert.equal(status, 200, 'completing root-10-m02-a00-lab-bank-intro should 200'); + } 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'); + // Phase 6F (Step 7) added root-09-kubernetes-security as a second, joint course-level + // prerequisite for root-capstone alongside root-12 — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. root-09-m03 also carries a real cross-course + // module prerequisite on root-07-m04 (Ingress & NetworkPolicy), which itself requires + // root-07-m03 complete — neither touched anywhere else in this file's bypass flow. + for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) { + const { status } = await bypass.request('POST', `/api/student/activities/root-07-m03-${suffix}/complete`); + assert.equal(status, 200, `completing root-07-m03-${suffix} should 200`); + } + for (const suffix of ['a01-lesson-ingress-networkpolicy', 'a01b-demonstration-ingress-routing', 'a02-knowledge-check', 'a02b-guided-exercise-network-isolation', 'a05-reflection']) { + const { status } = await bypass.request('POST', `/api/student/activities/root-07-m04-${suffix}/complete`); + assert.equal(status, 200, `completing root-07-m04-${suffix} should 200`); + } + await completeScenario(bypass, 'ingress-task'); + await completeScenario(bypass, 'network-policy'); + + for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan', 'a09-reflection']) { + const { status } = await bypass.request('POST', `/api/student/activities/root-09-m01-${suffix}/complete`); + assert.equal(status, 200, `completing root-09-m01-${suffix} should 200`); + } + for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await completeScenario(bypass, sid); + + for (const suffix of ['a01-lesson-serviceaccounts-rbac', 'a01b-demonstration-rbac-denial', 'a02-knowledge-check', 'a02b-guided-exercise-least-privilege-role', 'a09-reflection']) { + const { status } = await bypass.request('POST', `/api/student/activities/root-09-m02-${suffix}/complete`); + assert.equal(status, 200, `completing root-09-m02-${suffix} should 200`); + } + for (const sid of ['serviceaccount-pod', 'cks-automount-token', 'rbac-role', 'cks-rbac-clusterrole', 'cks-rbac-least-privilege']) await completeScenario(bypass, sid); + + for (const suffix of ['a01-lesson-network-supplychain', 'a01b-demonstration-default-deny', 'a02-knowledge-check', 'a02b-guided-exercise-egress-plan', 'a09-reflection']) { + const { status } = await bypass.request('POST', `/api/student/activities/root-09-m03-${suffix}/complete`); + assert.equal(status, 200, `completing root-09-m03-${suffix} should 200`); + } + for (const sid of ['cks-network-policy', 'cks-egress-namespace', 'cks-tls-ingress', 'cks-image-pull-secret']) await completeScenario(bypass, sid); + + await bypass.request('POST', '/api/student/activities/root-09-m04-a01-lesson-cluster-hardening/complete'); + for (const sid of ['cks-psa-namespace', 'cks-immutable-secret']) await completeScenario(bypass, sid); + { + const { body: startBody } = await bypass.request('POST', '/api/assessments/rootnode-security-assessment/start'); + for (const s of startBody.scenarios) await completeScenario(bypass, s.scenario_id); + } + const { body: chk2 } = await bypass.request('GET', `/api/student/programs/${bypassEnroll.enrollment_id}/courses`); + assert.equal(courseById(chk2.courses, 'root-09-kubernetes-security').complete, true, 'root-09 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`); diff --git a/platform/test/e2e-phase6d.js b/platform/test/e2e-phase6d.js index 55bedff..7efef3f 100644 --- a/platform/test/e2e-phase6d.js +++ b/platform/test/e2e-phase6d.js @@ -25,7 +25,9 @@ if (!OWNER_EMAIL || !OWNER_PASSWORD) { } 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']; +// 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; @@ -99,11 +101,22 @@ async function main() { // ── 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 () => { + 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-m01-how-computers-run-software'); - assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson'); + 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 ───── diff --git a/platform/test/e2e-phase6e.js b/platform/test/e2e-phase6e.js index 1fac987..db29855 100644 --- a/platform/test/e2e-phase6e.js +++ b/platform/test/e2e-phase6e.js @@ -57,6 +57,10 @@ async function completeScenario(session, sid) { // labs/reflections later would trip the module-lock check for later modules — a real ordering // constraint, not a test artifact. async function completeRoot01(session) { + // Phase 6F — a required orientation module now gates root-01-m01, same pattern as root-05-m00. + await completeActivity(session, 'root-01-m00-a01-lesson-welcome'); + await completeActivity(session, 'root-01-m00-a02-lesson-disposable-lab'); + await completeActivity(session, 'root-01-m00-a03-knowledge-check'); await completeActivity(session, 'root-01-m01-a01-lesson'); await completeActivity(session, 'root-01-m01-a02-guided-exercise'); await completeScenario(session, 'linux-inspect-hardware'); @@ -231,10 +235,12 @@ async function main() { // ── A real student walking the full ROOT 01->02->03->04 chain end to end ──────────────── const alice = await newStudent(owner, 'Alice'); - await test('resume points at ROOT 01 module 1 activity 1 for a brand-new student', async () => { + await test('resume points at ROOT 01 orientation module activity 1 for a brand-new student', async () => { + // Phase 6F added a required orientation module in front of root-01-m01 — see + // docs/PHASE6F_ROOT_V1_PILOT_READINESS.md. const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); assert.equal(body.resume.course_id, 'root-01-computing-linux-foundations'); - assert.equal(body.resume.activity_id, 'root-01-m01-a01-lesson'); + assert.equal(body.resume.activity_id, 'root-01-m00-a01-lesson-welcome'); }); // ── 30/26/27/28/29: direct API cannot bypass prerequisites (Section 11's real finding) ── diff --git a/platform/test/e2e-phase6f.js b/platform/test/e2e-phase6f.js new file mode 100644 index 0000000..bf2aaea --- /dev/null +++ b/platform/test/e2e-phase6f.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +// Phase 6F — ROOT_ V1 Curriculum Integration & Pilot Readiness acceptance test. Covers the V1 +// manifest/exclusion decisions, the full V1 prerequisite graph (including the new +// root-capstone/root-09 edge), self-paced/instructor-led eligibility, the orientation module, +// and the platform architecture's age-independence. Same conventions as e2e-phase6a-e.js: +// assert-based, no framework, live docker-compose.dev.yml stack. +// +// 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-phase6f.js + +const assert = require('assert'); +const { execSync } = require('child_process'); +const { makeSession } = require('./session-helper'); +const { completeRootFoundations01Through04, completeThroughRoot10, completeThroughCapstoneEligible } = 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'; + +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++; } +} +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 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-6F-${tag}-${RUN_ID}`, login_identifier: `e2e6f-${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 6F ROOT_ V1 Curriculum Integration & Pilot Readiness acceptance test\n'); + const owner = makeSession(GW); + { const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); } + + // ── 1/2: the V1 manifest — inclusion/exclusion, statuses ───────────────────────────────── + let adminStructure; + await test('the V1 manifest\'s course statuses match the live database', async () => { + const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + adminStructure = body; + for (const id of ['root-01-computing-linux-foundations', 'root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker', 'root-05-kubernetes-foundations', 'root-06-kubernetes-application-workloads', 'root-07-kubernetes-networking-config', 'root-09-kubernetes-security', 'root-10-kubernetes-troubleshooting-observability', 'root-12-real-ops', 'root-capstone']) { + assert.equal(courseById(adminStructure, id).status, 'ready', `${id} should be ready`); + } + assert.equal(courseById(adminStructure, 'root-08-kubernetes-storage-scheduling').status, 'partial'); + assert.equal(courseById(adminStructure, 'root-11-devops-platform-engineering').status, 'partial'); + }); + + await test('ROOT 08 is excluded from V1 (draft) but ROOT 11 stays published as a bonus module', async () => { + // buildCourseTree() never surfaces `visibility` on the shaped course object (it's a SQL + // WHERE-clause filter only, per course-progress.js) — verified directly against Postgres. + assert.equal(psql(`SELECT visibility FROM courses WHERE course_id = 'root-08-kubernetes-storage-scheduling'`), 'draft'); + assert.equal(psql(`SELECT visibility FROM courses WHERE course_id = 'root-11-devops-platform-engineering'`), 'published'); + }); + + const fresh = await newStudent(owner, 'Fresh'); + await test('a brand-new student never sees ROOT 08 at all (visibility, not just locked)', async () => { + const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-08-kubernetes-storage-scheduling'), undefined, 'a draft course must not appear in the student response'); + assert.ok(courseById(body.courses, 'root-11-devops-platform-engineering'), 'ROOT 11 must still appear (published)'); + }); + + // ── 3/4: prerequisite graph — no cycles, no missing nodes ──────────────────────────────── + await test('the full V1 prerequisite graph has no cycles and no missing nodes (validator)', async () => { + const out = execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' }).toString(); + assert.ok(/PASS — 0 error\(s\)/.test(out), `expected 0 validation errors: ${out}`); + }); + + await test('ROOT 08 (excluded) is not referenced as a prerequisite by anything ELSE in V1', async () => { + for (const c of adminStructure) { + if (c.course_id === 'root-08-kubernetes-storage-scheduling') continue; // ROOT 08's own internal module chain referencing itself doesn't count + assert.ok(!(c.prerequisites || []).includes('root-08-kubernetes-storage-scheduling'), `${c.course_id} must not depend on excluded ROOT 08`); + for (const m of c.modules) assert.ok(!(m.prerequisites || []).some((p) => p.startsWith('root-08-')), `${m.module_id} must not depend on excluded ROOT 08`); + } + }); + + // ── 5/6: ROOT 01-04 and intended ROOT 05+ modules ready ────────────────────────────────── + await test('every ROOT 01-04 module is ready (re-verified, not inherited from Phase 6E)', async () => { + for (const c of ['root-01-computing-linux-foundations', 'root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) { + const course = courseById(adminStructure, c); + for (const m of course.modules) assert.equal(m.status, 'ready', `${m.module_id} should be ready`); + } + }); + + // ── orientation, resume, unlock ─────────────────────────────────────────────────────────── + await test('resume points at the new orientation module for a brand-new student', async () => { + const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); + 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('completing the orientation module unlocks ROOT 01 module 1', async () => { + for (const id of ['root-01-m00-a01-lesson-welcome', 'root-01-m00-a02-lesson-disposable-lab', 'root-01-m00-a03-knowledge-check']) { + const r = await fresh.session.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(r.status, 200); + } + const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.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); + }); + + // ── 11: optional practice never blocks (ROOT 09's advanced CKS labs) ───────────────────── + const optStudent = await newStudent(owner, 'OptionalCheck'); + await test('an optional activity left undone never blocks its module or course from completing', async () => { + await completeThroughCapstoneEligible(optStudent.session); + const { body } = await optStudent.session.request('GET', `/api/student/programs/${optStudent.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-09-kubernetes-security').complete, true, 'root-09 complete despite its optional advanced labs never being touched'); + assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true); + }); + + // ── 14: Capstone gating — root-09 AND root-12 both required, direct API cannot bypass ──── + await test('root-capstone stays locked with only root-12 done (root-09 also required)', async () => { + const s = await newStudent(owner, 'CapstoneGateA'); + await completeThroughRoot10(s.session); + // root-12 only, no root-09: + async function act(id) { const r = await s.session.request('POST', `/api/student/activities/${id}/complete`); assert.equal(r.status, 200, id); } + async function scen(sid) { const r = await s.session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); assert.equal(r.status, 200, sid); } + await act('root-12-m01-a00-lesson-incident-response'); + for (const sid of ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-enforce-limits']) await scen(sid); + await act('root-12-m01-a99-reflection-post-incident'); + const { body } = await s.session.request('GET', `/api/student/programs/${s.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true); + assert.equal(courseById(body.courses, 'root-capstone').locked, true, 'root-capstone must stay locked until root-09 is ALSO complete'); + const bypass = await s.session.request('POST', '/api/student/activities/root-capstone-m02-a01-lesson-what-it-measures/complete'); + assert.equal(bypass.status, 403); + assert.equal(bypass.body.error, 'locked'); + }); + + await test('root-capstone unlocks once both root-09 and root-12 are complete', async () => { + const { body } = await optStudent.session.request('GET', `/api/student/programs/${optStudent.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-capstone').locked, false); + const r = await optStudent.session.request('POST', '/api/student/activities/root-capstone-m02-a01-lesson-what-it-measures/complete'); + assert.equal(r.status, 200); + }); + + // ── 15: V1 version behavior — migrations bumped version, content unaffected ────────────── + await test('bumping a Phase 6F module\'s version again does not drop its content', async () => { + psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-06-m01-deployments-and-workloads'`); + const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + const mod = moduleById(body, 'root-06-m01-deployments-and-workloads'); + assert.ok(mod.activities.some((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro')); + }); + + // ── 16: instructor notes ───────────────────────────────────────────────────────────────── + await test('instructor notes on the new orientation and intro-lesson content are instructor-only', async () => { + const { body: adminView } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + const lesson = moduleById(adminView, 'root-01-m00-welcome-to-root').activities.find((a) => a.activity_id === 'root-01-m00-a02-lesson-disposable-lab'); + assert.ok(lesson.instructor_notes === null || lesson.instructor_notes === undefined || lesson.instructor_notes === '', 'this particular lesson has no Instructor Notes section, which is fine — it must never be fabricated'); + const bank = moduleById(adminView, 'root-06-m01-deployments-and-workloads').activities.find((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro'); + assert.ok(bank.instructor_notes && bank.instructor_notes.includes('Instructor Notes')); + // root-06-m01 is LOCKED for fresh (not yet unlocked), not DRAFT — a locked module still + // appears in the tree so a student can see what's ahead, it just never carries + // instructor_notes regardless of lock state. + const { body: studentView } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); + const studentBank = moduleById(studentView.courses, 'root-06-m01-deployments-and-workloads'); + const studentBankActivity = studentBank.activities.find((a) => a.activity_id === 'root-06-m01-a00-lab-bank-intro'); + assert.ok(!studentBankActivity.instructor_notes, 'a student must never receive instructor_notes, locked or not'); + }); + + // ── 17/18: isolation, enrollment ────────────────────────────────────────────────────────── + await test('two students on ROOT_ V1 have fully independent progress', async () => { + const a = await newStudent(owner, 'IsoA'); + const b = await newStudent(owner, 'IsoB'); + await a.session.request('POST', '/api/student/activities/root-01-m00-a01-lesson-welcome/complete'); + const [{ body: aBody }, { body: bBody }] = await Promise.all([ + a.session.request('GET', `/api/student/programs/${a.enrollmentId}/courses`), + b.session.request('GET', `/api/student/programs/${b.enrollmentId}/courses`), + ]); + assert.notEqual(moduleById(aBody.courses, 'root-01-m00-welcome-to-root').percent_complete, moduleById(bBody.courses, 'root-01-m00-welcome-to-root').percent_complete); + }); + + await test('a fresh V1 enrollment sees the complete intended course list with no gaps', async () => { + const { body } = await fresh.session.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); + const ids = body.courses.map((c) => c.course_id); + for (const id of ['root-01-computing-linux-foundations', 'root-05-kubernetes-foundations', 'root-09-kubernetes-security', 'root-11-devops-platform-engineering', 'root-12-real-ops', 'root-capstone']) { + assert.ok(ids.includes(id), `${id} should be visible to a self-paced V1 student`); + } + assert.ok(!ids.includes('root-08-kubernetes-storage-scheduling')); + }); + + // ── 19: Lab lifecycle persistence (logout/login) ───────────────────────────────────────── + await test('V1 progress survives logout and a fresh login', async () => { + await fresh.session.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: `e2e6f-Fresh-${RUN_ID}`, password: 'FreshPassword123!' }); + const { body } = await returning.request('GET', `/api/student/programs/${fresh.enrollmentId}/courses`); + assert.equal(moduleById(body.courses, 'root-01-m00-welcome-to-root').complete, true); + }); + + // ── 22: age-independent architecture — enrollment never requires or stores an age field ── + await test('enrollment succeeds with no age/grade field anywhere in the request or student record', async () => { + const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6F-NoAge-${RUN_ID}`, login_identifier: `e2e6f-noage-${RUN_ID}` }); + assert.ok(create.student_id); + const cols = psql(`SELECT column_name FROM information_schema.columns WHERE table_name = 'students' AND column_name ILIKE '%age%'`); + assert.equal(cols, '', 'the students table must not carry an age/grade gate column'); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/platform/test/foundations-helper.js b/platform/test/foundations-helper.js index ddbd65a..84e5f9c 100644 --- a/platform/test/foundations-helper.js +++ b/platform/test/foundations-helper.js @@ -15,6 +15,9 @@ async function completeRootFoundations01Through04(session) { if (r.status !== 200) throw new Error(`completing scenario ${sid} failed: ${r.status}`); } + // Phase 6F — the ROOT_ V1 orientation module (Steps 9-11) is a real, required front-door + // module now gating root-01-m01, same as root-05-m00 already gates root-05-m01. + await act('root-01-m00-a01-lesson-welcome'); await act('root-01-m00-a02-lesson-disposable-lab'); await act('root-01-m00-a03-knowledge-check'); await act('root-01-m01-a01-lesson'); await act('root-01-m01-a02-guided-exercise'); await scen('linux-inspect-hardware'); await act('root-01-m01-a04-reflection'); const l1 = { 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 act(`root-01-${m}-a01-lesson`); await act(`root-01-${m}-a02-demonstration`); await act(`root-01-${m}-a03-knowledge-check`); await act(`root-01-${m}-a04-guided-exercise`); await scen(l1[m]); await act(`root-01-${m}-a06-reflection`); } @@ -64,6 +67,9 @@ async function completeThroughRoot06(session) { 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 scen(sid); await act('root-06-m02-a01-lesson-deployments'); await act('root-06-m02-a02-demonstration-rollout'); await act('root-06-m02-a03-knowledge-check'); await act('root-06-m02-a04-guided-exercise-rollout-strategy'); await act('root-06-m02-a06-reflection'); + // Phase 6F gave root-06-m01 (a bare practice bank) a real intro-lesson activity — required, + // prerequisite root-06-m02 complete (just finished above). + await act('root-06-m01-a00-lab-bank-intro'); for (const sid of ['job-task', 'cronjob-task']) await scen(sid); await act('root-06-m03-a01-lesson-jobs-cronjobs-daemonsets'); await act('root-06-m03-a01b-demonstration-job-lifecycle'); await act('root-06-m03-a02-knowledge-check'); await act('root-06-m03-a02b-guided-exercise-choosing-primitive'); await act('root-06-m03-a05-reflection'); } @@ -79,7 +85,41 @@ async function completeThroughRoot10(session) { await scen('rootnode-probe-port-mismatch'); await act('root-10-m01-a06-reflection-real-ops-takeaway'); await act('root-10-m03-a01-lesson-framework'); await act('root-10-m03-a02-demonstration-crashloop'); await act('root-10-m03-a03-knowledge-check'); await act('root-10-m03-a03b-guided-exercise-endpoint-mismatch'); await scen('crashloop-fix'); await scen('endpoint-fix-task'); await act('root-10-m03-a06-reflection'); + // Phase 6F gave root-10-m02 (a bare practice bank) a real intro-lesson activity — required, + // prerequisite root-10-m03 complete (just finished above). + await act('root-10-m02-a00-lab-bank-intro'); for (const sid of ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq', 'container-logging-mcq']) await scen(sid); } -module.exports = { completeRootFoundations01Through04, completeThroughRoot06, completeThroughRoot10 }; +// Phase 6F — completes through ROOT 10, then ROOT 07 (m03/m04, needed by root-09-m03's +// cross-course prerequisite), ROOT 09 (all 4 modules + its assessment), and ROOT 12, leaving +// the student's root-capstone COURSE genuinely unlocked (both its real prerequisites — +// root-12-real-ops and root-09-kubernetes-security — satisfied) without touching the Capstone +// itself. See docs/ROOT_V1_CURRICULUM_MANIFEST.md Step 7 for why root-09 is now required. +async function completeThroughCapstoneEligible(session) { + await completeThroughRoot10(session); + async function act(id) { const r = await session.request('POST', `/api/student/activities/${id}/complete`); if (r.status !== 200) throw new Error(`completing ${id} failed: ${r.status} ${JSON.stringify(r.body)}`); } + async function scen(sid) { const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); if (r.status !== 200) throw new Error(`completing scenario ${sid} failed: ${r.status}`); } + + for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) await act(`root-07-m03-${suffix}`); + await scen('expose-service-basics'); // root-07-m03's own lab — its OWN prerequisite for root-07-m04, easy to miss since ROOT 07's practice bank reuses the same scenario id. + for (const suffix of ['a01-lesson-ingress-networkpolicy', 'a01b-demonstration-ingress-routing', 'a02-knowledge-check', 'a02b-guided-exercise-network-isolation', 'a05-reflection']) await act(`root-07-m04-${suffix}`); + await scen('ingress-task'); await scen('network-policy'); + + for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan', 'a09-reflection']) await act(`root-09-m01-${suffix}`); + for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await scen(sid); + for (const suffix of ['a01-lesson-serviceaccounts-rbac', 'a01b-demonstration-rbac-denial', 'a02-knowledge-check', 'a02b-guided-exercise-least-privilege-role', 'a09-reflection']) await act(`root-09-m02-${suffix}`); + for (const sid of ['serviceaccount-pod', 'cks-automount-token', 'rbac-role', 'cks-rbac-clusterrole', 'cks-rbac-least-privilege']) await scen(sid); + for (const suffix of ['a01-lesson-network-supplychain', 'a01b-demonstration-default-deny', 'a02-knowledge-check', 'a02b-guided-exercise-egress-plan', 'a09-reflection']) await act(`root-09-m03-${suffix}`); + for (const sid of ['cks-network-policy', 'cks-egress-namespace', 'cks-tls-ingress', 'cks-image-pull-secret']) await scen(sid); + await act('root-09-m04-a01-lesson-cluster-hardening'); + for (const sid of ['cks-psa-namespace', 'cks-immutable-secret']) await scen(sid); + const r = await session.request('POST', '/api/assessments/rootnode-security-assessment/start'); + for (const s of r.body.scenarios) await scen(s.scenario_id); + + await act('root-12-m01-a00-lesson-incident-response'); + for (const sid of ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-enforce-limits']) await scen(sid); + await act('root-12-m01-a99-reflection-post-incident'); +} + +module.exports = { completeRootFoundations01Through04, completeThroughRoot06, completeThroughRoot10, completeThroughCapstoneEligible };