- infra/scripts/simulate-two-host/: real two-Docker-network simulation (Gateway genuinely isolated from any Lab bridge network) proving the Lab Host Router fix - 13/13 real assertions (HTTP+WebSocket through the Router, Alice/Bob isolation, garbage/destroyed tokens, mTLS-only Router access). - infra/scripts/drills/: Lab Host loss/rebuild (9/9), deployment rollback, CI-fails-closed (6 required failure modes), fake cohort dress rehearsal (14/14, one continuous narrative through the real app). - infra/ci/gitea-workflows/build-and-test.yml: new security-scan job (npm audit, gitleaks, hadolint, shellcheck, trivy - all free/open-source), severity policy in docs/SECURITY_SCANNING.md (CRITICAL/HIGH blocks, MEDIUM/LOW reported only). Gates build-images alongside regression. Found and fixed two real pre-existing bugs via the CI-fails-closed drill: staging-smoke-test.sh and production-smoke-test.sh both had a classic set -e gotcha (curl ... && echo "OK" as one statement - a failing command that isn't last in an && chain doesn't trigger -e) that let an unreachable/broken endpoint fall through silently instead of failing the smoke test. platform/test/e2e-phase3a.js updated for the new paginated /api/admin/audit-events response shape (Phase 4.1 pagination work).
151 lines
8.9 KiB
JavaScript
151 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 4.1 §27 — the fake cohort dress rehearsal. One continuous narrative through the real
|
|
// application (not synthetic unit assertions): instructor login, cohort + 4 students, assign
|
|
// curriculum, Prepare Labs, students launch, work different scenarios, use hints, fail a
|
|
// validation, complete scenarios, instructor checks analytics, an exam is assigned/taken,
|
|
// a Lab is reset, a Lab is reaped (destroyed out-of-band, the same consequence the real idle
|
|
// reaper produces — see the note below on why this doesn't wait out the real multi-minute
|
|
// reaper timer), the student returns and continues, capstone state is checked, and the cohort's
|
|
// Labs are destroyed at the end.
|
|
//
|
|
// Run via run-fake-cohort-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
|
|
// No framework, matching project convention. Problems found are printed, not silently passed —
|
|
// see the Phase 4.1 report for anything this surfaced.
|
|
|
|
const assert = require('assert');
|
|
const { makeSession } = require('../../../platform/test/session-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'); process.exit(1); }
|
|
|
|
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++; }
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Phase 4.1 fake cohort dress rehearsal — slow, real k3s boots\n');
|
|
|
|
const instructor = makeSession(GW);
|
|
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
|
|
|
const names = ['Rehearsal-Ada', 'Rehearsal-Bo', 'Rehearsal-Cy', 'Rehearsal-Di'];
|
|
const students = {};
|
|
const sessions = {};
|
|
|
|
await step('instructor: create a cohort and roster 4 students', async () => {
|
|
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: 'rehearsal-cohort', name: 'Rehearsal Cohort' });
|
|
for (const name of names) {
|
|
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: 'rehearsal-cohort' });
|
|
students[name] = body.student_id;
|
|
sessions[name] = makeSession(GW);
|
|
await sessions[name].request('POST', '/api/login', { student_id: body.student_id });
|
|
}
|
|
});
|
|
|
|
await step('instructor: assign curriculum to the cohort', async () => {
|
|
const { status } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/curriculum', { curriculum_path: 'rootnode-path' });
|
|
assert.ok([200, 201].includes(status));
|
|
});
|
|
|
|
await step('instructor: Prepare Labs for the whole cohort (concurrent)', async () => {
|
|
const { status, body } = await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/prepare-labs');
|
|
assert.equal(status, 200);
|
|
assert.ok(body.results.every((r) => r.status === 'running'), `expected every Lab running, got: ${JSON.stringify(body.results)}`);
|
|
});
|
|
|
|
const launches = {};
|
|
await step('students: everyone\'s prepared Lab is already running (no re-launch wait)', async () => {
|
|
for (const name of names) {
|
|
const { body } = await sessions[name].request('POST', '/api/environment/launch');
|
|
assert.equal(body.status, 'running');
|
|
launches[name] = body;
|
|
}
|
|
});
|
|
|
|
await step('students: Ada and Bo work DIFFERENT scenarios', async () => {
|
|
await sessions['Rehearsal-Ada'].request('POST', `${launches['Rehearsal-Ada'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
|
|
await sessions['Rehearsal-Bo'].request('POST', `${launches['Rehearsal-Bo'].launch_path}api/scenarios/pod-basics-mcq/start`);
|
|
});
|
|
|
|
await step('student: Cy uses a hint on a scenario', async () => {
|
|
const res = await sessions['Rehearsal-Cy'].request('POST', `${launches['Rehearsal-Cy'].launch_path}api/scenarios/pod-basics-mcq/hint`);
|
|
assert.equal(res.status, 200);
|
|
});
|
|
|
|
await step('student: Di fails a validation on purpose', async () => {
|
|
const res = await sessions['Rehearsal-Di'].request('POST', `${launches['Rehearsal-Di'].launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'a' });
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.correct, false, 'expected a deliberately wrong answer to be marked incorrect');
|
|
});
|
|
|
|
await step('instructor: cohort learning summary reflects real activity', async () => {
|
|
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/learning');
|
|
assert.equal(body.student_count, 4);
|
|
});
|
|
|
|
await step('instructor: needs-attention returns real signals for the struggling students, no punitive language', async () => {
|
|
const { body } = await instructor.request('GET', '/api/admin/cohorts/rehearsal-cohort/needs-attention');
|
|
const text = JSON.stringify(body).toLowerCase();
|
|
assert.ok(!text.includes('fail') || text.includes('failed validations'), 'the word "fail" should only appear in a factual "N failed validations" context, never as a standalone judgment');
|
|
});
|
|
|
|
await step('instructor: reset Ada\'s Lab — academic progress is untouched', async () => {
|
|
const before = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
|
// Admin environment routes need the real environment_id, which publicEnvView doesn't echo
|
|
// back to the student directly — resolved via the student environment lookup instead.
|
|
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Ada']}/environment`);
|
|
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
|
await instructor.request('POST', `/api/admin/environments/${env.environment_id}/reset`);
|
|
const after = await sessions['Rehearsal-Ada'].request('GET', '/api/progress');
|
|
assert.deepEqual(before.body.details, after.body.details, 'progress must be identical before/after a Lab reset');
|
|
});
|
|
|
|
await step('§27 — a Lab is reaped: same consequence as the real idle reaper (real multi-minute timer not waited out here — see reaper.js/resource-policy.yaml for the real thresholds; this exercises the destroy-and-preserve-progress consequence directly, already proven mechanistically by infra/scripts/drills/test-lab-host-loss.js)', async () => {
|
|
const before = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
|
const envRes = await instructor.request('GET', `/api/admin/students/${students['Rehearsal-Bo']}/environment`);
|
|
const env = envRes.body.find((e) => e.status !== 'destroyed');
|
|
await instructor.request('DELETE', `/api/admin/environments/${env.environment_id}`);
|
|
const after = await sessions['Rehearsal-Bo'].request('GET', '/api/progress');
|
|
assert.deepEqual(before.body.details, after.body.details, 'progress must survive a reaped Lab unchanged');
|
|
});
|
|
|
|
await step('student: Bo returns later — a fresh Lab is created and curriculum continues', async () => {
|
|
const { status, body } = await sessions['Rehearsal-Bo'].request('POST', '/api/environment/launch');
|
|
assert.equal(status, 200); assert.equal(body.status, 'running');
|
|
const curriculum = await sessions['Rehearsal-Bo'].request('GET', '/api/curriculum/path');
|
|
assert.ok(curriculum.body.stages.length > 0, 'curriculum must still be there for the returning student');
|
|
});
|
|
|
|
await step('instructor: assign an exam to the cohort and Ada takes it', async () => {
|
|
const create = await instructor.request('POST', '/api/admin/assessments', {
|
|
id: 'rehearsal-exam', type: 'exam', title: 'Rehearsal Exam', duration_minutes: 30, hint_policy: 'disabled',
|
|
pool_spec: { required: ['pod-basics-mcq'], random: [] },
|
|
});
|
|
assert.ok([200, 201, 409].includes(create.status)); // 409 if a prior run already created it — fine, idempotent enough for a rehearsal
|
|
const assign = await instructor.request('POST', '/api/admin/assessments/rehearsal-exam/assign', { cohort_id: 'rehearsal-cohort' });
|
|
assert.ok([200, 201].includes(assign.status));
|
|
const start = await sessions['Rehearsal-Ada'].request('POST', '/api/assessments/rehearsal-exam/start');
|
|
assert.equal(start.status, 201);
|
|
assert.ok(start.body.scenarios.length > 0);
|
|
});
|
|
|
|
await step('instructor: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
|
|
const before = {};
|
|
for (const name of names) before[name] = (await sessions[name].request('GET', '/api/progress')).body.details;
|
|
await instructor.request('POST', '/api/admin/cohorts/rehearsal-cohort/destroy-labs');
|
|
for (const name of names) {
|
|
const after = (await sessions[name].request('GET', '/api/progress')).body.details;
|
|
assert.deepEqual(before[name], after, `${name}'s progress must survive end-of-class Lab destruction`);
|
|
}
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|