diff --git a/platform/test/e2e-phase3b.js b/platform/test/e2e-phase3b.js new file mode 100644 index 0000000..73a4445 --- /dev/null +++ b/platform/test/e2e-phase3b.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node +// Phase 3B — Learning & Assessment Engine acceptance test. Covers attempt telemetry, +// progressive hints, curriculum stage/capstone unlock rules, the exam engine (random draw, +// timer, expiry, restart persistence), the capstone workflow end to end, two-student isolation, +// and instructor learning analytics/RBAC. Runs against a live platform/docker-compose.dev.yml +// stack — slow (real k3s boots + a real API restart). No framework, matching project +// convention. +// +// 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-phase3b.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 API = process.env.PLATFORM_API_URL || 'http://localhost:4300'; +const OWNER_EMAIL = process.env.OWNER_EMAIL; +const OWNER_PASSWORD = process.env.OWNER_PASSWORD; +const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1'; +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 COHORT_ID = `e2e-3b-${RUN_ID}`; + +const FOUNDATION = ['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']; +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']; +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']; +const ALL_NON_FOUNDATION = [...WORKLOADS, ...NETWORKING, ...CONFIGURATION, ...TROUBLESHOOTING, ...REAL_OPS]; + +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 complete(session, scenarioId, envId) { + const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'completed', environment_id: envId }); + assert.equal(status, 200, `completing ${scenarioId} should 200`); +} +async function fail(session, scenarioId, envId) { + const { status } = await session.request('POST', '/api/progress/record', { scenario_id: scenarioId, status: 'in_progress', environment_id: envId }); + assert.equal(status, 200); +} + +async function main() { + console.log('Phase 3B learning/assessment acceptance test — slow, real k3s boots + a real API restart\n'); + + const owner = makeSession(GW); + await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + + await test('setup: cohort + two students + Prepare Labs', async () => { + assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E 3B' })).status, 201); + const a = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Alice-${RUN_ID}`, cohort_id: COHORT_ID }); + const b = await owner.request('POST', '/api/admin/students', { display_name: `E2E-3B-Bob-${RUN_ID}`, cohort_id: COHORT_ID }); + global.__alice = a.body.student_id; global.__bob = b.body.student_id; + const { status, body } = await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`); + assert.equal(status, 200); + assert.ok(body.results.every((r) => r.status === 'running'), `expected all running: ${JSON.stringify(body.results)}`); + }); + + const alice = makeSession(GW); + const bob = makeSession(GW); + let aliceEnv, bobEnv; + await test('students log in and launch their prepared labs', async () => { + await alice.request('POST', '/api/login', { student_id: global.__alice }); + await bob.request('POST', '/api/login', { student_id: global.__bob }); + const a = await alice.request('POST', '/api/environment/launch'); + const b = await bob.request('POST', '/api/environment/launch'); + assert.equal(a.body.status, 'running'); assert.equal(b.body.status, 'running'); + aliceEnv = a.body.launch_path.split('/')[2]; + bobEnv = b.body.launch_path.split('/')[2]; + // resolve environment_id via the admin view (routing token isn't the environment_id) + const aEnvRow = (await owner.request('GET', `/api/admin/students/${global.__alice}/environment`)).body.find((e) => e.status === 'running'); + const bEnvRow = (await owner.request('GET', `/api/admin/students/${global.__bob}/environment`)).body.find((e) => e.status === 'running'); + global.__aliceEnvId = aEnvRow.environment_id; global.__bobEnvId = bEnvRow.environment_id; + }); + + // ── Attempt telemetry ──────────────────────────────────────────────────────────────────── + await test('learning records: start, fail, pass — telemetry counters correct', async () => { + const sid = FOUNDATION[0]; + await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId }); + await fail(alice, sid, global.__aliceEnvId); + await fail(alice, sid, global.__aliceEnvId); + await complete(alice, sid, global.__aliceEnvId); + const { body } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`); + assert.equal(body.attempt.status, 'completed'); + assert.equal(body.attempt.validation_attempt_count, 3); + assert.equal(body.attempt.validation_pass_count, 1); + assert.equal(body.attempt.validation_fail_count, 2); + assert.ok(body.attempt.duration_seconds !== null); + }); + + // ── Progressive hints ──────────────────────────────────────────────────────────────────── + await test('hints: sequential reveal, exhaustion, and persistence after Lab reset', async () => { + const sid = 'namespaces-basics'; // has 2 hints — needed to exercise sequential reveal past level 1 + await alice.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__aliceEnvId }); + const h1 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId }); + assert.equal(h1.status, 200); assert.equal(h1.body.level, 1); + const h2 = await alice.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__aliceEnvId }); + assert.equal(h2.body.level, 2); + // Lab reset must not erase attempt/hint state (Phase 3B §9) — the attempt lives in Platform + // Postgres, keyed by environment_id, which survives a reset (same env row, new container). + const resetRes = await alice.request('POST', '/api/environment/reset'); + assert.equal(resetRes.body.status, 'running'); + const { body: attemptAfterReset } = await alice.request('GET', `/api/scenarios/${sid}/attempt?environment_id=${global.__aliceEnvId}`); + assert.equal(attemptAfterReset.attempt.highest_hint_level, 2); + assert.equal(attemptAfterReset.attempt.hint_count, 2); + }); + + // ── Curriculum completion + stage unlock ──────────────────────────────────────────────── + await test('curriculum: stage locked until prerequisites, unlocks on completion', async () => { + const before = await alice.request('GET', '/api/curriculum/state'); + assert.equal(before.body.stages.find((s) => s.id === 'workloads').locked, true); + for (const sid of FOUNDATION) await complete(alice, sid, global.__aliceEnvId); + const after = await alice.request('GET', '/api/curriculum/state'); + assert.equal(after.body.stages.find((s) => s.id === 'foundation').complete, true); + assert.equal(after.body.stages.find((s) => s.id === 'workloads').locked, false); + assert.equal(after.body.current_stage, 'workloads'); + }); + + await test('achievements: stage_completed recorded for Foundation', async () => { + const { body } = await alice.request('GET', '/api/achievements'); + assert.ok(body.some((a) => a.achievement_type === 'stage_completed' && a.stage_id === 'foundation')); + }); + + // ── Capstone lock/unlock ───────────────────────────────────────────────────────────────── + await test('capstone: locked before all stages complete', async () => { + const { body } = await alice.request('GET', '/api/capstone'); + assert.equal(body.status, 'locked'); + assert.ok(body.blocking_stage_ids.length > 0); + }); + + await test('capstone: unlocks once every prerequisite stage is complete', async () => { + for (const sid of ALL_NON_FOUNDATION) await complete(alice, sid, global.__aliceEnvId); + const { body } = await alice.request('GET', '/api/capstone'); + assert.equal(body.status, 'unlocked'); + assert.equal(body.blocking_stage_ids.length, 0); + }); + + let capstoneScenarios; + await test('capstone: start draws a scenario set featuring Real Ops prominently', async () => { + const { status, body } = await alice.request('POST', '/api/assessments/rootnode-capstone/start'); + assert.equal(status, 201); + capstoneScenarios = body.scenarios.map((s) => s.scenario_id); + assert.equal(capstoneScenarios.length, 7); // 4 required Real Ops + 3 random + for (const req of REAL_OPS) assert.ok(capstoneScenarios.includes(req), `capstone draw missing required ${req}`); + }); + + await test('capstone: cannot start a second assessment while one is active', async () => { + const { status, body } = await alice.request('POST', '/api/assessments/rootnode-quick-assessment/start'); + assert.equal(status, 409); assert.equal(body.error, 'already_active'); + }); + + await test('capstone: completing all drawn scenarios finalizes the session and records an achievement', async () => { + for (const sid of capstoneScenarios) await complete(alice, sid, global.__aliceEnvId); + const { body } = await alice.request('GET', '/api/capstone'); + assert.equal(body.status, 'completed'); + const ach = await alice.request('GET', '/api/achievements'); + assert.ok(ach.body.some((a) => a.achievement_type === 'capstone_completed')); + }); + + // ── Exam engine (Bob) ──────────────────────────────────────────────────────────────────── + let examSessionId, examScenarios, examExpiresAt; + await test('exam: assign to cohort, start, random draw matches the definition\'s pool spec', async () => { + assert.equal((await owner.request('POST', '/api/admin/assessments/rootnode-quick-assessment/assign', { cohort_id: COHORT_ID })).status, 200); + const { status, body } = await bob.request('POST', '/api/assessments/rootnode-quick-assessment/start'); + assert.equal(status, 201); + examSessionId = body.assessment_session_id; examScenarios = body.scenarios.map((s) => s.scenario_id); examExpiresAt = body.expires_at; + assert.equal(examScenarios.length, 5); // 2 + 1 + 1 + 1 per the seeded pool_spec + assert.ok(examScenarios.some((s) => REAL_OPS.includes(s))); + }); + + await test('exam: student sees own active assessment with a live timer', async () => { + const { body } = await bob.request('GET', '/api/assessments/active'); + assert.equal(body.active, true); + assert.equal(body.assessment_session_id, examSessionId); + }); + + await test('exam: hints are disabled per the definition\'s hint_policy', async () => { + const sid = examScenarios[0]; + await bob.request('POST', `/api/scenarios/${sid}/start`, { environment_id: global.__bobEnvId }); + const { status, body } = await bob.request('POST', `/api/scenarios/${sid}/hint`, { environment_id: global.__bobEnvId }); + assert.equal(status, 403); assert.equal(body.error, 'hints_disabled'); + }); + + await test('security: a student cannot read another student\'s assessment session', async () => { + const { status } = await alice.request('GET', `/api/assessments/${examSessionId}`); + assert.equal(status, 404); // exists, but not owned by Alice — same "don't confirm existence" pattern as RBAC 403s + }); + + await test('two students: distinct sessions, distinct draws, no cross-contamination', async () => { + assert.notEqual(examSessionId, undefined); + const aliceActive = await alice.request('GET', '/api/assessments/active'); + assert.equal(aliceActive.body.active, false); // Alice already finished her capstone + assert.deepEqual([...examScenarios].sort(), [...new Set(examScenarios)].sort()); // no duplicate scenario ids in Bob's draw + }); + + await test('exam: completing every drawn scenario finalizes the session', async () => { + for (const sid of examScenarios) await complete(bob, sid, global.__bobEnvId); + const { body } = await bob.request('GET', `/api/assessments/${examSessionId}`); + assert.equal(body.status, 'completed'); + assert.equal(body.result.scenarios_passed, examScenarios.length); + const ach = await bob.request('GET', '/api/achievements'); + assert.ok(ach.body.some((a) => a.achievement_type === 'assessment_completed')); + }); + + // ── Timer integrity: a fresh short exam, verify Lab reset doesn't touch the timer ───────── + let shortExamId, shortExamExpiry; + await test('timer: Lab reset mid-assessment does not change expires_at', async () => { + await owner.request('POST', '/api/admin/assessments', { + id: `e2e-timer-${RUN_ID}`, type: 'exam', title: 'Timer Test', duration_minutes: 30, + hint_policy: 'free', pool_spec: { required: [FOUNDATION[2]], random: [] }, + }); + await owner.request('POST', `/api/admin/assessments/e2e-timer-${RUN_ID}/assign`, { cohort_id: COHORT_ID }); + const start = await bob.request('POST', `/api/assessments/e2e-timer-${RUN_ID}/start`); + shortExamId = start.body.assessment_session_id; shortExamExpiry = start.body.expires_at; + await bob.request('POST', '/api/environment/reset'); + const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`); + assert.equal(body.expires_at, shortExamExpiry, 'timer must be Platform-authoritative, unaffected by Lab recreation'); + assert.equal(body.status, 'in_progress'); + }); + + // ── Session persistence across a real restart ──────────────────────────────────────────── + if (!SKIP_RESTART_TEST) { + await test('assessment session survives a real Platform API restart', async () => { + execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' }); + let up = false; + for (let i = 0; i < 30 && !up; i++) { + await new Promise((r) => setTimeout(r, 1000)); + try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ } + } + assert.ok(up, 'Platform API did not come back up in time'); + const { body } = await bob.request('GET', `/api/assessments/${shortExamId}`); + assert.equal(body.status, 'in_progress'); + assert.equal(body.expires_at, shortExamExpiry); + }); + } else { + console.log(' (skipped) assessment session survives a real Platform API restart — SKIP_RESTART_TEST=1'); + } + + // ── Instructor analytics + RBAC ────────────────────────────────────────────────────────── + await test('instructor: cohort learning summary reflects real completion data', async () => { + const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`); + assert.equal(body.student_count, 2); + // Only Alice worked the curriculum path — Bob only ever did the exam (drawn from + // workloads/networking/troubleshooting/real-ops, never Foundation) — so this also confirms + // completion counts are genuinely per-student, not just "any activity happened." + assert.equal(body.stages.find((s) => s.id === 'foundation').completedBy, 1); + }); + + await test('instructor: student detail shows attempts, hints, assessment history', async () => { + const { body } = await owner.request('GET', `/api/admin/students/${global.__alice}/learning`); + assert.ok(body.attempts.length > 0); + assert.ok(body.assessment_sessions.some((s) => s.type === 'capstone' && s.status === 'completed')); + }); + + await test('instructor: needs-attention returns reasonable signals, no punitive labels', async () => { + const { body } = await owner.request('GET', `/api/admin/cohorts/${COHORT_ID}/needs-attention`); + assert.ok(Array.isArray(body)); + assert.ok(body.every((s) => !/bad|fail(ed)? student|stupid/i.test(s.label))); + }); + + await test('RBAC: a plain instructor is scoped to their own cohort for learning routes', async () => { + const inst = await owner.request('POST', '/api/admin/instructors', { email: `teach3b-${RUN_ID}@rootnode.test`, display_name: 'Teach 3B', role: 'instructor', password: 'TeacherPassword3B1' }); + const otherCohort = `e2e-3b-other-${RUN_ID}`; + await owner.request('POST', '/api/admin/cohorts', { cohort_id: otherCohort, name: 'Other' }); + const teacher = makeSession(GW); + await teacher.request('POST', '/api/auth/instructor/login', { email: `teach3b-${RUN_ID}@rootnode.test`, password: 'TeacherPassword3B1' }); + assert.equal((await teacher.request('GET', `/api/admin/cohorts/${otherCohort}/learning`)).status, 403); + await owner.request('POST', `/api/admin/cohorts/${COHORT_ID}/instructors`, { instructor_id: inst.body.instructor_id }); + assert.equal((await teacher.request('GET', `/api/admin/cohorts/${COHORT_ID}/learning`)).status, 200); + }); + + await test('regression: no Lab container publishes a host port', async () => { + const { body: envs } = await owner.request('GET', `/api/admin/students/${global.__alice}/environment`); + assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80)); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +} + +main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });