- 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).
225 lines
12 KiB
JavaScript
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); });
|