#!/usr/bin/env node // Phase 5C — enrollment operations & communications acceptance test. Covers the notification // lifecycle (queue/claim/sent/failed/retry), activation/enrollment-confirmation/password-reset/ // password-changed delivery, duplicate-webhook idempotency, retry/failure classification and // exhaustion, minor/no-email contact-email resolution, admin operations (resend, RBAC scoping), // security (token secrecy, production gating), and a real Platform API restart. Uses the // deterministic dev email provider throughout — no real network call is ever made by this // suite. Same conventions as e2e-phase5b.js: assert-based, no framework, live // docker-compose.dev.yml stack. // // 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=... INTEGRATION_KEY=... node test/e2e-phase5c.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'; const OWNER_EMAIL = process.env.OWNER_EMAIL; const OWNER_PASSWORD = process.env.OWNER_PASSWORD; const INTEGRATION_KEY = process.env.INTEGRATION_KEY || 'dev-integration-secret-not-for-production'; 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(); const PROGRAM = 'root-kubernetes-foundations'; 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++; } } function fireIntegrationEvent(payload) { return fetch(`${GW}/api/integrations/enrollments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(payload), }).then(async (r) => ({ status: r.status, body: await r.json() })); } async function waitForStatus(owner, studentId, wantStatus, timeoutMs = 8000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); const match = body.find((n) => n.status === wantStatus); if (match) return match; await new Promise((r) => setTimeout(r, 500)); } throw new Error(`timed out waiting for a notification with status=${wantStatus}`); } // Same idea, but for one SPECIFIC notification_id — waitForStatus alone isn't enough once a // student has more than one notification (e.g. right after a resend), since it would happily // match an EARLIER notification that already reached the wanted status instead of the new one. async function waitForNotificationStatus(owner, studentId, notificationId, wantStatus, timeoutMs = 8000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); const row = body.find((n) => n.notification_id === notificationId); if (row?.status === wantStatus) return row; await new Promise((r) => setTimeout(r, 500)); } throw new Error(`timed out waiting for notification ${notificationId} to reach status=${wantStatus}`); } // Nudges a still-queued row's next_attempt_at to now, so a test doesn't have to sit through // real exponential backoff (up to an hour) to observe retry/exhaustion behavior. function nudgeRetry() { execSync( `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE notification_events SET next_attempt_at = now() WHERE status = 'queued'"`, { cwd: `${__dirname}/..`, stdio: 'pipe' } ); } async function main() { console.log('Phase 5C enrollment operations & communications acceptance test\n'); const owner = makeSession(GW); await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); // ── §1-2: activation vs enrollment-confirmation ───────────────────────────────────────── let newStudentId, newEnrollmentId; await test('new student integration enrollment queues an activation notification', async () => { const { status, body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com`, display_name: 'New Student' }, }); assert.equal(status, 201); newStudentId = body.student_id; newEnrollmentId = body.enrollment_id; const sent = await waitForStatus(owner, newStudentId, 'sent'); assert.equal(sent.notification_type, 'activation'); }); await test('existing active student gets enrollment_confirmation instead of activation on a second program', async () => { // Activate the student from the previous test first (credentials required for this branch). const { body: preview } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`); const activationNotif = preview.find((n) => n.notification_type === 'activation'); const { body: devPreview } = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`); const token = devPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1]; const student = makeSession(GW); await student.request('POST', '/api/student/activate', { token, password: 'NewStudentPass123!' }); await owner.request('POST', '/api/admin/programs', { program_id: `p5c-second-${RUN_ID}`, name: 'Second Program', family: 'root' }); const { status, body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-second`, source: 'website', program_slug: `p5c-second-${RUN_ID}`, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com` }, }); assert.equal(status, 201); const sent = await waitForStatus(owner, newStudentId, 'sent', 8000); // waitForStatus returns the FIRST sent row it finds — there are now two (activation + // confirmation); assert the confirmation specifically exists among this student's notifications. const { body: all } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`); const confirmation = all.find((n) => n.notification_type === 'enrollment_confirmation' && n.enrollment_id === body.enrollment_id); assert.ok(confirmation, 'expected an enrollment_confirmation notification for the already-activated student'); }); // ── §3-4: duplicate/concurrent webhook -> no notification storm ──────────────────────── await test('duplicate integration event sends no duplicate notification', async () => { const { body: before } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`); await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-new`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-new-${RUN_ID}@example.com` } }); await new Promise((r) => setTimeout(r, 1500)); const { body: after } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`); assert.equal(after.length, before.length); }); await test('concurrent duplicate integration events send exactly one logical initial notification', async () => { const eventId = `p5c-${RUN_ID}-race`; const email = `p5c-racer-${RUN_ID}@example.com`; const fire = () => fireIntegrationEvent({ event_id: eventId, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email } }); const [a, b, c] = await Promise.all([fire(), fire(), fire()]); const studentId = a.body.student_id; await waitForStatus(owner, studentId, 'sent'); const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); assert.equal(notifs.length, 1, `expected exactly one notification, got ${notifs.length}`); }); // ── §5-10: worker delivery, retry, permanent failure, exhaustion ──────────────────────── await test('temporary provider failure retries (does not immediately fail)', async () => { const { body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-temp`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-temp-${RUN_ID}+forcetempfail@example.com` }, }); await new Promise((r) => setTimeout(r, 3500)); // let the first (failing) attempt run const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`); const notif = notifs[0]; assert.ok(['queued', 'sending'].includes(notif.status), `expected still-retrying status, got ${notif.status}`); assert.ok(notif.attempt_count >= 1); assert.equal(notif.last_error_code, 'simulated_temporary_failure'); }); await test('permanent failure becomes failed on the first attempt (no wasted retries)', async () => { const { body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-perm`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-perm-${RUN_ID}+forcepermfail@example.com` }, }); const failed = await waitForStatus(owner, body.student_id, 'failed'); assert.equal(failed.attempt_count, 1); assert.equal(failed.last_error_code, 'simulated_permanent_failure'); }); await test('retry exhaustion: a persistently-failing temporary error eventually becomes failed, bounded', async () => { const { body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-exhaust`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-exhaust-${RUN_ID}+forcetempfail@example.com` }, }); let finalStatus = null, finalAttempts = null; // MAX_ATTEMPTS is 5 (notifications.js) and the worker polls every NOTIFICATION_WORKER_POLL_MS // (default 3000ms) — each loop pass must outlast one poll cycle for the nudge to actually be // picked up before the next nudge, or this just re-nudges a row the worker hasn't touched yet. for (let i = 0; i < 10 && finalStatus !== 'failed'; i++) { nudgeRetry(); await new Promise((r) => setTimeout(r, 3500)); const { body: notifs } = await owner.request('GET', `/api/admin/students/${body.student_id}/notifications`); finalStatus = notifs[0]?.status; finalAttempts = notifs[0]?.attempt_count; } assert.equal(finalStatus, 'failed', `expected exhaustion to reach failed within the nudge loop (last status: ${finalStatus}, attempts: ${finalAttempts})`); assert.ok(finalAttempts <= 5, `expected a bounded attempt count, got ${finalAttempts}`); }); // ── §11-15: admin visibility, resend/regenerate ───────────────────────────────────────── await test('admin sees delivery status via both the global and per-student views', async () => { const { body: global_ } = await owner.request('GET', '/api/admin/notifications'); assert.ok(global_.items.length > 0); const { body: perStudent } = await owner.request('GET', `/api/admin/students/${newStudentId}/notifications`); assert.ok(perStudent.length > 0); assert.ok(!('metadata' in perStudent[0])); assert.ok(!('recipient_email' in perStudent[0])); // only the masked field is ever present }); let pendingStudentId, firstActivationNotifId, resentNotifId; await test('resend creates an intentional NEW notification attempt (not a mutation of the old row)', async () => { const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Resend Test', email: `p5c-resend-${RUN_ID}@example.com` }); pendingStudentId = created.student_id; await waitForStatus(owner, pendingStudentId, 'sent'); const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); firstActivationNotifId = before[0].notification_id; const { status, body: resent } = await owner.request('POST', `/api/admin/students/${pendingStudentId}/resend-activation`); assert.equal(status, 200); assert.notEqual(resent.notification_id, firstActivationNotifId); resentNotifId = resent.notification_id; }); let oldToken, newToken; await test('regenerate (resend) invalidates the previous activation token; old token now fails, new one works', async () => { const { status: previewStatus, body: firstPreview } = await owner.request('GET', `/api/admin/notifications/${firstActivationNotifId}/dev-preview`); if (previewStatus !== 200) throw new Error(`dev-preview for ${firstActivationNotifId} returned ${previewStatus}: ${JSON.stringify(firstPreview)}`); oldToken = firstPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1]; await waitForNotificationStatus(owner, pendingStudentId, resentNotifId, 'sent'); // the resend's OWN delivery, not just "any" sent notification const { status: secondPreviewStatus, body: secondPreview } = await owner.request('GET', `/api/admin/notifications/${resentNotifId}/dev-preview`); if (secondPreviewStatus !== 200) throw new Error(`dev-preview for ${resentNotifId} returned ${secondPreviewStatus}: ${JSON.stringify(secondPreview)}`); newToken = secondPreview.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1]; assert.notEqual(oldToken, newToken); const failWithOld = await makeSession(GW).request('POST', '/api/student/activate', { token: oldToken, password: 'ShouldNotWork123!' }); assert.equal(failWithOld.status, 400); const okWithNew = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'ResendPass123!' }); assert.equal(okWithNew.status, 200); }); await test('activation token is single-use (the new token, already consumed above, fails a second time)', async () => { const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: newToken, password: 'AnotherPass123!' }); assert.equal(status, 400); }); await test('activation token expiration is enforced server-side', async () => { const { body: created } = await owner.request('POST', '/api/admin/students', { display_name: 'Expiry Test', email: `p5c-expiry-${RUN_ID}@example.com` }); execSync( `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "UPDATE students SET credential_token_expires_at = now() - interval '1 hour' WHERE student_id = '${created.student_id}'"`, { cwd: `${__dirname}/..` } ); const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: created.activation_token, password: 'ExpiredPass123!' }); assert.equal(status, 400); }); // ── §18/§20/§21: token secrecy ─────────────────────────────────────────────────────────── await test('no activation/reset plaintext ever appears in any admin notification API response', async () => { const { body: list } = await owner.request('GET', '/api/admin/notifications'); const { body: perStudent } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); const serialized = JSON.stringify(list) + JSON.stringify(perStudent); assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken)); }); // ── §19-24: password reset ─────────────────────────────────────────────────────────────── await test('reset anti-enumeration preserved (identical response for real vs unknown identifier)', async () => { const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` }); const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}@example.com` }); assert.equal(real.body.message, fake.body.message); }); let resetToken; await test('reset email is queued and delivered for a valid account', async () => { const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-resend-${RUN_ID}@example.com` }); resetToken = res.body.dev_reset_token; assert.ok(resetToken); const sent = await waitForStatus(owner, pendingStudentId, 'sent', 8000); }); await test('reset token is single-use and expiration is enforced', async () => { const good = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'ResetPassNew123!' }); assert.equal(good.status, 200); const reuse = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'AnotherOne123!' }); assert.equal(reuse.status, 400); }); await test('password reset revokes prior sessions and the new password works', async () => { const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' }); assert.equal(login.status, 200); }); // ── §25-26: minor/no-email contact-email path ─────────────────────────────────────────── await test('minor/no-email recipient flow: contact_email delivers the activation notification', async () => { const { status, body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-minor`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { login_identifier: `p5c-minor-${RUN_ID}`, display_name: 'Minor Student', contact_email: `p5c-guardian-${RUN_ID}@example.com` }, }); assert.equal(status, 201); const sent = await waitForStatus(owner, body.student_id, 'sent'); const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`); assert.equal(preview.to, `p5c-guardian-${RUN_ID}@example.com`); }); await test('the guardian contact email is NEVER written to students.email', async () => { const out = execSync( `docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -tA -c "SELECT email FROM students WHERE login_identifier = 'p5c-minor-${RUN_ID}'"`, { cwd: `${__dirname}/..` } ).toString().trim(); assert.equal(out, ''); }); // ── §27-30: integration auth, cross-student isolation, RBAC scoping ──────────────────── await test('integration auth remains separate from student/admin session auth', async () => { const res = await fetch(`${GW}/api/integrations/enrollments`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` }, body: JSON.stringify({ event_id: 'x', program_slug: PROGRAM, status: 'paid', student: { email: 'x@example.com' } }), }); assert.equal(res.status, 401); }); await test('a student session cannot invoke any admin notification route', async () => { const student = makeSession(GW); await student.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' }); const { status } = await student.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); assert.ok([401, 403].includes(status)); }); // ── Regression: no arbitrary email template injection (§21) ──────────────────────────── // Found and fixed during this phase — display_name flows straight from a website // integration event (genuinely external, caller-controlled text) into the activation // email's HTML. templates.js now escapes every interpolated value in the HTML body. await test('a display_name containing HTML markup is escaped in the rendered email, not injected', async () => { const maliciousName = 'Injected'; const { status, body } = await fireIntegrationEvent({ event_id: `p5c-${RUN_ID}-injection`, source: 'website', program_slug: PROGRAM, status: 'paid', student: { email: `p5c-injection-${RUN_ID}@example.com`, display_name: maliciousName }, }); assert.equal(status, 201); const sent = await waitForStatus(owner, body.student_id, 'sent'); const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`); assert.ok(!preview.html.includes(''), 'raw markup must never appear unescaped in the HTML body'); assert.ok(preview.html.includes('<img'), 'the escaped form must be present instead'); }); const { body: unrelatedInstr } = await owner.request('POST', '/api/admin/instructors', { email: `p5c-unrelated-${RUN_ID}@rootnode.test`, display_name: 'Unrelated', role: 'instructor', password: 'UnrelatedPass123!', }); await test('instructor RBAC/scoping preserved for notification visibility', async () => { const unrelated = makeSession(GW); await unrelated.request('POST', '/api/auth/instructor/login', { email: `p5c-unrelated-${RUN_ID}@rootnode.test`, password: 'UnrelatedPass123!' }); const scopedList = await unrelated.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); assert.equal(scopedList.status, 403); const globalList = await unrelated.request('GET', '/api/admin/notifications'); assert.equal(globalList.status, 403); // global view is owner/admin only }); // ── §33-34: metrics / audit hygiene ────────────────────────────────────────────────────── await test('metrics contain notification gauges/counters with no PII', async () => { const res = await fetch(`${API}/metrics`); const text = await res.text(); assert.ok(text.includes('rootnode_notifications_total')); assert.ok(text.includes('rootnode_notification_delivery_attempts_total')); assert.ok(!text.includes(`p5c-resend-${RUN_ID}@example.com`)); assert.ok(!text.includes(pendingStudentId)); assert.ok(!text.includes(newToken || '')); }); await test('audit events contain no tokens/secrets', async () => { const { body } = await owner.request('GET', '/api/admin/audit-events?page=1'); const serialized = JSON.stringify(body); assert.ok(!serialized.includes(newToken) && !serialized.includes(oldToken) && !serialized.includes(resetToken)); assert.ok(!/password_hash|credential_token_hash/i.test(serialized)); }); // ── production gating (§8/§21) — recreate the SAME api service with COOKIE_SECURE=1 via the // standard compose env-file mechanism (no ad hoc container/port juggling under // network_mode: host, which doesn't tolerate a second container easily) ───────────────── await test('production mode never returns dev_reset_token or exposes the dev-preview route', async () => { const cwd = `${__dirname}/..`; execSync(`bash -c "echo COOKIE_SECURE=1 >> .env"`, { cwd, stdio: 'pipe' }); try { execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' }); await new Promise((r) => setTimeout(r, 3000)); const reset = await fetch(`${API}/api/student/request-password-reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: `p5c-resend-${RUN_ID}@example.com` }), }).then((r) => r.json()); assert.ok(!('dev_reset_token' in reset), 'production must never echo the reset token'); // The dev-preview route also refuses outright once COOKIE_SECURE=1, regardless of what a // notification row happens to hold. const preview = await fetch(`${API}/api/admin/notifications/${firstActivationNotifId}/dev-preview`, { headers: { Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` } }); assert.equal(preview.status, 404); } finally { execSync(`bash -c "grep -v '^COOKIE_SECURE=' .env > .env.tmp && mv .env.tmp .env"`, { cwd, stdio: 'pipe' }); execSync('docker compose -f docker-compose.dev.yml up -d --force-recreate api', { cwd, stdio: 'pipe' }); await new Promise((r) => setTimeout(r, 3000)); } }); // ── §31-32/§35-36: restart persistence + regression smoke ─────────────────────────────── if (!SKIP_RESTART_TEST) { await test('a real Platform API restart preserves the notification queue and does not duplicate an already-sent notification', async () => { const { body: before } = await owner.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' }); await new Promise((r) => setTimeout(r, 4000)); const reLogin = makeSession(GW); await reLogin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); const { body: after } = await reLogin.request('GET', `/api/admin/students/${pendingStudentId}/notifications`); assert.equal(after.length, before.length, 'restart must not duplicate any notification row'); const sentCount = after.filter((n) => n.status === 'sent').length; const sentCountBefore = before.filter((n) => n.status === 'sent').length; assert.equal(sentCount, sentCountBefore); }); } else { console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)'); } await test('Phase 5B enrollment/resume still works unmodified', async () => { const s = makeSession(GW); await s.request('POST', '/api/student/login', { identifier: `p5c-resend-${RUN_ID}@example.com`, password: 'ResetPassNew123!' }); const { status, body } = await s.request('GET', '/api/student/programs'); assert.equal(status, 200); }); await test('Phase 5A instructor authentication still works unmodified', async () => { const s = makeSession(GW); const { status } = await s.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(status, 200); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } main().catch((e) => { console.error(e); process.exit(1); });