From 59edceea1f5b503d275f53a5d5195a4b3413d79f Mon Sep 17 00:00:00 2001 From: ced4568 Date: Wed, 26 Aug 2026 17:08:48 -0500 Subject: [PATCH] test(phase6e): acceptance suite, real-Lab rehearsal, and regression fixes for the lock fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds platform/test/e2e-phase6e.js (23 checks): full ROOT 02/03/04 hierarchy/content/assessment- pool verification, the complete ROOT 01->02->03->04->05 chain proven on one student (each course's completion unlocking exactly the next), the direct-API lock bypass proven blocked on both a Phase 6E course pair and a pre-existing unrelated pair, existing ROOT 06+/Capstone/ is_required behavior confirmed unaffected, self-paced no-instructor-deferral scan, instructor-notes exclusion, RBAC, persistence, isolation, version stability, content validation. 23/23 pass against both a warm and a freshly-reset stack. Adds platform/test/foundations-helper.js — a shared completeRootFoundations01Through04/ completeThroughRoot06/completeThroughRoot10 helper, extracted once multiple suites needed the identical real-completion sequence as test setup, rather than duplicating it four times. Adds infra/scripts/drills/{run,test}-phase6e-foundations-rehearsal — the real Platform API/ Gateway/Provisioner/disposable-Lab/nested-Docker/Git/k3s dress rehearsal: one real Lab task per new course (nginx inspection, a real merge conflict, a real container-networking fix) plus the full chain to a genuine ROOT 05 unlock, with k3s reconfirmed healthy and untouched at the end. 13/13 pass against a real, freshly-built stack. Fixes the regression fallout from the (correct) course-routes.js lock fix: several earlier suites' own historical shortcuts had accumulated small ordering/scenario gaps the missing enforcement had been silently covering for — none were platform bugs. e2e-phase6a/6b/6c/6d.js now inject the real ROOT 01-04 (and where needed ROOT 05/06/10) completion sequence via foundations-helper.js before touching ROOT 05+ content; e2e-phase6b.js's bypass-student flow now completes deployment-rollback/job-task/cronjob-task/container-logging-mcq explicitly (real scenarios added in later phases that its original ALL_STAGES list never included); e2e-phase6a/ 6d.js's own ROOT 01/05 completion tests now complete each module fully (through its lab and reflection) before moving to the next, since those courses' own modules carry real intra-course prerequisites. Two now-obsolete Phase-6D-era assertions in e2e-phase6d.js (ROOT 02-04 stay PLANNED / stay vacuously unlocked) are updated to assert the opposite, with a comment explaining Phase 6E intentionally changed that fact. All four suites re-verified green fresh. Also fixes a real, reproducible dash-vs-bash bug caught while testing the new ROOT 01 rehearsal's ordering fix: `&>file` misparses under this image's actual /bin/sh (dash), breaking a later `$!`-based kill — the rehearsal script now writes multi-line completion loops with explicit status assertions, matching the discipline the other suites already use. Co-Authored-By: Claude Sonnet 5 --- .../run-phase6e-foundations-rehearsal.sh | 34 ++ .../test-phase6d-foundations-rehearsal.js | 16 +- .../test-phase6e-foundations-rehearsal.js | 275 +++++++++++++ platform/test/e2e-phase6a.js | 36 +- platform/test/e2e-phase6b.js | 82 +++- platform/test/e2e-phase6c.js | 9 + platform/test/e2e-phase6d.js | 35 +- platform/test/e2e-phase6e.js | 377 ++++++++++++++++++ platform/test/foundations-helper.js | 85 ++++ 9 files changed, 922 insertions(+), 27 deletions(-) create mode 100755 infra/scripts/drills/run-phase6e-foundations-rehearsal.sh create mode 100644 infra/scripts/drills/test-phase6e-foundations-rehearsal.js create mode 100644 platform/test/e2e-phase6e.js create mode 100644 platform/test/foundations-helper.js diff --git a/infra/scripts/drills/run-phase6e-foundations-rehearsal.sh b/infra/scripts/drills/run-phase6e-foundations-rehearsal.sh new file mode 100755 index 0000000..e8ba64a --- /dev/null +++ b/infra/scripts/drills/run-phase6e-foundations-rehearsal.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 6E §24 ROOT Foundations Completion dress rehearsal: brings up the real +# dev stack, bootstraps an owner, runs test-phase6e-foundations-rehearsal.js (real disposable +# Lab, real nested Docker, real Git, real k3s left untouched), 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}" +export LAB_IMAGE_REF="${LAB_IMAGE_REF:-rootnode-academy:test}" + +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 p6e-rehearsal-owner@rootnode.test "P6E Rehearsal Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p6e-rehearsal-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + node ../infra/scripts/drills/test-phase6e-foundations-rehearsal.js diff --git a/infra/scripts/drills/test-phase6d-foundations-rehearsal.js b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js index 82f46a1..ee7dac9 100644 --- a/infra/scripts/drills/test-phase6d-foundations-rehearsal.js +++ b/infra/scripts/drills/test-phase6d-foundations-rehearsal.js @@ -150,16 +150,20 @@ async function main() { await step('completing the remaining ROOT 01 activities finishes the course and unlocks ROOT 02', async () => { await session.request('POST', '/api/student/activities/root-01-m01-a04-reflection/complete'); + // ROOT 01's own modules carry real intra-course prerequisites (m02 requires m01 complete, + // etc., found and enforced this phase — see docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md) — + // each module must complete fully, including its lab and reflection, before the next + // module's activities will accept a manual-marker completion. + const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' }; for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) { for (const suffix of ['a01-lesson', 'a02-demonstration', 'a03-knowledge-check', 'a04-guided-exercise']) { - await session.request('POST', `/api/student/activities/root-01-${m}-${suffix}/complete`); + const r = await session.request('POST', `/api/student/activities/root-01-${m}-${suffix}/complete`); + assert.equal(r.status, 200, `completing root-01-${m}-${suffix} should 200: ${JSON.stringify(r.body)}`); } + await session.request('POST', '/api/progress/record', { scenario_id: labs[m], status: 'completed', environment_id: envId }); + const r = await session.request('POST', `/api/student/activities/root-01-${m}-a06-reflection/complete`); + assert.equal(r.status, 200, `completing root-01-${m}-a06-reflection should 200: ${JSON.stringify(r.body)}`); } - for (const [sid, activity] of [['linux-shell-basics', 'root-01-m02-a06-reflection'], ['linux-filesystem-navigation', 'root-01-m03-a06-reflection'], ['linux-files-and-text', 'root-01-m04-a06-reflection'], ['linux-process-management', 'root-01-m05-a06-reflection']]) { - await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId }); - await session.request('POST', `/api/student/activities/${activity}/complete`); - } - await session.request('POST', '/api/student/activities/root-01-m06-a06-reflection/complete'); const start = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); assert.equal(start.status, 201); diff --git a/infra/scripts/drills/test-phase6e-foundations-rehearsal.js b/infra/scripts/drills/test-phase6e-foundations-rehearsal.js new file mode 100644 index 0000000..2367508 --- /dev/null +++ b/infra/scripts/drills/test-phase6e-foundations-rehearsal.js @@ -0,0 +1,275 @@ +#!/usr/bin/env node +// Phase 6E §24 — the ROOT Foundations Completion dress rehearsal. One continuous realistic +// student narrative through ROOT 02, ROOT 03, and ROOT 04, using the REAL Platform API, REAL +// Gateway, REAL Provisioner, REAL disposable Lab, REAL shell, REAL Git, and REAL nested Docker +// (built and tested in Phase 6D) end to end — no mocking of the important path. Exercises one +// genuinely representative real Lab task per course (service inspection, a real merge conflict, +// and a real container networking fix) rather than re-running every single lab for real — the +// non-real-Lab e2e-phase6e.js suite already proves full completion logic exhaustively; this +// script's job is proving the underlying real mechanisms work end to end, matching the +// established convention from test-phase6c/6d-*-rehearsal.js. +// +// Run via run-phase6e-foundations-rehearsal.sh, which brings up platform/docker-compose.dev.yml +// first. + +const assert = require('assert'); +const fs = require('fs'); +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'; + +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; } +function courseFrom(tree, id) { return tree.find((c) => c.course_id === id); } +async function completeActivity(session, id) { + const r = await session.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(r.status, 200, `completing ${id} should 200: ${JSON.stringify(r.body)}`); +} +async function completeScenario(session, sid, envId) { + const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed', environment_id: envId }); + assert.equal(r.status, 200, `completing scenario ${sid} should 200`); +} +function runInContainer(containerName, script) { + const scriptPath = `/tmp/phase6e-rehearsal-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`; + fs.writeFileSync(scriptPath, `#!/bin/bash\nset -e\n${script}\n`); + const out = execSync(`docker cp ${scriptPath} ${containerName}:/tmp/rehearsal-step.sh && docker exec ${containerName} bash /tmp/rehearsal-step.sh`, { encoding: 'utf8' }); + fs.unlinkSync(scriptPath); + return out; +} + +async function main() { + console.log('Phase 6E ROOT Foundations Completion dress rehearsal — real Lab, real Docker, real Git\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 6E Student', login_identifier: `p6e-foundations-${RUN_ID}` }); + const session = makeSession(GW); + let enrollmentId, envId, containerName; + + await step('student activates credentials, logs in, enrolls', async () => { + const act = await session.request('POST', '/api/student/activate', { token: created.activation_token, password: 'RehearsalPass123!' }); + assert.equal(act.status, 200); + const enroll = await owner.request('POST', '/api/admin/enrollments', { student_id: created.student_id, program_id: PROGRAM, status: 'active' }); + assert.equal(enroll.status, 201); + enrollmentId = enroll.body.enrollment_id; + }); + + await step('student launches a REAL disposable Lab', 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; + 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(); + }); + + // ── 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 () => { + 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'); + const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' }; + for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) { + for (const suffix of ['a01-lesson', 'a02-demonstration', 'a03-knowledge-check', 'a04-guided-exercise']) await completeActivity(session, `root-01-${m}-${suffix}`); + await completeScenario(session, labs[m], envId); + await completeActivity(session, `root-01-${m}-a06-reflection`); + } + const start = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); + for (const s of start.body.scenarios) await completeScenario(session, s.scenario_id, envId); + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseFrom(body.courses, 'root-01-computing-linux-foundations').complete, true); + assert.equal(courseFrom(body.courses, 'root-02-linux-administration-networking').locked, false); + }); + + // ── ROOT 02 — one REAL Lab task: inspect the real nginx service, real ss/curl commands ──── + await step('student solves the real linux-service-inspection Lab using real shell commands', async () => { + await completeActivity(session, 'root-02-m01-a01-lesson'); + await completeActivity(session, 'root-02-m01-a02-demonstration'); + await completeActivity(session, 'root-02-m01-a03-knowledge-check'); + await completeActivity(session, 'root-02-m01-a04-guided-exercise'); + + 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/linux-service-inspection/setup`); + assert.equal(setup.status, 200); + + runInContainer(containerName, ` +mkdir -p /srv/service-lab +printf "LISTENING_PORT=80\\nCONFIG_PATH=/etc/nginx/nginx.conf\\nLOG_DIR=/var/log/nginx\\n" > /srv/service-lab/report.txt`); + + const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/linux-service-inspection/validate`); + assert.equal(validate.status, 200); + assert.equal(validate.body.passed, true, `expected real validation to pass: ${JSON.stringify(validate.body)}`); + await completeScenario(session, 'linux-service-inspection', envId); + await completeScenario(session, 'linux-service-lifecycle', envId); + await completeActivity(session, 'root-02-m01-a07-reflection'); + }); + + await step('completing the rest of ROOT 02 (shortcut) finishes the course and unlocks ROOT 03', async () => { + await completeActivity(session, 'root-02-m02-a01-lesson'); await completeActivity(session, 'root-02-m02-a02-demonstration'); + await completeActivity(session, 'root-02-m02-a03-knowledge-check'); await completeActivity(session, 'root-02-m02-a04-guided-exercise'); + await completeScenario(session, 'linux-storage-inspection', envId); await completeActivity(session, 'root-02-m02-a06-reflection'); + await completeActivity(session, 'root-02-m03-a01-lesson'); await completeActivity(session, 'root-02-m03-a02-demonstration'); + await completeActivity(session, 'root-02-m03-a03-knowledge-check'); await completeActivity(session, 'root-02-m03-a04-guided-exercise'); + await completeActivity(session, 'root-02-m03-a05-reflection'); + await completeActivity(session, 'root-02-m04-a01-lesson'); await completeActivity(session, 'root-02-m04-a02-demonstration'); + await completeActivity(session, 'root-02-m04-a03-knowledge-check'); await completeActivity(session, 'root-02-m04-a04-guided-exercise'); + await completeScenario(session, 'linux-network-inspection', envId); await completeActivity(session, 'root-02-m04-a06-reflection'); + await completeActivity(session, 'root-02-m05-a01-lesson'); await completeActivity(session, 'root-02-m05-a02-demonstration'); + await completeActivity(session, 'root-02-m05-a03-knowledge-check'); await completeActivity(session, 'root-02-m05-a04-guided-exercise'); + await completeScenario(session, 'linux-dns-http-diagnosis', envId); await completeActivity(session, 'root-02-m05-a06-reflection'); + await completeActivity(session, 'root-02-m06-a01-lesson'); await completeActivity(session, 'root-02-m06-a02-demonstration'); + await completeActivity(session, 'root-02-m06-a03-knowledge-check'); await completeActivity(session, 'root-02-m06-a04-guided-exercise'); + await completeScenario(session, 'linux-troubleshooting-framework', envId); await completeActivity(session, 'root-02-m06-a06-reflection'); + const start = await session.request('POST', '/api/assessments/rootnode-linux-networking-assessment/start'); + for (const s of start.body.scenarios) await completeScenario(session, s.scenario_id, envId); + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseFrom(body.courses, 'root-02-linux-administration-networking').complete, true); + assert.equal(courseFrom(body.courses, 'root-03-git-collaborative-development').locked, false); + }); + + // ── ROOT 03 — one REAL Lab task: a real merge conflict, resolved with real git commands ─── + await step('student resolves a REAL merge conflict using real git commands', async () => { + await completeActivity(session, 'root-03-m01-a01-lesson'); await completeActivity(session, 'root-03-m01-a02-knowledge-check'); await completeActivity(session, 'root-03-m01-a03-reflection'); + await completeActivity(session, 'root-03-m02-a01-lesson'); await completeActivity(session, 'root-03-m02-a02-demonstration'); + await completeActivity(session, 'root-03-m02-a03-knowledge-check'); await completeActivity(session, 'root-03-m02-a04-guided-exercise'); await completeActivity(session, 'root-03-m02-a05-reflection'); + await completeActivity(session, 'root-03-m03-a01-lesson'); await completeActivity(session, 'root-03-m03-a02-demonstration'); + await completeActivity(session, 'root-03-m03-a03-knowledge-check'); await completeActivity(session, 'root-03-m03-a04-guided-exercise'); + await completeScenario(session, 'git-everyday-basics', envId); await completeActivity(session, 'root-03-m03-a06-reflection'); + await completeActivity(session, 'root-03-m04-a01-lesson'); await completeActivity(session, 'root-03-m04-a02-demonstration'); + await completeActivity(session, 'root-03-m04-a03-knowledge-check'); await completeActivity(session, 'root-03-m04-a04-guided-exercise'); + + 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/git-branching-and-merge/setup`); + assert.equal(setup.status, 200); + + // Confirm the REAL conflict exists before resolving it. + const conflictCheck = runInContainer(containerName, `grep -c '<<<<<<<' /srv/git-lab-branch/shared.txt || true`); + assert.ok(conflictCheck.trim() !== '0', 'expected a real, unresolved merge conflict to exist before the fix'); + + runInContainer(containerName, ` +cd /srv/git-lab-branch +echo "resolved version" > shared.txt +git add shared.txt +git commit -q -m "Merge feature, resolve conflict"`); + + const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/git-branching-and-merge/validate`); + assert.equal(validate.status, 200); + assert.equal(validate.body.passed, true, `expected real validation to pass: ${JSON.stringify(validate.body)}`); + await completeScenario(session, 'git-branching-and-merge', envId); + await completeActivity(session, 'root-03-m04-a06-reflection'); + }); + + await step('completing the rest of ROOT 03 (shortcut) finishes the course and unlocks ROOT 04', async () => { + await completeActivity(session, 'root-03-m05-a01-lesson'); await completeActivity(session, 'root-03-m05-a02-demonstration'); + await completeActivity(session, 'root-03-m05-a03-knowledge-check'); await completeActivity(session, 'root-03-m05-a04-guided-exercise'); + await completeScenario(session, 'git-remote-clone-push', envId); await completeActivity(session, 'root-03-m05-a06-reflection'); + await completeActivity(session, 'root-03-m06-a01-lesson'); await completeActivity(session, 'root-03-m06-a02-demonstration'); + await completeActivity(session, 'root-03-m06-a03-knowledge-check'); await completeActivity(session, 'root-03-m06-a04-reflection'); + const start = await session.request('POST', '/api/assessments/rootnode-git-fundamentals-assessment/start'); + for (const s of start.body.scenarios) await completeScenario(session, s.scenario_id, envId); + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseFrom(body.courses, 'root-03-git-collaborative-development').complete, true); + assert.equal(courseFrom(body.courses, 'root-04-containers-and-docker').locked, false); + }); + + // ── ROOT 04 — one REAL Lab task: real nested Docker, a real port-publishing diagnosis+fix ─ + await step('student diagnoses and fixes a REAL container networking failure using nested Docker', async () => { + await completeActivity(session, 'root-04-m01-a01-lesson'); await completeActivity(session, 'root-04-m01-a02-demonstration'); + await completeActivity(session, 'root-04-m01-a03-knowledge-check'); await completeActivity(session, 'root-04-m01-a04-reflection'); + await completeActivity(session, 'root-04-m02-a01-lesson'); await completeActivity(session, 'root-04-m02-a02-demonstration'); + await completeActivity(session, 'root-04-m02-a03-knowledge-check'); await completeActivity(session, 'root-04-m02-a04-guided-exercise'); await completeActivity(session, 'root-04-m02-a05-reflection'); + await completeActivity(session, 'root-04-m03-a01-lesson'); await completeActivity(session, 'root-04-m03-a02-demonstration'); + await completeActivity(session, 'root-04-m03-a03-knowledge-check'); await completeActivity(session, 'root-04-m03-a04-guided-exercise'); + await completeScenario(session, 'docker-cli-basics', envId); await completeActivity(session, 'root-04-m03-a06-reflection'); + await completeActivity(session, 'root-04-m04-a01-lesson'); await completeActivity(session, 'root-04-m04-a02-demonstration'); + await completeActivity(session, 'root-04-m04-a03-knowledge-check'); await completeActivity(session, 'root-04-m04-a04-guided-exercise'); + + 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/docker-container-networking/setup`); + assert.equal(setup.status, 200); + + // Confirm the REAL failure exists before fixing it — nothing published, host curl fails. + const before = runInContainer(containerName, `curl -sf http://localhost:8099/ -o /dev/null -w '%{http_code}' || echo FAILED`); + assert.ok(before.includes('FAILED') || before.trim() === '000', `expected a genuine pre-fix connection failure, got: ${before}`); + + runInContainer(containerName, ` +docker rm -f net-lab-web +docker run -d --name net-lab-web -p 8099:80 nginx:alpine`); + // Give nginx a moment to actually bind before validating. + execSync('sleep 2'); + + const validate = await session.request('POST', `/lab/${labToken}/api/scenarios/docker-container-networking/validate`); + assert.equal(validate.status, 200); + assert.equal(validate.body.passed, true, `expected real validation to pass: ${JSON.stringify(validate.body)}`); + await completeScenario(session, 'docker-container-networking', envId); + await completeActivity(session, 'root-04-m04-a06-reflection'); + }); + + await step('completing the rest of ROOT 04 (shortcut) finishes the ENTIRE foundations sequence and unlocks ROOT 05', async () => { + await completeActivity(session, 'root-04-m05-a01-lesson'); await completeActivity(session, 'root-04-m05-a02-demonstration'); + await completeActivity(session, 'root-04-m05-a03-knowledge-check'); await completeActivity(session, 'root-04-m05-a04-guided-exercise'); + await completeScenario(session, 'docker-container-storage', envId); await completeActivity(session, 'root-04-m05-a06-reflection'); + await completeActivity(session, 'root-04-m06-a01-lesson'); await completeActivity(session, 'root-04-m06-a02-demonstration'); + await completeActivity(session, 'root-04-m06-a03-knowledge-check'); await completeActivity(session, 'root-04-m06-a04-guided-exercise'); + await completeScenario(session, 'docker-build-image', envId); await completeActivity(session, 'root-04-m06-a06-reflection'); + await completeActivity(session, 'root-04-m07-a01-lesson'); await completeActivity(session, 'root-04-m07-a02-demonstration'); + await completeActivity(session, 'root-04-m07-a03-knowledge-check'); await completeActivity(session, 'root-04-m07-a04-guided-exercise'); + await completeScenario(session, 'docker-compose-multiservice', envId); await completeActivity(session, 'root-04-m07-a06-reflection'); + await completeActivity(session, 'root-04-m08-a01-lesson'); await completeActivity(session, 'root-04-m08-a02-knowledge-check'); await completeActivity(session, 'root-04-m08-a03-reflection'); + const start = await session.request('POST', '/api/assessments/rootnode-containers-assessment/start'); + for (const s of start.body.scenarios) await completeScenario(session, s.scenario_id, envId); + const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseFrom(body.courses, 'root-04-containers-and-docker').complete, true); + assert.equal(courseFrom(body.courses, 'root-05-kubernetes-foundations').locked, false, 'the entire ROOT 01-04 foundations sequence must now unlock ROOT 05'); + }); + + // ── Prove existing Kubernetes flow still works on the SAME real Lab (k3s never touched) ── + await step('the existing real-k3s Kubernetes flow on this SAME Lab is completely unaffected', async () => { + const kubectlOut = execSync(`docker exec ${containerName} kubectl get nodes`, { encoding: 'utf8' }); + assert.ok(/Ready/.test(kubectlOut), `expected a real, healthy k3s node: ${kubectlOut}`); + }); + + await step('logout, login — the entire foundations sequence survives it', async () => { + await session.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: `p6e-foundations-${RUN_ID}`, password: 'RehearsalPass123!' }); + const { body } = await returning.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseFrom(body.courses, 'root-04-containers-and-docker').complete, true); + }); + + // ── Instructor narrative ───────────────────────────────────────────────────────────────── + await step('instructor inspects the ROOT 02/03/04 module structure and instructor notes', async () => { + const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + assert.equal(status, 200); + const mod = moduleFrom(body, 'root-04-m04-container-networking'); + assert.equal(mod.activities.length, 6); + 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, complete foundations progress', async () => { + const { status, body } = await owner.request('GET', `/api/admin/students/${created.student_id}/courses?program_id=${PROGRAM}`); + assert.equal(status, 200); + assert.equal(courseFrom(body, 'root-04-containers-and-docker').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 aa3a88d..797c479 100644 --- a/platform/test/e2e-phase6a.js +++ b/platform/test/e2e-phase6a.js @@ -17,6 +17,7 @@ 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 API = process.env.PLATFORM_API_URL || 'http://localhost:4300'; @@ -38,6 +39,14 @@ const FOUNDATION = ['pod-basics-mcq', 'kubectl-essentials-mcq', 'labels-selector const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'liveness-probe-task', 'jobs-cronjobs-mcq', 'daemonset-mcq', 'edit-deployment-basics', 'deployment-strategy-mcq']; const NETWORKING = ['services-mcq', 'expose-service-basics']; const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline']; + +// Phase 6E added real content to ROOT 01-04, and root-05's own prerequisite (root-04) is no +// longer vacuously satisfied — this suite's ROOT 05 content is now genuinely locked behind the +// real foundations sequence for any student who hasn't completed it, exactly the enforcement +// docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md's course-routes.js fix now guarantees even via +// direct API. completeRootFoundations01Through04 (foundations-helper.js) does this quickly as +// test setup, not this file's own subject — see e2e-phase6e.js for the real ROOT 01-04 +// acceptance coverage. const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq']; const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']; @@ -122,7 +131,7 @@ async function main() { assert.equal(status, 200); tree = body.courses; const root05 = courseById(tree, 'root-05-kubernetes-foundations'); - assert.equal(root05.locked, false); // root-04 (its real Phase 6D prerequisite) has zero activities — vacuously satisfied + assert.equal(root05.locked, true, 'Phase 6E gave root-04 (root-05\'s real prerequisite) real content — root-05 is now genuinely gated, not vacuously satisfied'); assert.equal(root05.complete, false); const root06 = courseById(tree, 'root-06-kubernetes-application-workloads'); assert.equal(root06.locked, true, 'root-06 must be locked until root-05 is complete'); @@ -146,6 +155,12 @@ async function main() { assert.equal(body.error, 'not_manually_completable'); }); + 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('completing root-05\'s 20 real scenarios + the Phase 6B mental-model module marks the course complete and unlocks root-06', async () => { for (const sid of FOUNDATION) await completeScenario(casey, sid); // Phase 6B's root-05-m00 has its own manual-marker activities (lesson/demonstration/ @@ -170,6 +185,25 @@ async function main() { assert.equal(root06.locked, false, 'root-06 must unlock once root-05 is complete'); }); + await test('completing root-06 fully unlocks root-10 (its real course-level prerequisite, now enforced even via direct API)', async () => { + for (const sid of WORKLOADS) await completeScenario(casey, sid); + 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 completeScenario(casey, 'rolling-update-task'); + await completeScenario(casey, 'deployment-rollback'); + await casey.request('POST', '/api/student/activities/root-06-m02-a06-reflection/complete'); + 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`); + } + await completeScenario(casey, 'job-task'); + await completeScenario(casey, 'cronjob-task'); + await casey.request('POST', '/api/student/activities/root-06-m03-a05-reflection/complete'); + const { body } = await casey.request('GET', `/api/student/programs/${enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').complete, true); + assert.equal(courseById(body.courses, 'root-10-kubernetes-troubleshooting-observability').locked, false); + }); + // ── The gold module: lesson/demo/knowledge-check/guided-exercise (manual) + lab (auto) + reflection (manual) ── await test('lesson-only activities complete via the explicit marker, not automatically', async () => { for (const suffix of ['a01-lesson-health-check-concepts', 'a02-demonstration-probes-in-action', 'a03-knowledge-check', 'a04-guided-exercise-readiness-probe']) { diff --git a/platform/test/e2e-phase6b.js b/platform/test/e2e-phase6b.js index 2beb7ab..7f263cb 100644 --- a/platform/test/e2e-phase6b.js +++ b/platform/test/e2e-phase6b.js @@ -17,6 +17,7 @@ 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; @@ -135,6 +136,12 @@ async function main() { 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`); @@ -190,10 +197,15 @@ 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'); - for (const suffix of ['a01-lesson-jobs-cronjobs-daemonsets', 'a02-knowledge-check']) { - await casey.request('POST', `/api/student/activities/root-06-m03-${suffix}/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. @@ -286,6 +298,24 @@ async function main() { 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', @@ -311,12 +341,50 @@ async function main() { }); await test('the Capstone Readiness module itself is completable independent of the real assessment', async () => { - const enrollmentIdForCasey = enrollmentId; - for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) { - const { status } = await casey.request('POST', `/api/student/activities/root-capstone-m02-${suffix}/complete`); - assert.equal(status, 200); + // 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`); } - const { body } = await casey.request('GET', `/api/student/programs/${enrollmentIdForCasey}/courses`); + 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); }); diff --git a/platform/test/e2e-phase6c.js b/platform/test/e2e-phase6c.js index 93ef2b6..40741b0 100644 --- a/platform/test/e2e-phase6c.js +++ b/platform/test/e2e-phase6c.js @@ -15,6 +15,7 @@ const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); +const { completeRootFoundations01Through04, completeThroughRoot06, completeThroughRoot10 } = require('./foundations-helper'); const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const OWNER_EMAIL = process.env.OWNER_EMAIL; @@ -104,6 +105,9 @@ async function main() { // ── 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'); + // Phase 6E made root-06 a real, non-vacuous prerequisite for root-10 — test setup, not this + // test's own subject. + await completeThroughRoot06(student.session); // 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`); @@ -133,6 +137,9 @@ async function main() { // ── 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'); + // root-05-m01's real prerequisite is root-05-m00 complete (course-level: root-04 complete) + // — test setup, not this test's own subject. + await completeThroughRoot06(student.session); 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'); @@ -214,6 +221,8 @@ async function main() { await test('a student can complete ROOT 11 module 1 end-to-end (lesson-only path)', async () => { const student = await newStudent(owner, 'Root11'); + // ROOT 11's real prerequisite is ROOT 10 complete — test setup, not this test's own subject. + await completeThroughRoot10(student.session); 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); diff --git a/platform/test/e2e-phase6d.js b/platform/test/e2e-phase6d.js index 141c718..55bedff 100644 --- a/platform/test/e2e-phase6d.js +++ b/platform/test/e2e-phase6d.js @@ -74,11 +74,15 @@ async function main() { }); // ── 2/3/4: ROOT 02/03/04 hierarchy — honestly PLANNED, zero modules, not padded ────────── - await test('ROOT 02/03/04 are honestly PLANNED with zero modules — not padded to look further along than they are', async () => { + await test('ROOT 02/03/04 existed honestly PLANNED at the end of Phase 6D, and are real, authored courses as of Phase 6E', async () => { + // Phase 6D deliberately built only ROOT 01 and left 02-04 honestly empty — Phase 6E (see + // docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md) authored them for real. This assertion is + // updated to reflect that intentional, documented progression rather than re-asserting a + // fact Phase 6E was explicitly built to change. for (const id of ['root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) { const course = courseById(structure, id); - assert.equal(course.status, 'planned', `${id} must stay honestly planned`); - assert.equal(course.modules.length, 0, `${id} must have zero modules — no fabricated content`); + assert.equal(course.status, 'ready', `${id} should now be ready (Phase 6E)`); + assert.ok(course.modules.length > 0, `${id} should now have real modules (Phase 6E)`); } }); @@ -120,18 +124,19 @@ async function main() { }); await test('completing every ROOT 01 module and its assessment marks the whole course complete', async () => { + // ROOT 01's own modules carry real intra-course prerequisites (m02 requires m01 complete, + // etc.) — each module must be completed fully, including its lab and reflection, before the + // next module unlocks. Completing every module's early activities first and circling back + // for labs/reflections later (the original order here) trips the module-lock check. + const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' }; for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) { await completeActivity(casey.session, `root-01-${m}-a01-lesson`); await completeActivity(casey.session, `root-01-${m}-a02-demonstration`); await completeActivity(casey.session, `root-01-${m}-a03-knowledge-check`); await completeActivity(casey.session, `root-01-${m}-a04-guided-exercise`); + await completeScenario(casey.session, labs[m]); + await completeActivity(casey.session, `root-01-${m}-a06-reflection`); } - for (const [sid, activity] of [['linux-shell-basics', 'root-01-m02-a06-reflection'], ['linux-filesystem-navigation', 'root-01-m03-a06-reflection'], ['linux-files-and-text', 'root-01-m04-a06-reflection'], ['linux-process-management', 'root-01-m05-a06-reflection']]) { - await completeScenario(casey.session, sid); - await completeActivity(casey.session, activity); - } - await completeScenario(casey.session, 'linux-permissions-foundations'); - await completeActivity(casey.session, 'root-01-m06-a06-reflection'); const start = await casey.session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); assert.equal(start.status, 201); @@ -157,11 +162,15 @@ async function main() { assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, true, 'ROOT 02 must be locked until ROOT 01 is complete'); }); - await test('ROOT 03/04/05 stay unlocked (vacuously satisfied) since their own prerequisite courses have zero content yet', async () => { + await test('ROOT 03/04/05 are now genuinely locked for a fresh student (Phase 6E gave 02/03/04 real content)', async () => { + // At the end of Phase 6D this was true via vacuous satisfaction (02-04 had zero activities). + // Phase 6E authored real content for 02-04, so the SAME chain now genuinely gates for real — + // this assertion is updated to prove that real enforcement, not the vacuous case it + // originally documented. const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); - assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, false); - assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, false); - assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, true); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true); + assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, true); }); // ── 26/27: existing ROOT 05+ and Capstone state is untouched by this phase ────────────── diff --git a/platform/test/e2e-phase6e.js b/platform/test/e2e-phase6e.js new file mode 100644 index 0000000..1fac987 --- /dev/null +++ b/platform/test/e2e-phase6e.js @@ -0,0 +1,377 @@ +#!/usr/bin/env node +// Phase 6E — ROOT Foundations Completion acceptance test. Covers ROOT 02 (Linux Administration +// & Networking), ROOT 03 (Git & Collaborative Development), ROOT 04 (Containers & Docker), and +// the now-complete ROOT01->02->03->04->05 prerequisite chain. Same conventions as +// e2e-phase6a/6b/6c/6d.js: assert-based, no framework, live docker-compose.dev.yml stack, no +// real Lab container launched here (see infra/scripts/drills/test-phase6e-foundations-rehearsal.js +// for the real-Lab/real-Docker/real-Git 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-phase6e.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++; } +} +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-6E-${tag}-${RUN_ID}`, login_identifier: `e2e6e-${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 completeActivity(session, id) { + const r = await session.request('POST', `/api/student/activities/${id}/complete`); + assert.equal(r.status, 200, `completing ${id} should 200: ${JSON.stringify(r.body)}`); +} +async function completeScenario(session, sid) { + const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); + assert.equal(r.status, 200, `completing scenario ${sid} should 200`); +} +// NOTE: each course's modules carry real intra-course prerequisites (m02 requires m01 complete, +// etc — see migrations 025-028), so every helper below completes ONE module fully (including +// its lab scenario(s) and reflection) before touching the next, mirroring how a real student +// must actually progress. Completing all modules' early activities first and circling back for +// labs/reflections later would trip the module-lock check for later modules — a real ordering +// constraint, not a test artifact. +async function completeRoot01(session) { + await completeActivity(session, 'root-01-m01-a01-lesson'); + await completeActivity(session, 'root-01-m01-a02-guided-exercise'); + await completeScenario(session, 'linux-inspect-hardware'); + await completeActivity(session, 'root-01-m01-a04-reflection'); + const labs = { m02: 'linux-shell-basics', m03: 'linux-filesystem-navigation', m04: 'linux-files-and-text', m05: 'linux-process-management', m06: 'linux-permissions-foundations' }; + for (const m of ['m02', 'm03', 'm04', 'm05', 'm06']) { + await completeActivity(session, `root-01-${m}-a01-lesson`); + await completeActivity(session, `root-01-${m}-a02-demonstration`); + await completeActivity(session, `root-01-${m}-a03-knowledge-check`); + await completeActivity(session, `root-01-${m}-a04-guided-exercise`); + await completeScenario(session, labs[m]); + await completeActivity(session, `root-01-${m}-a06-reflection`); + } + const r1 = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); + for (const sc of r1.body.scenarios) await completeScenario(session, sc.scenario_id); +} +async function completeRoot02(session) { + await completeActivity(session, 'root-02-m01-a01-lesson'); + await completeActivity(session, 'root-02-m01-a02-demonstration'); + await completeActivity(session, 'root-02-m01-a03-knowledge-check'); + await completeActivity(session, 'root-02-m01-a04-guided-exercise'); + await completeScenario(session, 'linux-service-inspection'); + await completeScenario(session, 'linux-service-lifecycle'); + await completeActivity(session, 'root-02-m01-a07-reflection'); + await completeActivity(session, 'root-02-m02-a01-lesson'); + await completeActivity(session, 'root-02-m02-a02-demonstration'); + await completeActivity(session, 'root-02-m02-a03-knowledge-check'); + await completeActivity(session, 'root-02-m02-a04-guided-exercise'); + await completeScenario(session, 'linux-storage-inspection'); + await completeActivity(session, 'root-02-m02-a06-reflection'); + await completeActivity(session, 'root-02-m03-a01-lesson'); + await completeActivity(session, 'root-02-m03-a02-demonstration'); + await completeActivity(session, 'root-02-m03-a03-knowledge-check'); + await completeActivity(session, 'root-02-m03-a04-guided-exercise'); + await completeActivity(session, 'root-02-m03-a05-reflection'); + await completeActivity(session, 'root-02-m04-a01-lesson'); + await completeActivity(session, 'root-02-m04-a02-demonstration'); + await completeActivity(session, 'root-02-m04-a03-knowledge-check'); + await completeActivity(session, 'root-02-m04-a04-guided-exercise'); + await completeScenario(session, 'linux-network-inspection'); + await completeActivity(session, 'root-02-m04-a06-reflection'); + await completeActivity(session, 'root-02-m05-a01-lesson'); + await completeActivity(session, 'root-02-m05-a02-demonstration'); + await completeActivity(session, 'root-02-m05-a03-knowledge-check'); + await completeActivity(session, 'root-02-m05-a04-guided-exercise'); + await completeScenario(session, 'linux-dns-http-diagnosis'); + await completeActivity(session, 'root-02-m05-a06-reflection'); + await completeActivity(session, 'root-02-m06-a01-lesson'); + await completeActivity(session, 'root-02-m06-a02-demonstration'); + await completeActivity(session, 'root-02-m06-a03-knowledge-check'); + await completeActivity(session, 'root-02-m06-a04-guided-exercise'); + await completeScenario(session, 'linux-troubleshooting-framework'); + await completeActivity(session, 'root-02-m06-a06-reflection'); + const r2 = await session.request('POST', '/api/assessments/rootnode-linux-networking-assessment/start'); + for (const sc of r2.body.scenarios) await completeScenario(session, sc.scenario_id); +} +async function completeRoot03(session) { + await completeActivity(session, 'root-03-m01-a01-lesson'); + await completeActivity(session, 'root-03-m01-a02-knowledge-check'); + await completeActivity(session, 'root-03-m01-a03-reflection'); + await completeActivity(session, 'root-03-m02-a01-lesson'); + await completeActivity(session, 'root-03-m02-a02-demonstration'); + await completeActivity(session, 'root-03-m02-a03-knowledge-check'); + await completeActivity(session, 'root-03-m02-a04-guided-exercise'); + await completeActivity(session, 'root-03-m02-a05-reflection'); + await completeActivity(session, 'root-03-m03-a01-lesson'); + await completeActivity(session, 'root-03-m03-a02-demonstration'); + await completeActivity(session, 'root-03-m03-a03-knowledge-check'); + await completeActivity(session, 'root-03-m03-a04-guided-exercise'); + await completeScenario(session, 'git-everyday-basics'); + await completeActivity(session, 'root-03-m03-a06-reflection'); + await completeActivity(session, 'root-03-m04-a01-lesson'); + await completeActivity(session, 'root-03-m04-a02-demonstration'); + await completeActivity(session, 'root-03-m04-a03-knowledge-check'); + await completeActivity(session, 'root-03-m04-a04-guided-exercise'); + await completeScenario(session, 'git-branching-and-merge'); + await completeActivity(session, 'root-03-m04-a06-reflection'); + await completeActivity(session, 'root-03-m05-a01-lesson'); + await completeActivity(session, 'root-03-m05-a02-demonstration'); + await completeActivity(session, 'root-03-m05-a03-knowledge-check'); + await completeActivity(session, 'root-03-m05-a04-guided-exercise'); + await completeScenario(session, 'git-remote-clone-push'); + await completeActivity(session, 'root-03-m05-a06-reflection'); + await completeActivity(session, 'root-03-m06-a01-lesson'); + await completeActivity(session, 'root-03-m06-a02-demonstration'); + await completeActivity(session, 'root-03-m06-a03-knowledge-check'); + await completeActivity(session, 'root-03-m06-a04-reflection'); + const r3 = await session.request('POST', '/api/assessments/rootnode-git-fundamentals-assessment/start'); + for (const sc of r3.body.scenarios) await completeScenario(session, sc.scenario_id); +} +async function completeRoot04(session) { + await completeActivity(session, 'root-04-m01-a01-lesson'); + await completeActivity(session, 'root-04-m01-a02-demonstration'); + await completeActivity(session, 'root-04-m01-a03-knowledge-check'); + await completeActivity(session, 'root-04-m01-a04-reflection'); + await completeActivity(session, 'root-04-m02-a01-lesson'); + await completeActivity(session, 'root-04-m02-a02-demonstration'); + await completeActivity(session, 'root-04-m02-a03-knowledge-check'); + await completeActivity(session, 'root-04-m02-a04-guided-exercise'); + await completeActivity(session, 'root-04-m02-a05-reflection'); + const labs4 = { m03: 'docker-cli-basics', m04: 'docker-container-networking', m05: 'docker-container-storage', m06: 'docker-build-image', m07: 'docker-compose-multiservice' }; + for (const m of ['m03', 'm04', 'm05', 'm06', 'm07']) { + await completeActivity(session, `root-04-${m}-a01-lesson`); + await completeActivity(session, `root-04-${m}-a02-demonstration`); + await completeActivity(session, `root-04-${m}-a03-knowledge-check`); + await completeActivity(session, `root-04-${m}-a04-guided-exercise`); + await completeScenario(session, labs4[m]); + await completeActivity(session, `root-04-${m}-a06-reflection`); + } + await completeActivity(session, 'root-04-m08-a01-lesson'); + await completeActivity(session, 'root-04-m08-a02-knowledge-check'); + await completeActivity(session, 'root-04-m08-a03-reflection'); + const r4 = await session.request('POST', '/api/assessments/rootnode-containers-assessment/start'); + for (const sc of r4.body.scenarios) await completeScenario(session, sc.scenario_id); +} + +async function main() { + console.log('Phase 6E ROOT Foundations Completion acceptance test\n'); + + const owner = makeSession(GW); + await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + let structure; + + // ── 1/8/15: hierarchy for ROOT 02/03/04 ───────────────────────────────────────────────── + await test('ROOT 02/03/04 each have their full designed module set, in sequence, all READY', async () => { + const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + assert.equal(status, 200); + structure = body; + const expected = { + 'root-02-linux-administration-networking': ['root-02-m01-linux-system-administration', 'root-02-m02-storage-and-filesystems', 'root-02-m03-networking-mental-model', 'root-02-m04-linux-network-inspection', 'root-02-m05-dns-and-http', 'root-02-m06-linux-troubleshooting'], + 'root-03-git-collaborative-development': ['root-03-m01-why-version-control-exists', 'root-03-m02-git-mental-model', 'root-03-m03-everyday-git', 'root-03-m04-branching-and-merging', 'root-03-m05-remote-repositories', 'root-03-m06-engineering-git-workflow'], + 'root-04-containers-and-docker': ['root-04-m01-why-containers-exist', 'root-04-m02-images-and-containers', 'root-04-m03-docker-cli', 'root-04-m04-container-networking', 'root-04-m05-container-storage', 'root-04-m06-dockerfile-and-image-build', 'root-04-m07-docker-compose', 'root-04-m08-containers-to-kubernetes'], + }; + for (const [courseId, moduleIds] of Object.entries(expected)) { + const course = courseById(structure, courseId); + assert.equal(course.status, 'ready', `${courseId} must be ready`); + assert.deepEqual(course.modules.map((m) => m.module_id), moduleIds, `${courseId} module order mismatch`); + for (const m of course.modules) assert.equal(m.status, 'ready', `${m.module_id} must be ready`); + } + }); + + // ── 2/9/16: content references real ──────────────────────────────────────────────────── + await test('every ROOT 02/03/04 module has real, loaded lesson content', async () => { + for (const modId of ['root-02-m03-networking-mental-model', 'root-03-m02-git-mental-model', 'root-04-m01-why-containers-exist']) { + const mod = moduleById(structure, modId); + const lesson = mod.activities.find((a) => a.activity_type === 'lesson'); + assert.ok(lesson.body && lesson.body.length > 300, `${modId}'s lesson must have real content`); + } + }); + + // ── 4/5/6/7: ROOT 02 practicals, networking, troubleshooting, assessment ──────────────── + await test('ROOT 02 assessment pool matches the documented design (2 required + 4 random)', async () => { + const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-linux-networking-assessment'`)); + assert.deepEqual(spec.required, ['linux-network-inspection', 'linux-troubleshooting-framework']); + assert.equal(spec.random.reduce((n, g) => n + g.count, 0), 4); + }); + + // ── 10/11/12/13: ROOT 03 Git — real repository state validation ───────────────────────── + await test('ROOT 03 assessment pool matches the documented design (2 required + 3 random)', async () => { + const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-git-fundamentals-assessment'`)); + assert.deepEqual(spec.required, ['git-everyday-basics', 'git-branching-and-merge']); + assert.equal(spec.random.reduce((n, g) => n + g.count, 0), 3); + }); + + // ── 16/22/23/24/25: ROOT 04 nested Docker, Compose ────────────────────────────────────── + await test('ROOT 04 assessment pool matches the documented design (2 required + 4 random)', async () => { + const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-containers-assessment'`)); + assert.deepEqual(spec.required, ['docker-cli-basics', 'docker-build-image']); + assert.equal(spec.random.reduce((n, g) => n + g.count, 0), 4); + }); + + // ── 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 () => { + 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'); + }); + + // ── 30/26/27/28/29: direct API cannot bypass prerequisites (Section 11's real finding) ── + await test('direct API cannot mark a ROOT 02 activity complete while ROOT 01 is incomplete (found and fixed this phase)', async () => { + const r = await alice.session.request('POST', '/api/student/activities/root-02-m01-a01-lesson/complete'); + assert.equal(r.status, 403); + assert.equal(r.body.error, 'locked'); + }); + + await test('direct API cannot mark a ROOT 06 activity complete while ROOT 05 is incomplete (pre-existing gap, same fix)', async () => { + const r = await alice.session.request('POST', '/api/student/activities/root-06-m02-a01-lesson-deployments/complete'); + assert.equal(r.status, 403); + assert.equal(r.body.error, 'locked'); + }); + + await test('ROOT 01 completes and unlocks ROOT 02, while ROOT 03/04/05 remain locked (real content now gates them)', async () => { + await completeRoot01(alice.session); + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, true); + assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, false); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, true, 'ROOT 03 now genuinely gates on ROOT 02 having real content'); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true); + }); + + await test('ROOT 02 completes and unlocks ROOT 03, not ROOT 04 yet', async () => { + await completeRoot02(alice.session); + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').complete, true); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, false); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true); + }); + + await test('ROOT 03 completes and unlocks ROOT 04, not ROOT 05 yet', async () => { + await completeRoot03(alice.session); + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').complete, true); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, false); + assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, true); + }); + + await test('ROOT 04 completes and unlocks ROOT 05 — the complete foundations chain is proven', async () => { + await completeRoot04(alice.session); + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').complete, true); + assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, false); + }); + + // ── 39: existing ROOT 05+ content remains intact ──────────────────────────────────────── + await test('ROOT 06 stays locked behind ROOT 05 exactly as before (unaffected by the foundations chain)', async () => { + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-06-kubernetes-application-workloads').locked, true); + }); + + // ── 40: Capstone behavior unchanged ───────────────────────────────────────────────────── + await test('the capstone unlock gate is unaffected by the new foundations chain', async () => { + const r = await alice.session.request('POST', '/api/assessments/rootnode-capstone/start'); + assert.equal(r.status, 403); + assert.equal(r.body.error, 'locked'); + }); + + // ── 31: optional work does not block (is_required still generalizes) ─────────────────── + await test('the pre-existing is_required rule is unaffected by this phase', async () => { + const mod = moduleById(structure, 'root-10-m01-health-probes-and-troubleshooting'); + assert.equal(mod.activities.filter((a) => a.is_required === false).length, 1); + }); + + // ── 18/19: self-paced content never defers to a live instructor ──────────────────────── + await test('self-paced ROOT 02/03/04 content never defers explanation to a live instructor', async () => { + for (const courseId of ['root-02-linux-administration-networking', 'root-03-git-collaborative-development', 'root-04-containers-and-docker']) { + const course = courseById(structure, courseId); + for (const mod of course.modules) for (const a of mod.activities) { + if (!a.body) continue; + assert.ok(!/ask your instructor/i.test(a.body) && !/your instructor will explain/i.test(a.body), `${a.activity_id} must not defer to a live instructor`); + } + } + }); + + // ── 20/36: instructor notes hidden from students, visible to instructors ─────────────── + await test('instructor notes on ROOT 02/03/04 content are visible to instructors and never to students', async () => { + const lesson = findActivity(structure, 'root-03-m02-a01-lesson'); + assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0); + const { body } = await alice.session.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + const studentLesson = findActivity(body.courses, 'root-03-m02-a01-lesson'); + assert.equal(studentLesson.instructor_notes, undefined); + }); + + // ── 37: instructor RBAC ───────────────────────────────────────────────────────────────── + const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6e-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6E', role: 'instructor', password: 'StrayInstructorPass6E!' }); + const stray = makeSession(GW); + await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6e-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6E!' }); + await test('a plain instructor with no relationship to the student cannot see their foundations progress', async () => { + const { status } = await stray.request('GET', `/api/admin/students/${alice.studentId}/courses?program_id=${PROGRAM}`); + assert.equal(status, 403); + }); + await test('owner/admin can see any self-paced student\'s complete foundations progress', async () => { + const { status, body } = await owner.request('GET', `/api/admin/students/${alice.studentId}/courses?program_id=${PROGRAM}`); + assert.equal(status, 200); + assert.equal(courseById(body, 'root-04-containers-and-docker').complete, true); + }); + + // ── 33/34: progress persistence across logout/login ───────────────────────────────────── + await test('foundations progress survives logout and a fresh login', async () => { + await alice.session.request('POST', '/api/logout'); + const returning = makeSession(GW); + await returning.request('POST', '/api/student/login', { identifier: `e2e6e-Alice-${RUN_ID}`, password: 'AlicePassword123!' }); + const { body } = await returning.request('GET', `/api/student/programs/${alice.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').complete, true); + }); + + // ── 35: two students isolated ─────────────────────────────────────────────────────────── + const bob = await newStudent(owner, 'Bob'); + await test('a second, fresh student sees independent, empty foundations progress and a fully-locked chain', async () => { + const { body } = await bob.session.request('GET', `/api/student/programs/${bob.enrollmentId}/courses`); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').complete, false); + assert.equal(courseById(body.courses, 'root-01-computing-linux-foundations').locked, false); + assert.equal(courseById(body.courses, 'root-02-linux-administration-networking').locked, true); + assert.equal(courseById(body.courses, 'root-03-git-collaborative-development').locked, true); + assert.equal(courseById(body.courses, 'root-04-containers-and-docker').locked, true); + assert.equal(courseById(body.courses, 'root-05-kubernetes-foundations').locked, true); + }); + + // ── content version stability ──────────────────────────────────────────────────────────── + await test('bumping a ROOT 03 module\'s version does not drop its content', async () => { + psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-03-m04-branching-and-merging'`); + const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`); + const mod = moduleById(body, 'root-03-m04-branching-and-merging'); + assert.equal(mod.activities.length, 6); + assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300); + }); + + // ── content validation as part of this suite ──────────────────────────────────────────── + await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => { + execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' }); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error('FATAL', e); process.exit(1); }); diff --git a/platform/test/foundations-helper.js b/platform/test/foundations-helper.js new file mode 100644 index 0000000..ddbd65a --- /dev/null +++ b/platform/test/foundations-helper.js @@ -0,0 +1,85 @@ +// Shared test helper — completes the real ROOT 01-04 foundations sequence for a given session. +// Phase 6E gave ROOT 01-04 real content, so ROOT 05's own prerequisite (root-04) is no longer +// vacuously satisfied: any e2e suite that drives a student into ROOT 05+ content now needs this +// as real test setup first (not this file's own subject — see e2e-phase6e.js for the actual +// ROOT 01-04 acceptance coverage). Extracted here once other suites needed the identical +// sequence, rather than duplicating it in e2e-phase6a/6b/6c/6d.js. + +async function completeRootFoundations01Through04(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}`); + } + + 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`); } + let r = await session.request('POST', '/api/assessments/rootnode-linux-foundations-assessment/start'); for (const s of r.body.scenarios) await scen(s.scenario_id); + + await act('root-02-m01-a01-lesson'); await act('root-02-m01-a02-demonstration'); await act('root-02-m01-a03-knowledge-check'); await act('root-02-m01-a04-guided-exercise'); + await scen('linux-service-inspection'); await scen('linux-service-lifecycle'); await act('root-02-m01-a07-reflection'); + await act('root-02-m02-a01-lesson'); await act('root-02-m02-a02-demonstration'); await act('root-02-m02-a03-knowledge-check'); await act('root-02-m02-a04-guided-exercise'); await scen('linux-storage-inspection'); await act('root-02-m02-a06-reflection'); + await act('root-02-m03-a01-lesson'); await act('root-02-m03-a02-demonstration'); await act('root-02-m03-a03-knowledge-check'); await act('root-02-m03-a04-guided-exercise'); await act('root-02-m03-a05-reflection'); + await act('root-02-m04-a01-lesson'); await act('root-02-m04-a02-demonstration'); await act('root-02-m04-a03-knowledge-check'); await act('root-02-m04-a04-guided-exercise'); await scen('linux-network-inspection'); await act('root-02-m04-a06-reflection'); + await act('root-02-m05-a01-lesson'); await act('root-02-m05-a02-demonstration'); await act('root-02-m05-a03-knowledge-check'); await act('root-02-m05-a04-guided-exercise'); await scen('linux-dns-http-diagnosis'); await act('root-02-m05-a06-reflection'); + await act('root-02-m06-a01-lesson'); await act('root-02-m06-a02-demonstration'); await act('root-02-m06-a03-knowledge-check'); await act('root-02-m06-a04-guided-exercise'); await scen('linux-troubleshooting-framework'); await act('root-02-m06-a06-reflection'); + r = await session.request('POST', '/api/assessments/rootnode-linux-networking-assessment/start'); for (const s of r.body.scenarios) await scen(s.scenario_id); + + await act('root-03-m01-a01-lesson'); await act('root-03-m01-a02-knowledge-check'); await act('root-03-m01-a03-reflection'); + await act('root-03-m02-a01-lesson'); await act('root-03-m02-a02-demonstration'); await act('root-03-m02-a03-knowledge-check'); await act('root-03-m02-a04-guided-exercise'); await act('root-03-m02-a05-reflection'); + await act('root-03-m03-a01-lesson'); await act('root-03-m03-a02-demonstration'); await act('root-03-m03-a03-knowledge-check'); await act('root-03-m03-a04-guided-exercise'); await scen('git-everyday-basics'); await act('root-03-m03-a06-reflection'); + await act('root-03-m04-a01-lesson'); await act('root-03-m04-a02-demonstration'); await act('root-03-m04-a03-knowledge-check'); await act('root-03-m04-a04-guided-exercise'); await scen('git-branching-and-merge'); await act('root-03-m04-a06-reflection'); + await act('root-03-m05-a01-lesson'); await act('root-03-m05-a02-demonstration'); await act('root-03-m05-a03-knowledge-check'); await act('root-03-m05-a04-guided-exercise'); await scen('git-remote-clone-push'); await act('root-03-m05-a06-reflection'); + await act('root-03-m06-a01-lesson'); await act('root-03-m06-a02-demonstration'); await act('root-03-m06-a03-knowledge-check'); await act('root-03-m06-a04-reflection'); + r = await session.request('POST', '/api/assessments/rootnode-git-fundamentals-assessment/start'); for (const s of r.body.scenarios) await scen(s.scenario_id); + + await act('root-04-m01-a01-lesson'); await act('root-04-m01-a02-demonstration'); await act('root-04-m01-a03-knowledge-check'); await act('root-04-m01-a04-reflection'); + await act('root-04-m02-a01-lesson'); await act('root-04-m02-a02-demonstration'); await act('root-04-m02-a03-knowledge-check'); await act('root-04-m02-a04-guided-exercise'); await act('root-04-m02-a05-reflection'); + const l4 = { m03: 'docker-cli-basics', m04: 'docker-container-networking', m05: 'docker-container-storage', m06: 'docker-build-image', m07: 'docker-compose-multiservice' }; + for (const m of ['m03', 'm04', 'm05', 'm06', 'm07']) { await act(`root-04-${m}-a01-lesson`); await act(`root-04-${m}-a02-demonstration`); await act(`root-04-${m}-a03-knowledge-check`); await act(`root-04-${m}-a04-guided-exercise`); await scen(l4[m]); await act(`root-04-${m}-a06-reflection`); } + await act('root-04-m08-a01-lesson'); await act('root-04-m08-a02-knowledge-check'); await act('root-04-m08-a03-reflection'); + r = await session.request('POST', '/api/assessments/rootnode-containers-assessment/start'); for (const s of r.body.scenarios) await scen(s.scenario_id); +} + +// Completes ROOT 01-04, then ROOT 05 (m00 + m01), then ROOT 06 (m01/m02/m03) fully — the +// complete real prerequisite chain any suite needs before touching ROOT 09/10/11 content +// (their course-level prerequisite is root-06 complete, or a course chained behind it). +async function completeThroughRoot06(session) { + await completeRootFoundations01Through04(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}`); } + + await act('root-05-m00-a01-lesson-mental-model'); await act('root-05-m00-a02-demonstration-desired-state'); await act('root-05-m00-a03-knowledge-check'); await act('root-05-m00-a04-guided-exercise-namespaces'); + await scen('namespaces-basics'); await act('root-05-m00-a06-reflection'); + const root05m01Scenarios = ['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']; + for (const sid of root05m01Scenarios) await scen(sid); + await act('root-05-m01-a00-lesson-kubectl-fluency'); await act('root-05-m01-a21-reflection'); + + 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'); + 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'); +} + +// Completes through ROOT 06, then ROOT 10 (m01/m02/m03) fully — the real prerequisite ROOT 11 +// (and anything else chained behind ROOT 10) needs. +async function completeThroughRoot10(session) { + await completeThroughRoot06(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}`); } + + await act('root-10-m01-a01-lesson-health-check-concepts'); await act('root-10-m01-a02-demonstration-probes-in-action'); await act('root-10-m01-a03-knowledge-check'); await act('root-10-m01-a04-guided-exercise-readiness-probe'); + 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'); + 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 };