From 903b0ead43c1ec8439b53c2e4dfb9b780adf455f Mon Sep 17 00:00:00 2001 From: ced4568 Date: Wed, 26 Aug 2026 11:20:37 -0500 Subject: [PATCH] test(phase6c): acceptance suite, real-k3s rehearsal, and 6A/6B staleness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds platform/test/e2e-phase6c.js (17 checks): upgraded-module activity chains, documented-exception promotions, optional-lab non-blocking behavior, no-fake-completion-from-viewing, idempotent completion, instructor-notes exclusion and RBAC on new content, security-assessment pool/draw/cross-student isolation/completion, the capstone gate's documented indifference to ROOT 09, ROOT 11's honest partial/lesson-only rendering, and version-bump content stability — plus running validate-course-content.js as part of the suite. All 17 pass against both a warm and a freshly-reset dev stack. Adds infra/scripts/drills/{run,test}-phase6c-security-config-rehearsal — Section 22's real-k3s dress rehearsal: login -> program -> course -> lesson -> demonstration -> knowledge-check -> guided-exercise -> real Lab -> real kubectl (create Secret, apply Pod, wait for Ready) -> validation -> progress persistence across logout/login -> instructor visibility of the resulting learning state, walking root-07-m02-configuration-and-secrets (upgraded from PARTIAL this phase). 10/10 pass against a real k3s Lab, no mocking. Fixes two stale hardcoded assumptions in e2e-phase6a.js and e2e-phase6b.js that Phase 6C's own legitimate content changes broke: the gold module (root-10-m01) now has 7 activities (6 required + 1 new optional lab, not 6), and root-05-m01 now has 2 required manual-marker activities the course- completion path must also complete (its new bookending lesson/reflection). Both suites are back to green (21/21 and 24/24) against a fresh stack. Co-Authored-By: Claude Sonnet 5 --- .../run-phase6c-security-config-rehearsal.sh | 33 +++ .../test-phase6c-security-config-rehearsal.js | 191 ++++++++++++++ platform/test/e2e-phase6a.js | 22 +- platform/test/e2e-phase6b.js | 10 +- platform/test/e2e-phase6c.js | 243 ++++++++++++++++++ 5 files changed, 491 insertions(+), 8 deletions(-) create mode 100755 infra/scripts/drills/run-phase6c-security-config-rehearsal.sh create mode 100644 infra/scripts/drills/test-phase6c-security-config-rehearsal.js create mode 100644 platform/test/e2e-phase6c.js diff --git a/infra/scripts/drills/run-phase6c-security-config-rehearsal.sh b/infra/scripts/drills/run-phase6c-security-config-rehearsal.sh new file mode 100755 index 0000000..86881dc --- /dev/null +++ b/infra/scripts/drills/run-phase6c-security-config-rehearsal.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 6C §22 curriculum-completion dress rehearsal: brings up the real dev +# stack, bootstraps an owner, runs test-phase6c-security-config-rehearsal.js (real k3s Lab, real +# kubectl, real instructor visibility check), tears down. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform" + +export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}" +export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}" + +docker compose -f docker-compose.dev.yml up -d --build + +cleanup() { + echo "==> Tearing down" + docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Waiting for the stack" +for _ in $(seq 1 60); do + curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break + sleep 2 +done + +echo "==> Bootstrapping the rehearsal owner" +( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \ + node bootstrap-owner.js p6c-rehearsal-owner@rootnode.test "P6C Rehearsal Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p6c-rehearsal-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase6c-security-config-rehearsal.js diff --git a/infra/scripts/drills/test-phase6c-security-config-rehearsal.js b/infra/scripts/drills/test-phase6c-security-config-rehearsal.js new file mode 100644 index 0000000..4e23f61 --- /dev/null +++ b/infra/scripts/drills/test-phase6c-security-config-rehearsal.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Phase 6C §22 — the curriculum-completion dress rehearsal. One continuous realistic student +// narrative through a module Phase 6C actually upgraded this phase (ROOT 07's Configuration & +// Secrets, converted in place from a bare 5-lab bank to a full gold-pattern module), using a +// REAL k3s Lab end to end: login -> program -> course -> lesson -> demonstration -> knowledge +// check -> guided exercise -> real Lab -> real kubectl -> validation -> progress persistence, +// then an instructor narrative confirming the resulting learning state is visible. Same +// conventions as test-phase6b-root-course-rehearsal.js: assert-based, no framework, real k3s +// boots, no mocking. +// +// Run via run-phase6c-security-config-rehearsal.sh, which brings up +// platform/docker-compose.dev.yml first. + +const assert = require('assert'); +const { execSync } = require('child_process'); +const { makeSession } = require('../../../platform/test/session-helper'); + +const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; +const OWNER_EMAIL = process.env.OWNER_EMAIL; +const OWNER_PASSWORD = process.env.OWNER_PASSWORD; +if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); } +const RUN_ID = Date.now(); +const PROGRAM = 'root-kubernetes-foundations'; +const CONFIG_MODULE = 'root-07-m02-configuration-and-secrets'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); console.log(` ok — ${name}`); passed++; } + catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; } +} +function moduleFrom(tree, id) { for (const c of tree) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; } + +async function main() { + console.log('Phase 6C curriculum-completion dress rehearsal — slow, real k3s boots\n'); + + const owner = makeSession(GW); + await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + + const { body: created } = await owner.request('POST', '/api/admin/students', { + display_name: 'Rehearsal 6C Student', login_identifier: `p6c-config-${RUN_ID}`, + }); + const session = makeSession(GW); + let enrollmentId, envId; + + await step('student activates credentials and logs in', async () => { + const { status } = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' }); + assert.equal(status, 200); + }); + + await step('an instructor enrolls the student in the ROOT_ program', async () => { + const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' }); + assert.equal(status, 201); + enrollmentId = body.enrollment_id; + }); + + 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']; + const ROOT06_ALL = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', + 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq', 'deployment-rollback', 'job-task', 'cronjob-task']; + const ROOT07_SERVICES = ['services-mcq', 'expose-service-basics']; + + await step('student launches a REAL Lab and completes ROOT 05/06 plus ROOT 07\'s Services module to unlock Configuration & Secrets', async () => { + const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`); + assert.equal(launch.status, 200); + assert.equal(launch.body.status, 'running'); + const envRow = (await owner.request('GET', `/api/admin/students/${created.student_id}/environment`)).body.find((e) => e.status === 'running'); + envId = envRow.environment_id; + for (const sid of [...ROOT05_ALL, ...ROOT06_ALL, ...ROOT07_SERVICES]) { + const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId }); + assert.equal(r.status, 200, `completing ${sid} should 200`); + } + for (const suffix of ['a01-lesson-mental-model', 'a02-demonstration-desired-state', 'a03-knowledge-check', 'a04-guided-exercise-namespaces', 'a06-reflection']) { + assert.equal((await session.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`)).status, 200); + } + for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) { + assert.equal((await session.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`)).status, 200); + } + for (const suffix of ['a01-lesson-deployments', 'a02-demonstration-rollout', 'a03-knowledge-check', 'a04-guided-exercise-rollout-strategy', 'a06-reflection']) { + assert.equal((await session.request('POST', `/api/student/activities/root-06-m02-${suffix}/complete`)).status, 200); + } + for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a01b-demonstration-job-lifecycle', 'a02-knowledge-check', 'a02b-guided-exercise-choosing-primitive', 'a05-reflection']) { + assert.equal((await session.request('POST', `/api/student/activities/root-06-m03-${suffix}/complete`)).status, 200); + } + for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) { + assert.equal((await session.request('POST', `/api/student/activities/root-07-m03-${suffix}/complete`)).status, 200); + } + }); + + await step('the Phase 6C-upgraded Configuration & Secrets module is now unlocked with real lesson content', async () => { + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + const mod = moduleFrom(body.courses, CONFIG_MODULE); + assert.equal(mod.locked, false); + assert.equal(mod.activities[0].activity_type, 'lesson'); + assert.ok(mod.activities[0].body.includes('Secret'), 'the real lesson content must actually be served'); + }); + + await step('student reads the lesson and demonstration, completes the knowledge check and guided exercise', async () => { + for (const suffix of ['a00-lesson-configuration', 'a00b-demonstration-secret-not-encrypted', 'a00c-knowledge-check', 'a00d-guided-exercise-choosing-mechanism']) { + const { status } = await session.request('POST', `/api/student/activities/root-07-m02-${suffix}/complete`); + assert.equal(status, 200); + } + }); + + await step('student launches the module\'s Secrets Lab for real and runs real kubectl against it', async () => { + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + const mod = moduleFrom(body.courses, CONFIG_MODULE); + const lab = mod.activities.find((a) => a.scenario_id === 'secrets-basics'); + assert.equal(lab.complete, false); + + const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`); + const labToken = launch.body.launch_path.match(/\/lab\/([^/]+)\//)[1]; + const setup = await session.request('POST', `/lab/${labToken}/api/scenarios/secrets-basics/setup`); + assert.equal(setup.status, 200); + + const containerName = execSync( + `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "SELECT container_name FROM environments WHERE environment_id = '${envId}'"`, + { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' } + ).toString().trim(); + // Real kubectl, run inside the student's actual disposable Lab container. + execSync(`docker exec ${containerName} kubectl create secret generic app-secret --from-literal=api-key=supersecret`, { stdio: 'pipe' }); + execSync(`docker exec ${containerName} sh -c "cat <<'EOF' | kubectl apply -f - +apiVersion: v1 +kind: Pod +metadata: + name: secret-reader +spec: + containers: + - name: reader + image: busybox:1.36 + command: [\\"sleep\\",\\"3600\\"] + env: + - name: API_KEY + valueFrom: + secretKeyRef: + name: app-secret + key: api-key +EOF"`, { stdio: 'pipe' }); + + // Give the Pod a moment to reach Running before validating, same as a real student would. + execSync(`docker exec ${containerName} kubectl wait --for=condition=Ready pod/secret-reader --timeout=60s`, { stdio: 'pipe' }); + + const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/secrets-basics/validate`); + assert.equal(validate.status, 200); + assert.equal(validate.body.passed, true, `expected the real validation to pass: ${JSON.stringify(validate.body)}`); + + const record = await session.request('POST', '/api/progress/record', { scenario_id: 'secrets-basics', status: 'completed', environment_id: envId }); + assert.equal(record.status, 200); + }); + + await step('the remaining labs and reflection complete the module', async () => { + for (const sid of ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'pod-env-vars-inline']) { + const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId }); + assert.equal(r.status, 200); + } + const { body: before } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(moduleFrom(before.courses, CONFIG_MODULE).activities.find((a) => a.scenario_id === 'secrets-basics').complete, true); + await session.request('POST', '/api/student/activities/root-07-m02-a99-reflection/complete'); + const { body: after } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(moduleFrom(after.courses, CONFIG_MODULE).complete, true); + }); + + await step('logout, login, Resume Learning — the Phase 6C module\'s progress survives all of it', async () => { + await session.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: `p6c-config-${RUN_ID}`, password: 'RehearsalPass123!' }); + const { body: resumed } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(moduleFrom(resumed.courses, CONFIG_MODULE).complete, true, 'progress must survive a real logout/login'); + }); + + // ── Instructor narrative — Section 22's "an instructor can see the resulting learning state" ─ + await step('instructor inspects the Phase 6C module structure and its instructor notes', async () => { + const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + assert.equal(status, 200); + const mod = moduleFrom(body, CONFIG_MODULE); + assert.equal(mod.activities.length, 10); + assert.ok(mod.activities[0].instructor_notes, 'instructor must see instructor notes the student view never does'); + }); + + await step('instructor sees this specific student\'s real, completed progress through the Phase 6C module', async () => { + const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`); + assert.equal(status, 200); + assert.equal(moduleFrom(body, CONFIG_MODULE).complete, true); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/platform/test/e2e-phase6a.js b/platform/test/e2e-phase6a.js index 01f882e..8896193 100644 --- a/platform/test/e2e-phase6a.js +++ b/platform/test/e2e-phase6a.js @@ -83,12 +83,15 @@ async function main() { assert.equal(gold.status, 'partial'); // real content (gold module) alongside un-authored modules }); - await test('the gold-standard module is fully READY with 6 ordered activities and real content', async () => { + await test('the gold-standard module is fully READY with 7 ordered activities and real content', async () => { + // Phase 6C added one OPTIONAL practice lab (readiness-probe-task) after the original + // required lab — see docs/PHASE6C_CURRICULUM_COMPLETION.md. The required teaching chain + // (lesson/demo/kc/guided/lab/reflection) is otherwise byte-for-byte unchanged. const goldModule = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting'); assert.equal(goldModule.status, 'ready'); - assert.equal(goldModule.activities.length, 6); + assert.equal(goldModule.activities.length, 7); const types = goldModule.activities.map((a) => a.activity_type); - assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'reflection']); + assert.deepEqual(types, ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection']); for (let i = 1; i < goldModule.activities.length; i++) assert.ok(goldModule.activities[i].sequence > goldModule.activities[i - 1].sequence); const lesson = goldModule.activities[0]; assert.ok(lesson.body && lesson.body.length > 500, 'lesson content must actually be loaded from Markdown, not empty'); @@ -149,6 +152,13 @@ async function main() { const { status } = await casey.request('POST', `/api/student/activities/root-05-m00-${suffix}/complete`); assert.equal(status, 200); } + // Phase 6C added a bookending lesson + reflection around root-05-m01's 20-task kubectl- + // fluency bank (see docs/PHASE6C_CURRICULUM_COMPLETION.md) — both are required manual + // markers, so root-05 isn't complete until they're marked too. + for (const suffix of ['a00-lesson-kubectl-fluency', 'a21-reflection']) { + const { status } = await casey.request('POST', `/api/student/activities/root-05-m01-${suffix}/complete`); + assert.equal(status, 200); + } const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); const root05 = courseById(body.courses, 'root-05-kubernetes-foundations'); assert.equal(root05.complete, true); @@ -187,8 +197,8 @@ async function main() { assert.equal(status, 200); const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); const goldModule = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting'); - assert.equal(goldModule.complete, true); - assert.equal(goldModule.percent_complete, 100); + assert.equal(goldModule.complete, true, 'a module is complete once every REQUIRED activity is done, regardless of the one optional lab left untouched'); + assert.equal(goldModule.percent_complete, Math.round((6 / 7) * 100), 'percent_complete reflects ALL activities, optional included — Phase 6C added one optional lab'); // Phase 6B inserted root-10-m03 (the troubleshooting framework module) between the gold // module and the pre-existing bare-lab bank — m02's prerequisite is now m03, not m01 // directly. See docs/PHASE6B_ROOT_CURRICULUM_EXPANSION.md. @@ -301,7 +311,7 @@ async function main() { psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-10-m01-health-probes-and-troubleshooting'`); const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); const goldModule = moduleById(body, 'root-10-m01-health-probes-and-troubleshooting'); - assert.equal(goldModule.activities.length, 6, 'a version bump must never drop activities'); + assert.equal(goldModule.activities.length, 7, 'a version bump must never drop activities'); assert.ok(goldModule.activities[0].body && goldModule.activities[0].body.length > 500, 'content must still load after a version bump'); }); diff --git a/platform/test/e2e-phase6b.js b/platform/test/e2e-phase6b.js index 251252e..e4d0ea9 100644 --- a/platform/test/e2e-phase6b.js +++ b/platform/test/e2e-phase6b.js @@ -90,10 +90,11 @@ async function main() { assert.equal(courseById(structure, 'root-09-kubernetes-security').status, 'partial'); }); - await test('the gold-standard Health Probes module is byte-for-byte unchanged (still 6 activities, still READY)', async () => { + 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, 6); + 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 () => { @@ -144,6 +145,11 @@ async function main() { 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); diff --git a/platform/test/e2e-phase6c.js b/platform/test/e2e-phase6c.js new file mode 100644 index 0000000..93ef2b6 --- /dev/null +++ b/platform/test/e2e-phase6c.js @@ -0,0 +1,243 @@ +#!/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); });