#!/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); });