Files
rootnode-academy/infra/scripts/drills/test-phase6f-root-v1-end-to-end.js
T
ced4568andClaude Sonnet 5 bd55b74b4f test(pilot): add multi-student and end-to-end ROOT_ V1 rehearsals
- test-phase6f-root-v1-pilot.js (22/22): a real cohort + live classroom session with three
  students in genuinely different states — on track, a real Lab validation failure/help-
  request/fix cycle, and a real admin Lab-reap/recreate with academic-state persistence
  proven, not assumed. Instructor visibility distinguishes all three.
- test-phase6f-root-v1-end-to-end.js (14/14): one student, entirely through the real
  student API, ROOT 01 through Capstone-eligible, including a real nested-Docker
  container-networking fix and an explicit proof that the real rootnode-capstone assessment
  is genuinely startable at the end — not just that the course tree says complete. Found and
  documented (not fixed) a real, pre-existing gap: the legacy six-stage Capstone unlock_rule
  requires two scenario sets (root-07-m01/m02) that are not course-layer prerequisites of
  anything on the path to root-capstone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 18:33:35 -05:00

189 lines
16 KiB
JavaScript

#!/usr/bin/env node
// Phase 6F Step 24 — the broadest curriculum acceptance test yet: proves one real student can
// progress ROOT 01 -> 02 -> 03 -> 04 -> 05+ -> Security -> Troubleshooting -> Real Ops ->
// Capstone-eligible, entirely through the REAL student API (never an admin shortcut, never a
// direct database write to fake completion), with every real server-side prerequisite lock
// actually enforced along the way — not bypassed. Real disposable Lab + real nested Docker used
// for one genuine checkpoint (the ROOT 04 container-networking diagnose-and-fix, matching the
// standard Phase 6D/6E rehearsals already established); the rest of ROOT 01-04's real-Lab
// mechanics were already proven end-to-end in those rehearsals and in this phase's own
// multi-student pilot rehearsal, so this script does not repeat them — it proves the ADDITIONAL
// distance (05 through Capstone-eligibility) that no prior phase's rehearsal covered.
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('../../../platform/test/session-helper');
const { completeRootFoundations01Through04 } = require('../../../platform/test/foundations-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
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 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; }
async function act(session, id) { const r = await session.request('POST', `/api/student/activities/${id}/complete`); assert.equal(r.status, 200, `completing ${id}: ${JSON.stringify(r.body)}`); }
async function scen(session, sid) { const r = await session.request('POST', '/api/progress/record', { scenario_id: sid, status: 'completed' }); assert.equal(r.status, 200, `completing scenario ${sid}`); }
async function main() {
console.log('Phase 6F ROOT_ V1 end-to-end acceptance rehearsal — ROOT 01 through Capstone eligibility\n');
const owner = makeSession(GW);
await step('owner logs in', async () => {
const r = await owner.request('POST', '/api/auth/instructor/login', { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD });
assert.equal(r.status, 200);
});
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: 'E2E V1 Journey', login_identifier: `p6f-journey-${RUN_ID}` });
const session = makeSession(GW);
let enrollmentId;
await step('student activates and is enrolled in ROOT_', async () => {
const act1 = await session.request('POST', '/api/student/activate', { token: create.activation_token, password: 'JourneyPass123!' });
assert.equal(act1.status, 200);
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
enrollmentId = enroll.enrollment_id;
});
await step('ROOT 01-04 (Linux/Git/Docker foundations) complete through the real student API, unlocking ROOT 05', async () => {
await completeRootFoundations01Through04(session);
const { body } = await session.request('GET', `/api/student/programs/${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);
});
// ── One real Lab checkpoint: the ROOT 04 diagnose-and-fix, re-proven live on THIS student ──
await step('practical boundary check: a real nested-Docker container-networking fix, run live on this student\'s own Lab', async () => {
const launch = await session.request('POST', `/api/student/programs/${enrollmentId}/launch`);
assert.equal(launch.status, 200);
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);
const envRow = (await owner.request('GET', `/api/admin/students/${create.student_id}/environment`)).body.find((e) => e.status === 'running');
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 = '${envRow.environment_id}'"`,
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' }
).toString().trim();
// Confirm the REAL pre-fix failure before fixing it.
let failedBefore = false;
try { execSync(`docker exec ${containerName} curl -sf --max-time 3 http://localhost:8099`, { stdio: 'pipe' }); }
catch (e) { failedBefore = true; }
assert.equal(failedBefore, true, 'expected a REAL connection failure before the fix (container started without -p)');
execSync(`docker exec ${containerName} docker rm -f net-lab-web`, { stdio: 'pipe' });
execSync(`docker exec ${containerName} docker run -d --name net-lab-web -p 8099:80 nginx:alpine`, { stdio: 'pipe' });
const afterFix = execSync(`docker exec ${containerName} curl -sf --max-time 5 http://localhost:8099`, { stdio: 'pipe' }).toString();
assert.ok(afterFix.includes('nginx') || afterFix.length > 0, 'expected a real successful response after the real fix');
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);
await scen(session, 'docker-container-networking');
});
await step('direct API cannot skip ahead: ROOT 09/10/12/Capstone all remain locked until ROOT 05/06 are genuinely complete', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
for (const id of ['root-09-kubernetes-security', 'root-10-kubernetes-troubleshooting-observability', 'root-12-real-ops', 'root-capstone']) {
assert.equal(courseById(body.courses, id).locked, true, `${id} should still be locked`);
}
const bypass = await session.request('POST', '/api/student/activities/root-09-m01-a01-lesson-security-model/complete');
assert.equal(bypass.status, 403);
assert.equal(bypass.body.error, 'locked');
});
await step('ROOT 05 (Kubernetes Foundations) completes and unlocks ROOT 06', async () => {
await act(session, 'root-05-m00-a01-lesson-mental-model'); await act(session, 'root-05-m00-a02-demonstration-desired-state'); await act(session, 'root-05-m00-a03-knowledge-check'); await act(session, 'root-05-m00-a04-guided-exercise-namespaces');
await scen(session, 'namespaces-basics'); await act(session, 'root-05-m00-a06-reflection');
const pods = ['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 pods) await scen(session, sid);
await act(session, 'root-05-m01-a00-lesson-kubectl-fluency'); await act(session, 'root-05-m01-a21-reflection');
const { body } = await session.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);
});
await step('ROOT 06 (Application Workloads, incl. the practice bank) completes and unlocks ROOT 07/09/10', async () => {
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(session, sid);
await act(session, 'root-06-m02-a01-lesson-deployments'); await act(session, 'root-06-m02-a02-demonstration-rollout'); await act(session, 'root-06-m02-a03-knowledge-check'); await act(session, 'root-06-m02-a04-guided-exercise-rollout-strategy'); await act(session, 'root-06-m02-a06-reflection');
await act(session, 'root-06-m01-a00-lab-bank-intro');
for (const sid of ['job-task', 'cronjob-task']) await scen(session, sid);
await act(session, 'root-06-m03-a01-lesson-jobs-cronjobs-daemonsets'); await act(session, 'root-06-m03-a01b-demonstration-job-lifecycle'); await act(session, 'root-06-m03-a02-knowledge-check'); await act(session, 'root-06-m03-a02b-guided-exercise-choosing-primitive'); await act(session, 'root-06-m03-a05-reflection');
const { body } = await session.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-07-kubernetes-networking-config').locked, false);
assert.equal(courseById(body.courses, 'root-09-kubernetes-security').locked, false);
assert.equal(courseById(body.courses, 'root-10-kubernetes-troubleshooting-observability').locked, false);
});
await step('ROOT 09 (Security) completes via a real formal assessment', async () => {
for (const suffix of ['a01-lesson-security-model', 'a02-demonstration-securitycontext', 'a03-knowledge-check', 'a04-guided-exercise-hardening-plan', 'a09-reflection']) await act(session, `root-09-m01-${suffix}`);
for (const sid of ['cks-non-root-enforce', 'cks-drop-capabilities', 'cks-readonly-filesystem', 'cks-pod-security-context']) await scen(session, sid);
for (const suffix of ['a01-lesson-serviceaccounts-rbac', 'a01b-demonstration-rbac-denial', 'a02-knowledge-check', 'a02b-guided-exercise-least-privilege-role', 'a09-reflection']) await act(session, `root-09-m02-${suffix}`);
for (const sid of ['serviceaccount-pod', 'cks-automount-token', 'rbac-role', 'cks-rbac-clusterrole', 'cks-rbac-least-privilege']) await scen(session, sid);
for (const suffix of ['a01-lesson-services', 'a02-demonstration-zero-endpoints', 'a03-knowledge-check', 'a04-guided-exercise-service-type', 'a06-reflection']) await act(session, `root-07-m03-${suffix}`);
await scen(session, 'expose-service-basics');
for (const suffix of ['a01-lesson-ingress-networkpolicy', 'a01b-demonstration-ingress-routing', 'a02-knowledge-check', 'a02b-guided-exercise-network-isolation', 'a05-reflection']) await act(session, `root-07-m04-${suffix}`);
await scen(session, 'ingress-task'); await scen(session, 'network-policy');
for (const suffix of ['a01-lesson-network-supplychain', 'a01b-demonstration-default-deny', 'a02-knowledge-check', 'a02b-guided-exercise-egress-plan', 'a09-reflection']) await act(session, `root-09-m03-${suffix}`);
for (const sid of ['cks-network-policy', 'cks-egress-namespace', 'cks-tls-ingress', 'cks-image-pull-secret']) await scen(session, sid);
await act(session, 'root-09-m04-a01-lesson-cluster-hardening');
for (const sid of ['cks-psa-namespace', 'cks-immutable-secret']) await scen(session, sid);
const r = await session.request('POST', '/api/assessments/rootnode-security-assessment/start');
assert.equal(r.status, 201);
for (const s of r.body.scenarios) await scen(session, s.scenario_id);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-09-kubernetes-security').complete, true);
});
await step('ROOT 10 (Troubleshooting, incl. the practice bank) completes using the RootNode framework', async () => {
await act(session, 'root-10-m01-a01-lesson-health-check-concepts'); await act(session, 'root-10-m01-a02-demonstration-probes-in-action'); await act(session, 'root-10-m01-a03-knowledge-check'); await act(session, 'root-10-m01-a04-guided-exercise-readiness-probe');
await scen(session, 'rootnode-probe-port-mismatch'); await act(session, 'root-10-m01-a06-reflection-real-ops-takeaway');
await act(session, 'root-10-m03-a01-lesson-framework'); await act(session, 'root-10-m03-a02-demonstration-crashloop'); await act(session, 'root-10-m03-a03-knowledge-check'); await act(session, 'root-10-m03-a03b-guided-exercise-endpoint-mismatch');
await scen(session, 'crashloop-fix'); await scen(session, 'endpoint-fix-task'); await act(session, 'root-10-m03-a06-reflection');
await act(session, 'root-10-m02-a00-lab-bank-intro');
for (const sid of ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq', 'container-logging-mcq']) await scen(session, sid);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-10-kubernetes-troubleshooting-observability').complete, true);
assert.equal(courseById(body.courses, 'root-12-real-ops').locked, false);
});
await step('ROOT 12 (Real Ops) completes, real incident scenarios, deliberately un-guided', async () => {
await act(session, 'root-12-m01-a00-lesson-incident-response');
for (const sid of ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-enforce-limits']) await scen(session, sid);
await act(session, 'root-12-m01-a99-reflection-post-incident');
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-12-real-ops').complete, true);
});
await step('root-capstone is now genuinely unlocked — both real prerequisites (ROOT 09 AND ROOT 12) satisfied', async () => {
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(courseById(body.courses, 'root-capstone').locked, false);
});
await step('the Capstone Readiness orientation module is completable', async () => {
for (const suffix of ['a01-lesson-what-it-measures', 'a02-lesson-time-management', 'a03-knowledge-check', 'a04-reflection-readiness']) await act(session, `root-capstone-m02-${suffix}`);
const { body } = await session.request('GET', `/api/student/programs/${enrollmentId}/courses`);
assert.equal(moduleById(body.courses, 'root-capstone-m02-capstone-readiness').complete, true);
});
await step('the pre-existing legacy six-stage gate (a SEPARATE, older mechanism) also needs its own "networking"/"configuration" stage scenarios — never required by this journey\'s course-layer path, since root-07-m01/m02 aren\'t prerequisites of root-09-m03', async () => {
// "networking" stage needs services-mcq (root-07-m01's scenario; expose-service-basics was
// already done above via root-07-m03). "configuration" stage needs all 5 of root-07-m02's
// scenarios. Neither module is a course-content-model prerequisite of anything this journey
// already needed to touch — proving these two, genuinely separate gates aren't the same
// thing is exactly this step's point.
for (const sid of ['services-mcq', 'configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline']) await scen(session, sid);
});
await step('the real Capstone assessment itself is now startable (the pre-existing six-stage gate is also satisfied by this journey)', async () => {
const r = await session.request('POST', '/api/assessments/rootnode-capstone/start');
assert.equal(r.status, 201, `expected the Capstone to be genuinely startable: ${JSON.stringify(r.body)}`);
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });