Files
rootnode-academy/platform/test/e2e-phase2b.js
T
ced4568 face0ae132 test(phase3a): add identity and RBAC end-to-end coverage
New e2e-phase3a.js (21 cases): no account enumeration, CSRF positive/negative, login rate
limiting, RBAC across all three roles, cohort-scoped instructor access (visibility + route
access), last-owner protection, live role/status re-evaluation (no separate revoke needed),
password change (old password rejected after, other sessions revoked), audit log content
(including a no-secrets-in-metadata assertion), student session RBAC/logout, and session
persistence across a REAL docker compose restart api mid-test.

New session-helper.js — a shared cookie-jar test helper (Node's fetch doesn't persist
cookies across calls) that also auto-attaches X-CSRF-Token from whichever rn_csrf_*
cookie it's holding, replacing the single-cookie-string trackers e2e-phase2a.js and
e2e-phase2b.js each had their own copy of.

e2e-phase2a.js and e2e-phase2b.js updated for the new auth model: admin login is now a real
bootstrapped OWNER_EMAIL/OWNER_PASSWORD account instead of the old shared ADMIN_PASSWORD.
All cases still passing: 10/10 and 12/12 respectively.
2026-08-18 13:36:45 -05:00

142 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
// Phase 2B classroom workflow — the acceptance test from
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md, updated for Phase 3A's per-instructor accounts:
// instructor creates a cohort, adds three students, assigns curriculum, prepares Labs for the
// whole cohort, two students complete different scenarios, instructor resets one student's
// Lab, instructor destroys all cohort Labs after class — progress survives every step.
//
// Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots,
// concurrently). 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-phase2b.js
const assert = require('assert');
const { makeSession } = require('./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 (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const COHORT_ID = `e2e-cohort-${Date.now()}`;
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 main() {
console.log('Phase 2B classroom workflow acceptance test (Phase 3A auth) — slow, real k3s boots\n');
const admin = makeSession(GW);
const other = makeSession(GW);
await test('instructor auth: wrong password rejected, correct password issues a session', async () => {
const bad = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong' });
assert.equal(bad.status, 401);
const good = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(good.status, 200);
});
await test('security: a student session cannot call admin routes', async () => {
const res = await fetch(`${GW}/api/admin/dashboard`);
assert.equal(res.status, 401);
});
await test('admin: create cohort', async () => {
const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'E2E Cohort', tier: 'root' });
assert.equal(status, 201);
});
let students = [];
await test('admin: add three students to the cohort', async () => {
for (const name of ['E2E-Alice', 'E2E-Bob', 'E2E-Carlos']) {
const { status, body } = await admin.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
assert.equal(status, 201);
students.push(body.student_id);
}
const { body: roster } = await admin.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
assert.equal(roster.length, 3);
});
await test('admin: assign RootNode curriculum to the cohort', async () => {
const { status } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/curriculum`, { curriculum_path: 'rootnode-path' });
assert.equal(status, 200);
});
let prepResults;
await test('admin: Prepare Labs provisions all three concurrently — 3/3 READY', async () => {
const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/prepare-labs`);
assert.equal(status, 200);
prepResults = body.results;
assert.equal(prepResults.length, 3);
assert.ok(prepResults.every((r) => r.status === 'running'), `expected all running, got: ${JSON.stringify(prepResults)}`);
});
const [aliceId, bobId] = students;
const alice = makeSession(GW);
const bob = makeSession(GW);
await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => {
await alice.request('POST', '/api/login', { student_id: aliceId });
await bob.request('POST', '/api/login', { student_id: bobId });
const start = Date.now();
const { body: aliceEnv } = await alice.request('POST', '/api/environment/launch');
const { body: bobEnv } = await bob.request('POST', '/api/environment/launch');
const elapsedMs = Date.now() - start;
assert.equal(aliceEnv.status, 'running');
assert.equal(bobEnv.status, 'running');
assert.notEqual(aliceEnv.launch_path, bobEnv.launch_path);
// Reusing an already-prepared lab should be near-instant, not another full k3s boot.
assert.ok(elapsedMs < 15000, `expected instant reuse, took ${elapsedMs}ms`);
global.__aliceLaunch = aliceEnv; global.__bobLaunch = bobEnv;
});
await test('students: Alice and Bob complete DIFFERENT scenarios', async () => {
await alice.request('POST', `${global.__aliceLaunch.launch_path}api/scenarios/pod-basics-mcq/answer`, { selected: 'b' });
await bob.request('POST', `${global.__bobLaunch.launch_path}api/scenarios/kubectl-essentials-mcq/answer`, { selected: 'd' });
await new Promise((r) => setTimeout(r, 1500));
});
await test('admin: dashboard reflects student completions', async () => {
const { body } = await admin.request('GET', '/api/admin/dashboard');
assert.equal(body.labs.running, 3);
assert.ok(body.recent_completions.some((c) => c.student_id === aliceId));
});
let aliceEnvId;
await test('admin: reset Alice\'s Lab — academic progress remains intact', async () => {
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
aliceEnvId = envs.find((e) => e.status !== 'destroyed').environment_id;
const { status } = await admin.request('POST', `/api/admin/environments/${aliceEnvId}/reset`);
assert.equal(status, 200);
const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`);
const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
await test('admin: destroy all cohort Labs after class — progress remains intact for everyone', async () => {
const { status, body } = await admin.request('POST', `/api/admin/cohorts/${COHORT_ID}/destroy-labs`);
assert.equal(status, 200);
assert.ok(body.results.every((r) => r.status === 'destroyed'));
const { body: progress } = await admin.request('GET', `/api/admin/students/${aliceId}/progress`);
const scenario = progress.stages.find((s) => s.id === 'foundation').scenarios.find((sc) => sc.id === 'pod-basics-mcq');
assert.equal(scenario.status, 'completed');
});
await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => {
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/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); });