- platform/test/e2e-phase5c.js (29 checks): activation-vs-confirmation routing, duplicate/concurrent webhook idempotency, temporary/permanent/exhausted retry classification, resend/regenerate token invalidation, activation/reset single-use and expiration, HTML-injection regression, production token-secrecy gating via a real container recreate with COOKIE_SECURE=1, minor/contact-email resolution, RBAC scoping, metrics/audit hygiene, and a real Platform API restart. - infra/scripts/drills/test-phase5c-enrollment-activation.js (10 checks): one continuous story — website enrollment through a real rendered/extracted activation email, a real Lab launch and scenario completion, a second enrollment's confirmation email, a full password-reset round trip, and the minor/no-email contact-email path. - infra/scripts/gws-email-test.js: the opt-in (RUN_GWS_EMAIL_TEST=true) live Google Workspace delivery test — does nothing without the explicit flag, sends exactly one message to one operator-configured recipient when enabled.
166 lines
9.7 KiB
JavaScript
Executable File
166 lines
9.7 KiB
JavaScript
Executable File
#!/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); });
|