Files
rootnode-academy/platform/test/e2e-phase3a.js
T
ced4568 f3c356951c test(preprod): two-host routing simulation, drill suite, CI security scanning
- 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).
2026-08-24 20:16:00 -05:00

225 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Phase 3A — identity and instructor access. Covers session persistence (including a real
// Platform API restart), per-instructor auth (no shared ADMIN_PASSWORD), RBAC across
// owner/admin/instructor, cohort-scoped instructor access, CSRF enforcement, login rate
// limiting, audit logging, and a repeat of the Phase 2B classroom workflow with a real
// instructor account instead of a shared password.
//
// Runs against a live platform/docker-compose.dev.yml stack, and shells out to `docker compose
// restart api` for the restart-persistence test — so it must run from a checkout where that
// compose file's `api` service is actually running (see Usage). 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-phase3a.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'; // direct — a few checks (restart) need to bypass the Gateway's proxy caching
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();
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 3A identity/RBAC acceptance test — slow, real k3s boots + a real API restart\n');
const owner = makeSession(GW);
await test('instructor auth: no account enumeration (unknown email and wrong password look identical)', async () => {
const unknown = await owner.request('POST', '/api/auth/instructor/login', { email: `nobody-${RUN_ID}@rootnode.test`, password: 'whatever12345' });
const wrongPw = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong-password' });
assert.equal(unknown.status, 401);
assert.equal(wrongPw.status, 401);
assert.deepEqual(unknown.body, wrongPw.body);
});
await test('instructor auth: valid login issues a session + CSRF cookie', async () => {
const { status } = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(status, 200);
assert.ok(owner.hasCookie('rn_instructor'));
assert.ok(owner.hasCookie('rn_csrf_i'));
});
await test('CSRF: mutating request without the header is rejected', async () => {
const res = await fetch(`${GW}/api/admin/cohorts`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
body: JSON.stringify({ cohort_id: `csrf-should-fail-${RUN_ID}`, name: 'nope' }),
});
assert.equal(res.status, 403);
});
await test('CSRF: mutating request WITH the header succeeds', async () => {
const { status } = await owner.request('POST', '/api/admin/cohorts', { cohort_id: `csrf-ok-${RUN_ID}`, name: 'CSRF OK' });
assert.equal(status, 201);
});
await test('rate limiting: repeated bad logins for one identity get throttled', async () => {
const probe = makeSession(GW);
let sawThrottle = false;
for (let i = 0; i < 9; i++) {
const { status } = await probe.request('POST', '/api/auth/instructor/login', { email: `throttle-${RUN_ID}@rootnode.test`, password: 'wrong' });
if (status === 429) sawThrottle = true;
}
assert.ok(sawThrottle, 'expected a 429 within 9 rapid attempts');
});
let adminId, instructorId;
await test('owner: create an admin-role and an instructor-role account', async () => {
const a = await owner.request('POST', '/api/admin/instructors', { email: `admin-${RUN_ID}@rootnode.test`, display_name: 'E2E Admin', role: 'admin', password: 'AdminPassword123' });
assert.equal(a.status, 201);
adminId = a.body.instructor_id;
const i = await owner.request('POST', '/api/admin/instructors', { email: `teach-${RUN_ID}@rootnode.test`, display_name: 'E2E Teacher', role: 'instructor', password: 'TeacherPassword123' });
assert.equal(i.status, 201);
instructorId = i.body.instructor_id;
});
const teacher = makeSession(GW);
await test('RBAC: instructor role rejected from owner-only routes', async () => {
await teacher.request('POST', '/api/auth/instructor/login', { email: `teach-${RUN_ID}@rootnode.test`, password: 'TeacherPassword123' });
const { status } = await teacher.request('GET', '/api/admin/instructors');
assert.equal(status, 403);
});
await test('RBAC: instructor role rejected from admin/owner cohort-creation route', async () => {
const { status } = await teacher.request('POST', '/api/admin/cohorts', { cohort_id: `teacher-should-fail-${RUN_ID}`, name: 'nope' });
assert.equal(status, 403);
});
const admin = makeSession(GW);
await test('RBAC: admin role can create cohorts (below owner, above instructor)', async () => {
await admin.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: `admin-ok-${RUN_ID}`, name: 'Admin OK' });
assert.equal(status, 201);
});
await test('RBAC: admin role rejected from owner-only instructor management', async () => {
const { status } = await admin.request('GET', '/api/admin/instructors');
assert.equal(status, 403);
});
const scopedCohort = `scoped-${RUN_ID}`;
const unscopedCohort = `unscoped-${RUN_ID}`;
await test('cohort scoping: owner creates two cohorts, assigns the instructor to only one', async () => {
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: scopedCohort, name: 'Scoped' })).status, 201);
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: unscopedCohort, name: 'Unscoped' })).status, 201);
assert.equal((await owner.request('POST', `/api/admin/cohorts/${scopedCohort}/instructors`, { instructor_id: instructorId })).status, 200);
});
await test('cohort scoping: instructor sees the assigned cohort, not the other', async () => {
const { body } = await teacher.request('GET', '/api/admin/cohorts');
const ids = body.map((c) => c.cohort_id);
assert.ok(ids.includes(scopedCohort));
assert.ok(!ids.includes(unscopedCohort));
});
await test('cohort scoping: instructor allowed on the assigned cohort, 403 on the other', async () => {
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${scopedCohort}/students`)).status, 200);
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${unscopedCohort}/students`)).status, 403);
});
await test('owner-safety: cannot deactivate the last active owner', async () => {
const { body: whoami } = await owner.request('GET', '/api/auth/session');
const { status, body } = await owner.request('PATCH', `/api/admin/instructors/${whoami.instructor.instructor_id}`, { status: 'inactive' });
assert.equal(status, 400);
assert.equal(body.error, 'last_owner');
});
await test('role/status changes take effect on the very next request, no separate revoke needed', async () => {
assert.equal((await teacher.request('GET', '/api/admin/dashboard')).status, 200); // works before deactivation
await owner.request('PATCH', `/api/admin/instructors/${instructorId}`, { status: 'inactive' });
const { status } = await teacher.request('GET', '/api/admin/dashboard');
assert.equal(status, 401);
});
await test('password change: old password rejected afterward, other sessions revoked', async () => {
const second = makeSession(GW);
await second.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
await admin.request('POST', '/api/auth/change-password', { old_password: 'AdminPassword123', new_password: 'NewAdminPassword456' });
const stillOld = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }) });
assert.equal(stillOld.status, 401);
const newWorks = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'NewAdminPassword456' }) });
assert.equal(newWorks.status, 200);
// The OTHER session (logged in before the change) should now be revoked.
const { status } = await second.request('GET', '/api/auth/session');
assert.equal(status, 401);
});
await test('audit log: security events were actually recorded', async () => {
// Phase 4.1 §16 — /api/admin/audit-events is now paginated (see platform/api/src/pagination.js);
// the events land on the most recent page, but this test has produced fewer than one
// page's worth by this point, so page 1's items are still every event so far.
const { body } = await owner.request('GET', '/api/admin/audit-events');
const items = body.items;
const actions = items.map((e) => e.action);
assert.ok(actions.includes('login'));
assert.ok(actions.includes('authz_denied'));
assert.ok(actions.includes('instructor_update'));
assert.ok(items.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material');
});
// ── Student session hardening ──────────────────────────────────────────────────────────
let studentId;
await test('roster: create a student for session tests', async () => {
const { status, body } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-Session-${RUN_ID}` });
assert.equal(status, 201);
studentId = body.student_id;
});
const student = makeSession(GW);
await test('student session: cannot call admin routes', async () => {
await student.request('POST', '/api/login', { student_id: studentId });
const { status } = await student.request('GET', '/api/admin/dashboard');
assert.equal(status, 401);
});
await test('student session: logout revokes, curriculum then rejected', async () => {
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 200);
await student.request('POST', '/api/logout');
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 401);
});
// ── Session persistence across a real restart ──────────────────────────────────────────
if (!SKIP_RESTART_TEST) {
await test('session persistence: instructor AND student sessions survive a Platform API restart', async () => {
const persistOwner = makeSession(GW);
await persistOwner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const persistStudent = makeSession(GW);
await persistStudent.request('POST', '/api/login', { student_id: studentId });
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' });
// Give the container a moment to re-apply migrations and start listening again.
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');
assert.equal((await persistOwner.request('GET', '/api/auth/session')).status, 200);
assert.equal((await persistStudent.request('GET', '/api/curriculum/path')).status, 200);
});
} else {
console.log(' (skipped) session persistence: restart test — SKIP_RESTART_TEST=1');
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });