fix(capstone): enforce unlock rule server-side
POST /api/assessments/:definitionId/start only ever checked that the
definition existed and was active — it never checked unlock_rule. GET
/api/capstone correctly computed and displayed lock state, and the
student UI never exposed a start action while locked, but a direct POST
to this route bypassed the check entirely (documented as a known gap in
docs/PHASE6A_ROOT_COURSE_FOUNDATION.md §10). Student UI state is not the
enforcement boundary.
Fixed by binding to the exact same computeCurriculumState() gate
/api/capstone already relies on, rather than re-parsing
definition.unlock_rule independently — one computation of 'is this
unlocked,' not two that could drift. Only a definition the curriculum
path actually names as ITS capstone is gated this way; an ordinary exam
is completely unaffected. Returns a clean 403 { error: 'locked',
blocking_stage_ids } — the same shape /api/capstone already exposes.
Found while fixing this: e2e-phase5b.js's own capstone-completion test
had been passing only because of this exact gap — it never included the
real-ops stage in its completion loop before attempting to start the
capstone, and the missing unlock check let it succeed anyway. Fixed
alongside the platform code (REAL_OPS added to ALL_NON_CAPSTONE), not
routed around.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -156,6 +156,31 @@ module.exports = function registerLearningRoutes(app, deps) {
|
|||||||
);
|
);
|
||||||
const definition = rows[0];
|
const definition = rows[0];
|
||||||
if (!definition) return res.status(404).json({ error: 'not_found' });
|
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);
|
const cohortId = await studentCohortId(pool, req.studentId);
|
||||||
try {
|
try {
|
||||||
const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId });
|
const session = await assessmentsLib.startSession(pool, { definition, studentId: req.studentId, cohortId });
|
||||||
|
|||||||
@@ -38,7 +38,14 @@ const WORKLOADS = ['deploy-nginx', 'scale-deployment', 'rolling-update-task', 'l
|
|||||||
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
const NETWORKING = ['services-mcq', 'expose-service-basics'];
|
||||||
const CONFIGURATION = ['configmap-volume', 'resource-limits-task', 'env-vars-configmap', 'secrets-basics', 'pod-env-vars-inline'];
|
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 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;
|
let passed = 0, failed = 0;
|
||||||
async function test(name, fn) {
|
async function test(name, fn) {
|
||||||
|
|||||||
Reference in New Issue
Block a user