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 <[email protected]>
253 lines
17 KiB
JavaScript
253 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 6C — curriculum completion, assessment bank, and ROOT 11 scope acceptance test. Covers
|
|
// what THIS phase actually changed: the six upgraded PARTIAL modules, the new security
|
|
// assessment bank, the ROOT 11 orientation module, and the one newly-wired optional-practice
|
|
// activity — not re-proving the underlying completion/locking/RBAC engine from scratch (already
|
|
// proven exhaustively in e2e-phase6a.js and re-proven against new content in e2e-phase6b.js).
|
|
// Same conventions: assert-based, no framework, live docker-compose.dev.yml stack, no real Lab
|
|
// container launched here (infra/scripts/drills/phase6c-security-config-rehearsal.js is the
|
|
// real-k3s counterpart).
|
|
//
|
|
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
|
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
|
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase6c.js
|
|
|
|
const assert = require('assert');
|
|
const { execSync } = require('child_process');
|
|
const { makeSession } = require('./session-helper');
|
|
const { completeRootFoundations01Through04, completeThroughRoot06, completeThroughRoot10 } = require('./foundations-helper');
|
|
|
|
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
|
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
|
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
|
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
|
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
|
process.exit(1);
|
|
}
|
|
const RUN_ID = Date.now();
|
|
const PROGRAM = 'root-kubernetes-foundations';
|
|
|
|
let passed = 0, failed = 0;
|
|
async function test(name, fn) {
|
|
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
|
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
|
}
|
|
async function completeScenario(session, scenarioId) {
|
|
const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed' });
|
|
assert.equal(status, 200, `completing ${scenarioId} should 200`);
|
|
}
|
|
function courseById(courses, id) { return courses.find((c) => c.course_id === id); }
|
|
function moduleById(courses, id) { for (const c of courses) { const m = c.modules.find((x) => x.module_id === id); if (m) return m; } return null; }
|
|
function findActivity(courses, id) {
|
|
for (const c of courses) for (const m of c.modules) { const a = m.activities.find((x) => x.activity_id === id); if (a) return a; }
|
|
return null;
|
|
}
|
|
function psql(sql) {
|
|
return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim();
|
|
}
|
|
async function newStudent(owner, tag) {
|
|
const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-6C-${tag}-${RUN_ID}`, login_identifier: `e2e6c-${tag}-${RUN_ID}` });
|
|
const s = makeSession(GW);
|
|
await s.request('POST', '/api/student/activate', { token: create.activation_token, password: `${tag}Password123!` });
|
|
const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' });
|
|
return { session: s, studentId: create.student_id, enrollmentId: enroll.enrollment_id };
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Phase 6C curriculum completion / assessment bank / ROOT 11 acceptance test\n');
|
|
|
|
const owner = makeSession(GW);
|
|
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
let structure;
|
|
|
|
// ── 1: course tree correctness — every module Phase 6C touched, in the expected order ────
|
|
await test('every module Phase 6C upgraded exists with the expected full activity-type sequence', async () => {
|
|
const { status, body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
assert.equal(status, 200);
|
|
structure = body;
|
|
const expected = {
|
|
'root-06-m03-jobs-cronjobs-daemonsets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
|
'root-07-m04-ingress-networkpolicy-foundations': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
|
'root-07-m02-configuration-and-secrets': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'lab', 'lab', 'lab', 'reflection'],
|
|
'root-10-m03-troubleshooting-framework': ['lesson', 'demonstration', 'knowledge_check', 'guided_exercise', 'lab', 'lab', 'reflection'],
|
|
'root-05-m01-pods-and-kubectl-basics': ['lesson', ...Array(20).fill('lab'), 'reflection'],
|
|
'root-11-m01-devops-platform-thinking': ['lesson', 'knowledge_check', 'reflection'],
|
|
};
|
|
for (const [modId, types] of Object.entries(expected)) {
|
|
const mod = moduleById(structure, modId);
|
|
assert.ok(mod, `${modId} must exist`);
|
|
assert.deepEqual(mod.activities.map((a) => a.activity_type), types, `${modId} activity chain mismatch`);
|
|
assert.equal(mod.status, modId === 'root-11-m01-devops-platform-thinking' ? 'partial' : 'ready', `${modId} status mismatch`);
|
|
}
|
|
});
|
|
|
|
await test('root-09-m02/m03 kept every original lab activity_id and scenario_id while gaining teaching activities', async () => {
|
|
const m02 = moduleById(structure, 'root-09-m02-serviceaccounts-and-rbac');
|
|
const labs = m02.activities.filter((a) => a.activity_type === 'lab').map((a) => a.activity_id);
|
|
assert.deepEqual(labs, ['root-09-m02-a03-lab-serviceaccount-pod', 'root-09-m02-a04-lab-automount-token', 'root-09-m02-a05-lab-rbac-role', 'root-09-m02-a06-lab-rbac-clusterrole', 'root-09-m02-a07-lab-rbac-least-privilege', 'root-09-m02-a08-lab-rbac-clusterrole-independent']);
|
|
});
|
|
|
|
await test('the three documented-exception modules were promoted to READY with no new activities added', async () => {
|
|
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').activities.length, 6, 'real-ops incidents must stay at 6 — no KC/guided added by design');
|
|
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').activities.length, 1, 'capstone module IS the exam, no bookending added');
|
|
assert.equal(moduleById(structure, 'root-12-m01-real-ops-incidents').status, 'ready');
|
|
assert.equal(moduleById(structure, 'root-capstone-m01-operate-and-recover').status, 'ready');
|
|
});
|
|
|
|
await test('the survey-depth security module (root-09-m04) is READY and carries the new assessment activity last', async () => {
|
|
const mod = moduleById(structure, 'root-09-m04-cluster-hardening-and-runtime');
|
|
assert.equal(mod.status, 'ready');
|
|
const last = mod.activities[mod.activities.length - 1];
|
|
assert.equal(last.activity_type, 'assessment');
|
|
assert.equal(last.assessment_definition_id, 'rootnode-security-assessment');
|
|
});
|
|
|
|
// ── 3/18: required/optional — the newly-wired optional readiness-probe lab ───────────────
|
|
await test('the new optional readiness-probe lab does not block root-10-m01 completion when skipped', async () => {
|
|
const student = await newStudent(owner, 'OptSkip');
|
|
// 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`);
|
|
assert.equal(status, 200);
|
|
}
|
|
await completeScenario(student.session, 'rootnode-probe-port-mismatch');
|
|
await student.session.request('POST', '/api/student/activities/root-10-m01-a06-reflection-real-ops-takeaway/complete');
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
const mod = moduleById(body.courses, 'root-10-m01-health-probes-and-troubleshooting');
|
|
assert.equal(mod.complete, true, 'module must be complete without the optional lab');
|
|
const optionalActivity = findActivity(body.courses, 'root-10-m01-a05b-lab-readiness-probe-optional');
|
|
assert.equal(optionalActivity.is_required, false);
|
|
assert.equal(optionalActivity.complete, false, 'the optional lab itself is correctly still incomplete');
|
|
});
|
|
|
|
// ── 4/12: new module activity chains + no fake completion from viewing ──────────────────
|
|
await test('viewing the course tree never marks a lesson complete on its own (no fake completion)', async () => {
|
|
const student = await newStudent(owner, 'NoFake');
|
|
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
const mod = moduleById(body.courses, 'root-05-m01-pods-and-kubectl-basics');
|
|
assert.equal(mod.complete, false);
|
|
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, false);
|
|
});
|
|
|
|
// ── 14: idempotency — completing the same activity twice never errors or double-counts ──
|
|
await test('completing the same activity twice is idempotent', async () => {
|
|
const student = await newStudent(owner, 'Idem');
|
|
// 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');
|
|
assert.ok([200, 409].includes(second.status), `second completion should 200 or 409, got ${second.status}`);
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
assert.equal(findActivity(body.courses, 'root-05-m01-a00-lesson-kubectl-fluency').complete, true);
|
|
});
|
|
|
|
// ── 6/17: instructor notes exclusion + RBAC on NEW Phase 6C content ──────────────────────
|
|
await test('instructor notes on new Phase 6C content are visible to instructors and never to students', async () => {
|
|
const lesson = findActivity(structure, 'root-07-m02-a00-lesson-configuration');
|
|
assert.ok(lesson.instructor_notes && lesson.instructor_notes.length > 0);
|
|
const student = await newStudent(owner, 'NotesCheck');
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
const studentLesson = findActivity(body.courses, 'root-07-m02-a00-lesson-configuration');
|
|
assert.equal(studentLesson.instructor_notes, undefined);
|
|
});
|
|
|
|
const { body: strayInstructor } = await owner.request('POST', '/api/admin/instructors', { email: `e2e6c-stray-${RUN_ID}@example.com`, display_name: 'Stray Instructor 6C', role: 'instructor', password: 'StrayInstructorPass6C!' });
|
|
const stray = makeSession(GW);
|
|
await stray.request('POST', '/api/auth/instructor/login', { email: `e2e6c-stray-${RUN_ID}@example.com`, password: 'StrayInstructorPass6C!' });
|
|
await test('a plain instructor with no relationship to the student cannot see their Phase 6C module progress', async () => {
|
|
const student = await newStudent(owner, 'RbacCheck');
|
|
const { status } = await stray.request('GET', `/api/admin/students/${student.studentId}/courses?program_id=${PROGRAM}`);
|
|
assert.equal(status, 403);
|
|
});
|
|
|
|
// ── 7/19: assessment bank — definition, server-authoritative draw, cross-student isolation ─
|
|
await test('the security assessment pool_spec matches the documented design (3 required + 6 random)', async () => {
|
|
const spec = JSON.parse(psql(`SELECT pool_spec FROM assessment_definitions WHERE assessment_definition_id = 'rootnode-security-assessment'`));
|
|
assert.deepEqual(spec.required, ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']);
|
|
const randomCount = spec.random.reduce((sum, g) => sum + g.count, 0);
|
|
assert.equal(randomCount, 6);
|
|
});
|
|
|
|
await test('two independent students each get their own 9-scenario draw, and it is server-authoritative', async () => {
|
|
const alice = await newStudent(owner, 'SecAliceA');
|
|
const bob = await newStudent(owner, 'SecBobA');
|
|
const aStart = await alice.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
|
const bStart = await bob.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
|
assert.equal(aStart.status, 201);
|
|
assert.equal(bStart.status, 201);
|
|
assert.equal(aStart.body.scenarios.length, 9);
|
|
assert.equal(bStart.body.scenarios.length, 9);
|
|
for (const s of ['cks-pod-security-context', 'cks-rbac-least-privilege', 'cks-network-policy']) {
|
|
assert.ok(aStart.body.scenarios.some((x) => x.scenario_id === s), `alice draw missing required scenario ${s}`);
|
|
}
|
|
assert.notEqual(aStart.body.assessment_session_id, bStart.body.assessment_session_id, 'each student must get an independent session');
|
|
// A student cannot read another student's assessment session (cross-student isolation).
|
|
const crossRead = await bob.session.request('GET', `/api/assessments/${aStart.body.assessment_session_id}`);
|
|
assert.ok([403, 404].includes(crossRead.status), `bob must not be able to read alice's session, got ${crossRead.status}`);
|
|
});
|
|
|
|
await test('completing the security assessment marks its activity complete in the course tree', async () => {
|
|
const student = await newStudent(owner, 'SecComplete');
|
|
const { body: start } = await student.session.request('POST', '/api/assessments/rootnode-security-assessment/start');
|
|
for (const s of start.scenarios) await completeScenario(student.session, s.scenario_id);
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
assert.equal(findActivity(body.courses, 'root-09-m04-a06-assessment-security').complete, true);
|
|
});
|
|
|
|
// ── 10: capstone gate — confirm the known architectural fact (ROOT 08/09 don't gate it) ──
|
|
await test('the capstone unlock gate is unaffected by ROOT 09 (security) being incomplete — documented architecture, not a bug', async () => {
|
|
const student = await newStudent(owner, 'CapstoneGate');
|
|
const { status, body } = await student.session.request('POST', '/api/assessments/rootnode-capstone/start');
|
|
assert.equal(status, 403);
|
|
assert.equal(body.error, 'locked');
|
|
assert.ok(!body.blocking_stage_ids.some((id) => String(id).includes('security')), 'security must never appear as a capstone-blocking stage');
|
|
});
|
|
|
|
// ── 11: ROOT 11 renders honestly per its actual partial/lesson-only status ───────────────
|
|
await test('ROOT 11 renders as partial with a lesson-only module and no fabricated Lab activity', async () => {
|
|
const course = courseById(structure, 'root-11-devops-platform-engineering');
|
|
assert.equal(course.status, 'partial');
|
|
const mod = moduleById(structure, 'root-11-m01-devops-platform-thinking');
|
|
assert.equal(mod.status, 'partial');
|
|
assert.ok(!mod.activities.some((a) => a.activity_type === 'lab'), 'ROOT 11 must not present a fake hands-on lab');
|
|
});
|
|
|
|
await test('a student can complete ROOT 11 module 1 end-to-end (lesson-only path)', async () => {
|
|
const student = await newStudent(owner, 'Root11');
|
|
// 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);
|
|
}
|
|
const { body } = await student.session.request('GET', `/api/student/programs/${student.enrollmentId}/courses`);
|
|
assert.equal(moduleById(body.courses, 'root-11-m01-devops-platform-thinking').complete, true);
|
|
});
|
|
|
|
// ── 16: version snapshot integrity on a NEW Phase 6C module ──────────────────────────────
|
|
await test('bumping a Phase 6C-upgraded module\'s version does not drop its content', async () => {
|
|
psql(`UPDATE course_modules SET version = version + 1 WHERE module_id = 'root-07-m02-configuration-and-secrets'`);
|
|
const { body } = await owner.request('GET', `/api/admin/programs/${PROGRAM}/courses`);
|
|
const mod = moduleById(body, 'root-07-m02-configuration-and-secrets');
|
|
assert.equal(mod.activities.length, 10);
|
|
assert.ok(mod.activities[0].body && mod.activities[0].body.length > 300);
|
|
});
|
|
|
|
// ── 24: content validation script itself passes as part of this suite ───────────────────
|
|
await test('infra/scripts/validate-course-content.js reports zero errors against the current database', () => {
|
|
execSync('node infra/scripts/validate-course-content.js', { cwd: `${__dirname}/../..`, stdio: 'pipe' });
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed ? 1 : 0);
|
|
}
|
|
|
|
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|