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:
2026-08-18 13:36:45 -05:00
parent 1ca5dd3995
commit face0ae132
4 changed files with 315 additions and 66 deletions
+20 -31
View File
@@ -1,38 +1,26 @@
#!/usr/bin/env node #!/usr/bin/env node
// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture. // Phase 2A student-flow regression, updated for Phase 3A's persistent session + per-instructor
// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as // auth model. Originally used X-Student-Id headers and a ?sid= query param — both mechanisms
// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact // are long gone; this version exercises the same flow through the real rn_session cookie, with
// same flow through the real rn_session cookie instead, since that's how it actually works now. // 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 // Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
// framework, matching project convention. // 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 assert = require('assert');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const COHORT = `e2e-test-${Date.now()}`; const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a if (!OWNER_EMAIL || !OWNER_PASSWORD) {
// browser does, so each "session" here explicitly carries its own Set-Cookie forward. console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
function makeSession() { process.exit(1);
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 } : {} });
},
};
} }
let passed = 0, failed = 0; let passed = 0, failed = 0;
@@ -42,10 +30,11 @@ async function test(name, fn) {
} }
async function main() { 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(); const admin = makeSession(GW);
await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD }); 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; let alice, bob;
await test('roster: add two students via admin', async () => { await test('roster: add two students via admin', async () => {
@@ -55,8 +44,8 @@ async function main() {
assert.notEqual(alice, bob); assert.notEqual(alice, bob);
}); });
const aliceSession = makeSession(); const aliceSession = makeSession(GW);
const bobSession = makeSession(); const bobSession = makeSession(GW);
await test('login: student session cookie is issued and required for curriculum', async () => { await test('login: student session cookie is issued and required for curriculum', async () => {
const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice }); const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice });
+22 -35
View File
@@ -1,38 +1,28 @@
#!/usr/bin/env node #!/usr/bin/env node
// Phase 2B classroom workflow — the actual acceptance test from // Phase 2B classroom workflow — the acceptance test from
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: instructor creates a cohort, adds three students, // docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md, updated for Phase 3A's per-instructor accounts:
// assigns curriculum, prepares Labs for the whole cohort, two students complete different // instructor creates a cohort, adds three students, assigns curriculum, prepares Labs for the
// scenarios, instructor resets one student's Lab, instructor destroys all cohort Labs after // whole cohort, two students complete different scenarios, instructor resets one student's
// class — progress survives every step. // 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, // Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots,
// concurrently). No framework, matching project convention. // concurrently). No framework, matching project convention.
// //
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d // 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 assert = require('assert');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; const OWNER_EMAIL = process.env.OWNER_EMAIL;
if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); } const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const COHORT_ID = `e2e-cohort-${Date.now()}`; if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
function makeSession() { process.exit(1);
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 COHORT_ID = `e2e-cohort-${Date.now()}`;
let passed = 0, failed = 0; let passed = 0, failed = 0;
async function test(name, fn) { async function test(name, fn) {
@@ -41,15 +31,15 @@ async function test(name, fn) {
} }
async function main() { 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 admin = makeSession(GW);
const other = makeSession(); const other = makeSession(GW);
await test('admin auth: wrong password rejected, correct password issues a session', async () => { await test('instructor auth: wrong password rejected, correct password issues a session', async () => {
const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' }); const bad = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong' });
assert.equal(bad.status, 401); 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); assert.equal(good.status, 200);
}); });
@@ -89,8 +79,8 @@ async function main() {
}); });
const [aliceId, bobId] = students; const [aliceId, bobId] = students;
const alice = makeSession(); const alice = makeSession(GW);
const bob = makeSession(); const bob = makeSession(GW);
await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => { 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 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 () => { 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`); const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80)); assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
}); });
+220
View File
@@ -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); });
+53
View File
@@ -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 };