diff --git a/platform/api/src/learning-routes.js b/platform/api/src/learning-routes.js index aae3b1b..0191ff6 100644 --- a/platform/api/src/learning-routes.js +++ b/platform/api/src/learning-routes.js @@ -156,6 +156,31 @@ module.exports = function registerLearningRoutes(app, deps) { ); const definition = rows[0]; if (!definition) return res.status(404).json({ error: 'not_found' }); + + // Phase 6B §10 — a known Phase 3B gap, documented in docs/PHASE6A_ROOT_COURSE_FOUNDATION.md + // §10: this route only ever checked the definition existed/was active. GET /api/capstone + // computes and displays lock state correctly and the UI never exposes a "start" action while + // locked, but student UI state is not the enforcement boundary — a direct POST here could + // start a capstone session before its prerequisite stages were actually complete. Fixed by + // binding to the SAME gate /api/capstone already derives from the curriculum path's own + // capstone stage (computeCurriculumState), rather than re-parsing definition.unlock_rule + // independently — one computation of "is the capstone unlocked," not two that could drift. + // Only a definition the path actually names as ITS capstone is gated this way; an ordinary + // exam (no capstone stage names it) is unaffected — see e2e-phase6b.js for both cases. + const path = curriculum.loadCurriculumPath(); + const capstoneStage = path?.stages.find((s) => s.type === 'capstone' && s.capstone_assessment_id === definition.assessment_definition_id); + if (capstoneStage) { + const progress = await loadProgress(pool, req.studentId); + const capstoneAchievement = await getCapstoneAchievement(pool, req.studentId); + const state = computeCurriculumState(path, progress, capstoneAchievement ? 'completed' : undefined); + if (state.capstone?.status === 'locked') { + return res.status(403).json({ + error: 'locked', message: 'Complete the required stages before starting this assessment.', + blocking_stage_ids: state.capstone.blocking_stage_ids, + }); + } + } + const cohortId = await studentCohortId(pool, req.studentId); try { const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId }); diff --git a/platform/test/e2e-phase5b.js b/platform/test/e2e-phase5b.js index cc4d430..9b135cb 100644 --- a/platform/test/e2e-phase5b.js +++ b/platform/test/e2e-phase5b.js @@ -38,7 +38,14 @@ const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'l const NETWORKING = ['services-mcq', 'expose-service-basics']; const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline']; const TROUBLESHOOTING = ['extract-logs-basics', 'kubectl-cp-basics', 'exec-command-basics', 'node-cordon-basics', 'pod-node-assignment-mcq']; -const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING]; +// Phase 6B §10 — REAL_OPS was missing from this list entirely, which this test never noticed +// because POST /api/assessments/:id/start didn't check the capstone's unlock_rule server-side +// before Phase 6B's fix (docs/PHASE6A_ROOT_COURSE_FOUNDATION.md §10). Now that the fix actually +// enforces "all 6 stages complete," a genuine capstone attempt needs all 6, real-ops included — +// completing only 5 of 6 is not a legitimately-unlocked capstone and should not (and now does +// not) be accepted. Found by testing. +const REAL_OPS = ['rootnode-runaway-monitor', 'broken-deployment', 'rootnode-probe-port-mismatch', 'rootnode-enforce-limits']; +const ALL_NON_CAPSTONE = [...FOUNDATION, ...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING, ...REAL_OPS]; let passed = 0, failed = 0; async function test(name, fn) {