From face0ae132c76a6cefe198474096cc4633c9f4ab Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 13:36:45 -0500 Subject: [PATCH] test(phase3a): add identity and RBAC end-to-end coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- platform/test/e2e-phase2a.js | 51 +++----- platform/test/e2e-phase2b.js | 57 ++++----- platform/test/e2e-phase3a.js | 220 ++++++++++++++++++++++++++++++++ platform/test/session-helper.js | 53 ++++++++ 4 files changed, 315 insertions(+), 66 deletions(-) create mode 100644 platform/test/e2e-phase3a.js create mode 100644 platform/test/session-helper.js diff --git a/platform/test/e2e-phase2a.js b/platform/test/e2e-phase2a.js index 7430016..cc4d67d 100644 --- a/platform/test/e2e-phase2a.js +++ b/platform/test/e2e-phase2a.js @@ -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 }); diff --git a/platform/test/e2e-phase2b.js b/platform/test/e2e-phase2b.js index ab7940d..1025e4b 100644 --- a/platform/test/e2e-phase2b.js +++ b/platform/test/e2e-phase2b.js @@ -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= 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)); }); diff --git a/platform/test/e2e-phase3a.js b/platform/test/e2e-phase3a.js new file mode 100644 index 0000000..49edebd --- /dev/null +++ b/platform/test/e2e-phase3a.js @@ -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); }); diff --git a/platform/test/session-helper.js b/platform/test/session-helper.js new file mode 100644 index 0000000..30972ee --- /dev/null +++ b/platform/test/session-helper.js @@ -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 };