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.
This commit is contained in:
@@ -1,38 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture.
|
||||
// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as
|
||||
// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact
|
||||
// same flow through the real rn_session cookie instead, since that's how it actually works now.
|
||||
// Phase 2A student-flow regression, updated for Phase 3A's persistent session + per-instructor
|
||||
// auth model. Originally used X-Student-Id headers and a ?sid= query param — both mechanisms
|
||||
// are long gone; this version exercises the same flow through the real rn_session cookie, with
|
||||
// the admin roster-add step now going through a real instructor account instead of a shared
|
||||
// ADMIN_PASSWORD.
|
||||
//
|
||||
// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
|
||||
// framework, matching project convention.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js
|
||||
// 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-phase2a.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const COHORT = `e2e-test-${Date.now()}`;
|
||||
|
||||
// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a
|
||||
// browser does, so each "session" here explicitly carries its own Set-Cookie forward.
|
||||
function makeSession() {
|
||||
let cookie = '';
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) cookie = setCookie.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
rawFetch(path) {
|
||||
return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} });
|
||||
},
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
@@ -42,10 +30,11 @@ async function test(name, fn) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 2A student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n');
|
||||
console.log('Phase 2A student-flow regression (Phase 3A sessions/auth) — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession();
|
||||
await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD });
|
||||
const admin = makeSession(GW);
|
||||
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(loginStatus, 200, 'owner login must succeed — did you bootstrap one?');
|
||||
|
||||
let alice, bob;
|
||||
await test('roster: add two students via admin', async () => {
|
||||
@@ -55,8 +44,8 @@ async function main() {
|
||||
assert.notEqual(alice, bob);
|
||||
});
|
||||
|
||||
const aliceSession = makeSession();
|
||||
const bobSession = makeSession();
|
||||
const aliceSession = makeSession(GW);
|
||||
const bobSession = makeSession(GW);
|
||||
|
||||
await test('login: student session cookie is issued and required for curriculum', async () => {
|
||||
const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice });
|
||||
|
||||
@@ -1,38 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 2B classroom workflow — the actual acceptance test from
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: 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.
|
||||
// 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
|
||||
// ADMIN_PASSWORD=<value from platform/.env> node test/e2e-phase2b.js
|
||||
// (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 ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); }
|
||||
const COHORT_ID = `e2e-cohort-${Date.now()}`;
|
||||
|
||||
function makeSession() {
|
||||
let cookie = '';
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) cookie = setCookie.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
};
|
||||
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) {
|
||||
@@ -41,15 +31,15 @@ async function test(name, fn) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 2B classroom workflow acceptance test — slow, real k3s boots\n');
|
||||
console.log('Phase 2B classroom workflow acceptance test (Phase 3A auth) — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession();
|
||||
const other = makeSession();
|
||||
const admin = makeSession(GW);
|
||||
const other = makeSession(GW);
|
||||
|
||||
await test('admin auth: wrong password rejected, correct password issues a session', async () => {
|
||||
const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' });
|
||||
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/admin/login', { password: ADMIN_PASSWORD });
|
||||
const good = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(good.status, 200);
|
||||
});
|
||||
|
||||
@@ -89,8 +79,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const [aliceId, bobId] = students;
|
||||
const alice = makeSession();
|
||||
const bob = makeSession();
|
||||
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 });
|
||||
@@ -140,9 +130,6 @@ async function main() {
|
||||
});
|
||||
|
||||
await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => {
|
||||
// Sanity check via the admin environment list — this doesn't re-run the Provisioner's own
|
||||
// iptables tests (see platform/provisioner/test/run.js for those), just confirms nothing
|
||||
// in the Phase 2B layer introduced a published port.
|
||||
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
|
||||
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/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 () => {
|
||||
const { body } = await owner.request('GET', '/api/admin/audit-events');
|
||||
const actions = body.map((e) => e.action);
|
||||
assert.ok(actions.includes('login'));
|
||||
assert.ok(actions.includes('authz_denied'));
|
||||
assert.ok(actions.includes('instructor_update'));
|
||||
assert.ok(body.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); });
|
||||
@@ -0,0 +1,53 @@
|
||||
// Shared cookie-jar test helper for the e2e suites (Phase 2A/2B/3A). Node's fetch doesn't
|
||||
// persist cookies across calls the way a browser does, and Phase 3A's CSRF double-submit
|
||||
// pattern means every state-changing request needs an X-CSRF-Token header echoing whichever
|
||||
// rn_csrf_* cookie the jar is currently holding — this centralizes both instead of repeating
|
||||
// slightly-different versions in every test file.
|
||||
|
||||
function makeSession(baseUrl) {
|
||||
const jar = new Map(); // cookie name -> value
|
||||
|
||||
function cookieHeader() {
|
||||
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
function csrfToken() {
|
||||
return jar.get('rn_csrf_s') || jar.get('rn_csrf_i') || '';
|
||||
}
|
||||
function absorbSetCookie(res) {
|
||||
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
for (const c of cookies) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
const value = pair.slice(eq + 1).trim();
|
||||
if (value === '') jar.delete(name); else jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const cookies = cookieHeader();
|
||||
if (cookies) headers.Cookie = cookies;
|
||||
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = csrfToken();
|
||||
const res = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
absorbSetCookie(res);
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
rawFetch(path) {
|
||||
const cookies = cookieHeader();
|
||||
return fetch(`${baseUrl}${path}`, { headers: cookies ? { Cookie: cookies } : {} });
|
||||
},
|
||||
hasCookie(name) { return jar.has(name); },
|
||||
getCookie(name) { return jar.get(name); },
|
||||
// Lets a test simulate "this browser was never issued a CSRF cookie" or "attach the wrong
|
||||
// one" for a negative CSRF test, without hand-rolling a second fetch call.
|
||||
setCookie(name, value) { jar.set(name, value); },
|
||||
clearCookie(name) { jar.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { makeSession };
|
||||
Reference in New Issue
Block a user