diff --git a/infra/scripts/drills/run-phase5c-enrollment-activation-drill.sh b/infra/scripts/drills/run-phase5c-enrollment-activation-drill.sh new file mode 100755 index 0000000..419463e --- /dev/null +++ b/infra/scripts/drills/run-phase5c-enrollment-activation-drill.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Orchestrates the Phase 5C §25 enrollment/activation dress rehearsal: brings up the real dev +# stack, bootstraps an owner, runs test-phase5c-enrollment-activation.js, tears down. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform" + +export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}" +export INTEGRATION_SHARED_SECRET="${INTEGRATION_SHARED_SECRET:-dev-integration-secret-not-for-production}" + +docker compose -f docker-compose.dev.yml up -d --build + +cleanup() { + echo "==> Tearing down" + docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Waiting for the stack" +for _ in $(seq 1 60); do + curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break + sleep 2 +done + +echo "==> Bootstrapping the rehearsal owner" +( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \ + node bootstrap-owner.js p5c-rehearsal-owner@rootnode.test "P5C Rehearsal Owner" 'RehearsalPass!2026' ) + +echo "==> Running the rehearsal" +OWNER_EMAIL=p5c-rehearsal-owner@rootnode.test OWNER_PASSWORD='RehearsalPass!2026' \ + INTEGRATION_KEY="$INTEGRATION_SHARED_SECRET" \ + node ../infra/scripts/drills/test-phase5c-enrollment-activation.js diff --git a/infra/scripts/drills/test-phase5c-enrollment-activation.js b/infra/scripts/drills/test-phase5c-enrollment-activation.js new file mode 100755 index 0000000..dc4f6ba --- /dev/null +++ b/infra/scripts/drills/test-phase5c-enrollment-activation.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// Phase 5C §25 — the enrollment/activation dress rehearsal. One continuous realistic story: a +// simulated website enrollment event creates a brand-new student, the activation notification +// is queued and delivered through the (deterministic, never-real-network) dev email provider, +// the rendered email is inspected and its activation URL extracted exactly the way a real +// inbox/click would work, the student sets their password, logs in, sees their program, uses +// Continue Learning to launch a REAL Lab, completes one real scenario, and an admin can see +// both the enrollment and its delivery state. Then a second enrollment for the SAME +// (now-activated) student proves it gets an enrollment confirmation, not another activation. +// Then a full password-reset round trip, including session revocation. Then the minor/no-email +// contact-email path. No framework, matching project convention (see +// test-phase5b-selfpaced-rehearsal.js). +// +// Run via run-phase5c-enrollment-activation-drill.sh, which brings up +// platform/docker-compose.dev.yml first. + +const assert = require('assert'); +const { makeSession } = require('../../../platform/test/session-helper'); + +const GW = process.env.GATEWAY_URL || 'http://localhost:4200'; +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'; +if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); } +const RUN_ID = Date.now(); +const PROGRAM = 'root-kubernetes-foundations'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); console.log(` ok — ${name}`); passed++; } + catch (e) { console.error(` FAIL — ${name}\n ${e.stack || 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 waitForSent(owner, studentId, notificationId, timeoutMs = 10000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const { body } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); + const row = notificationId ? body.find((n) => n.notification_id === notificationId) : body[0]; + if (row?.status === 'sent') return row; + if (row?.status === 'failed') throw new Error(`notification failed: ${row.last_error_code}`); + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error('timed out waiting for delivery'); +} + +async function main() { + console.log('Phase 5C enrollment/activation dress rehearsal — slow, real k3s boots\n'); + + const owner = makeSession(GW); + await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); + + let studentId, enrollmentId; + await step('website enrollment event creates a brand-new student + self-paced enrollment', async () => { + const { status, body } = await fireIntegrationEvent({ + event_id: `p5c-rehearsal-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid', + student: { email: `p5c-rehearsal-${RUN_ID}@example.com`, display_name: 'Rehearsal Student' }, + }); + assert.equal(status, 201); + studentId = body.student_id; enrollmentId = body.enrollment_id; + }); + + let activationUrl; + await step('an activation notification is queued and delivered through the dev provider', async () => { + const sent = await waitForSent(owner, studentId, null); + assert.equal(sent.notification_type, 'activation'); + const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`); + assert.equal(preview.to, `p5c-rehearsal-${RUN_ID}@example.com`); + assert.ok(preview.subject.includes('Activate')); + const match = preview.text.match(/(http:\/\/\S+\/\?activate=[A-Za-z0-9_-]+)/); + assert.ok(match, 'expected a real activation URL in the rendered email body'); + activationUrl = match[1]; + }); + + const student = makeSession(GW); + await step('student "clicks" the extracted URL and sets a password', async () => { + const token = new URL(activationUrl).searchParams.get('activate'); + const { status } = await student.request('POST', '/api/student/activate', { token, password: 'RehearsalPass123!' }); + assert.equal(status, 200); + }); + + let launchEnrollmentId, envId; + await step('student sees the Root program and Continue Learning works', async () => { + const { body: programs } = await student.request('GET', '/api/student/programs'); + assert.equal(programs.length, 1); + assert.equal(programs[0].can_launch, true); + launchEnrollmentId = programs[0].enrollment_id; + assert.equal(launchEnrollmentId, enrollmentId); + }); + + await step('launches a REAL Lab and completes one real scenario', async () => { + const { status, body } = await student.request('POST', `/api/student/programs/${launchEnrollmentId}/launch`); + assert.equal(status, 200); + assert.equal(body.status, 'running'); + const envRow = (await owner.request('GET', `/api/admin/students/${studentId}/environment`)).body.find((e) => e.status === 'running'); + envId = envRow.environment_id; + const complete = await student.request('POST', '/api/progress/record', { scenario_id: 'pod-basics-mcq', status: 'completed', environment_id: envId }); + assert.equal(complete.status, 200); + }); + + await step('progress persists and the admin sees both the enrollment and its delivery state', async () => { + const { body } = await student.request('GET', `/api/student/programs/${launchEnrollmentId}`); + assert.ok(body.progress.percent_complete > 0); + const { body: adminEnrollments } = await owner.request('GET', `/api/admin/students/${studentId}/enrollments`); + assert.equal(adminEnrollments[0].status, 'active'); + const { body: adminNotifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); + assert.ok(adminNotifs.every((n) => n.status === 'sent')); + }); + + // ── A second enrollment for the SAME, now-activated student -> confirmation, not activation ── + await step('admin creates a second program and enrolls the SAME student — enrollment confirmation, not another activation', async () => { + await owner.request('POST', '/api/admin/programs', { program_id: `p5c-rehearsal-second-${RUN_ID}`, name: 'Second Rehearsal Program', family: 'root' }); + const { status, body } = await owner.request('POST', '/api/admin/enrollments', { student_id: studentId, program_id: `p5c-rehearsal-second-${RUN_ID}`, status: 'active' }); + assert.equal(status, 201); + const sent = await waitForSent(owner, studentId, null); + const { body: notifs } = await owner.request('GET', `/api/admin/students/${studentId}/notifications`); + const confirmation = notifs.find((n) => n.enrollment_id === body.enrollment_id); + assert.equal(confirmation.notification_type, 'enrollment_confirmation'); + }); + + // ── Password reset round trip ───────────────────────────────────────────────────────────── + let resetToken; + await step('password reset is requested, delivered, and the old session is revoked once used', async () => { + const res = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5c-rehearsal-${RUN_ID}@example.com` }); + resetToken = res.body.dev_reset_token; + assert.ok(resetToken); + await waitForSent(owner, studentId, null); + const before = await student.request('GET', '/api/student/profile'); + assert.equal(before.status, 200); + const resetRes = await makeSession(GW).request('POST', '/api/student/reset-password', { token: resetToken, password: 'RehearsalReset123!' }); + assert.equal(resetRes.status, 200); + const after = await student.request('GET', '/api/student/profile'); + assert.notEqual(after.status, 200); // the OLD session is revoked + }); + + await step('the new password works', async () => { + const s = makeSession(GW); + const { status } = await s.request('POST', '/api/student/login', { identifier: `p5c-rehearsal-${RUN_ID}@example.com`, password: 'RehearsalReset123!' }); + assert.equal(status, 200); + }); + + // ── Minor / no-email path ──────────────────────────────────────────────────────────────── + await step('minor/no-email student: guardian contact_email receives the activation, never stored as students.email', async () => { + const { status, body } = await fireIntegrationEvent({ + event_id: `p5c-rehearsal-minor-${RUN_ID}`, source: 'website', program_slug: PROGRAM, status: 'paid', + student: { login_identifier: `p5c-rehearsal-minor-${RUN_ID}`, display_name: 'Rehearsal Minor', contact_email: `p5c-rehearsal-guardian-${RUN_ID}@example.com` }, + }); + assert.equal(status, 201); + const sent = await waitForSent(owner, body.student_id, null); + const { body: preview } = await owner.request('GET', `/api/admin/notifications/${sent.notification_id}/dev-preview`); + assert.equal(preview.to, `p5c-rehearsal-guardian-${RUN_ID}@example.com`); + const { body: students } = await owner.request('GET', '/api/admin/students'); + const minorRow = students.find((s) => s.student_id === body.student_id); + assert.equal(minorRow.email, null); + }); + + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/infra/scripts/gws-email-test.js b/infra/scripts/gws-email-test.js new file mode 100755 index 0000000..f50f09c --- /dev/null +++ b/infra/scripts/gws-email-test.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Phase 5C §24 — the OPTIONAL, opt-in Google Workspace live-delivery test. Does NOTHING unless +// explicitly enabled — no test/regression run ever sends real email by accident. +// +// Usage (only when you actually have real Workspace credentials configured): +// RUN_GWS_EMAIL_TEST=true \ +// TEST_EMAIL_RECIPIENT=you@example.com \ +// GOOGLE_SERVICE_ACCOUNT_JSON='{"client_email":"...","private_key":"..."}' \ +// EMAIL_FROM_ADDRESS=noreply@rootnodeacademy.com \ +// EMAIL_FROM_NAME="RootNode Academy" \ +// node infra/scripts/gws-email-test.js +// +// Sends exactly ONE clearly-marked test message to TEST_EMAIL_RECIPIENT — never a +// production/student address, never inferred from anything in the database. Run it once after +// setting up domain-wide delegation to confirm the adapter actually works against the real +// Gmail API, not as part of any regular regression pass (see docs/RUNBOOK_GOOGLE_WORKSPACE_EMAIL.md). + +if (process.env.RUN_GWS_EMAIL_TEST !== 'true') { + console.log('Skipped: set RUN_GWS_EMAIL_TEST=true to actually send a live test email. Nothing was sent.'); + process.exit(0); +} + +const recipient = process.env.TEST_EMAIL_RECIPIENT; +if (!recipient) { + console.error('Set TEST_EMAIL_RECIPIENT to the ONE address this test should send to — it is never inferred from anything else.'); + process.exit(1); +} + +const provider = require('../../platform/api/src/email/gws-provider'); + +async function main() { + console.log(`Sending one live test message via the Gmail API adapter to ${recipient}...`); + const result = await provider.send({ + to: recipient, + fromName: process.env.EMAIL_FROM_NAME || 'RootNode Academy', + fromAddress: process.env.EMAIL_FROM_ADDRESS, + replyTo: process.env.EMAIL_REPLY_TO, + subject: '[RootNode Academy] Google Workspace adapter test', + text: `This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation). If you received this, live delivery is working. Sent at ${new Date().toISOString()}.`, + html: `
This is a one-off test of RootNode Academy's Google Workspace email adapter (Gmail API, service account domain-wide delegation).
If you received this, live delivery is working. Sent at ${new Date().toISOString()}.
`, + }); + console.log('Provider accepted the message.'); + console.log('Provider message ID:', result.providerMessageId); + console.log('\nGWS LIVE DELIVERY TEST: PASSED'); +} + +main().catch((e) => { + console.error('GWS LIVE DELIVERY TEST: FAILED'); + console.error(` ${e.errorCode || 'error'}: ${e.message}`); + process.exit(1); +}); diff --git a/platform/test/e2e-phase5c.js b/platform/test/e2e-phase5c.js new file mode 100644 index 0000000..2efb443 --- /dev/null +++ b/platform/test/e2e-phase5c.js @@ -0,0 +1,413 @@ +#!/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 = '