#!/usr/bin/env node // Phase 9 — Commerce Integration acceptance test. Price configuration, the public price // serializer, checkout eligibility (agreements/registration-window/price), server-authoritative // amount, verified webhook processing (signature, idempotency, success/failure/refund), // registration confirmation -> enrollment conversion -> activation, guardian recipient routing, // capacity/seat-reservation behavior, abandoned-reservation release, admin payment visibility, // RBAC, audit/metric hygiene, and confirmation ROOT_ V1/Phase 8 registration/classroom flows // are all unaffected. Uses the 'test' payment provider throughout — no real Stripe credentials, // no real money, deterministic. // // 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-phase9.js const assert = require('assert'); const { execSync } = require('child_process'); const { makeSession } = require('./session-helper'); const { getPaymentProvider } = require('../api/src/payment-provider'); const commerce = require('../api/src/commerce'); const { Pool } = require('../api/node_modules/pg'); 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_SHARED_SECRET || 'dev-integration-secret-not-for-production'; 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'; const pool = new Pool({ connectionString: process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform' }); 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.stack || e.message}`); failed++; } } function psql(sql) { return execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -t -c "${sql}"`, { cwd: `${__dirname}/..`, stdio: 'pipe' }).toString().trim(); } async function integrationPost(path, body) { const r = await fetch(`${GW}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RootNode-Integration-Key': INTEGRATION_KEY }, body: JSON.stringify(body) }); return { status: r.status, body: await r.json().catch(() => ({})) }; } async function integrationGet(path) { const r = await fetch(`${GW}${path}`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } }); return { status: r.status, body: await r.json().catch(() => ({})) }; } async function webhookPost(body, signature) { const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Test-Payment-Signature': signature }, body }); return { status: r.status, body: await r.json().catch(() => ({})) }; } async function setupPaidOffering(owner, { offeringId, slug, capacity, requireAgreement = true }) { await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `Test Offering ${offeringId}`, programId: PROGRAM, deliveryMode: 'camp', capacity, priceAmount: 15000, priceCurrency: 'usd' }); if (requireAgreement) { const agreementId = `agree-${offeringId}`; await owner.request('POST', '/api/admin/agreements', { agreementId, title: `Agreement for ${offeringId}` }).catch(() => {}); await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId }); } await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' }); } async function main() { console.log('Phase 9 Commerce Integration acceptance test\n'); const owner = makeSession(GW); { const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200); } const provider = getPaymentProvider(); assert.equal(provider.name, 'test', 'this suite must run against the deterministic test provider, never real Stripe'); // ── 1/2: price configuration, public price serializer ──────────────────────────────────── const offeringId = `off-p9-${RUN_ID}`, slug = `p9-camp-${RUN_ID}`; await test('an offering price is configured and reflected in the public serializer, never leaking internal fields', async () => { await setupPaidOffering(owner, { offeringId, slug, capacity: 3 }); const pub = await fetch(`${GW}/api/public/offerings/${slug}`).then((r) => r.json()); assert.equal(pub.price_amount, 15000); assert.equal(pub.price_currency, 'usd'); assert.equal(pub.requires_payment, true); for (const key of ['transaction_id', 'provider_reference', 'reserved_seats', 'organization_id']) assert.ok(!(key in pub), `must not leak ${key}`); }); // ── 3/4: checkout eligibility, missing agreements rejected ─────────────────────────────── let registrationId; await test('checkout is rejected until required agreements are accepted', async () => { const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Test Adult', email: `testadult-${RUN_ID}@example.com` } }); assert.equal(reg.status, 201); registrationId = reg.body.registration_id; const blocked = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); assert.equal(blocked.status, 409); assert.equal(blocked.body.error, 'AGREEMENTS_INCOMPLETE'); }); // ── 5: closed registration rejected ─────────────────────────────────────────────────────── await test('registration is rejected on a draft/closed offering', async () => { const draftOffering = `off-p9-draft-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: draftOffering, slug: `p9-draft-${RUN_ID}`, name: 'Draft', programId: PROGRAM, deliveryMode: 'workshop' }); const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-draft-evt-${RUN_ID}`, offering_slug: `p9-draft-${RUN_ID}`, registrant: { display_name: 'Nope', email: `nope-${RUN_ID}@example.com` } }); assert.equal(reg.status, 409); assert.equal(reg.body.error, 'OFFERING_NOT_OPEN'); }); // ── 6: full offering rejected ───────────────────────────────────────────────────────────── await test('a full offering rejects further registrations', async () => { const fullOffering = `off-p9-full-${RUN_ID}`, fullSlug = `p9-full-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: fullOffering, slug: fullSlug, capacity: 1, requireAgreement: false }); const r1 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-1-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'A', email: `fa-${RUN_ID}@example.com` } }); assert.equal(r1.status, 201); const r2 = await integrationPost('/api/integrations/registrations', { event_id: `p9-full-2-${RUN_ID}`, offering_slug: fullSlug, registrant: { display_name: 'B', email: `fb-${RUN_ID}@example.com` } }); assert.equal(r2.status, 409); assert.equal(r2.body.error, 'OFFERING_FULL'); }); // ── 7/8: server-authoritative amount, checkout creation ────────────────────────────────── let transactionId, providerReference; await test('accepting the agreement unblocks checkout, and the amount always comes from the offering, never the caller', async () => { const agreementId = `agree-${offeringId}`; const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId }); assert.equal(accept.status, 201); const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c', amount: 1 }); assert.equal(checkout.status, 201, JSON.stringify(checkout.body)); transactionId = checkout.body.transaction_id; const tx = psql(`SELECT amount FROM payment_transactions WHERE transaction_id = '${transactionId}'`); assert.equal(Number(tx), 15000, 'the transaction amount must come from the offering, ignoring any amount the caller sent'); const txRow = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); providerReference = txRow.body[0].provider_reference; }); // ── 9/10/11: webhook signature, invalid rejection, idempotency ─────────────────────────── await test('webhook signature is required, invalid signatures are rejected, and valid events are idempotent', async () => { const event = provider.buildTestEvent({ providerReference, eventType: 'checkout.session.completed' }); const bad = await webhookPost(event.body, 'wrong-signature'); assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid_signature'); const first = await webhookPost(event.body, event.signature); assert.equal(first.status, 200); assert.equal(first.body.result, 'processed'); const replay = await webhookPost(event.body, event.signature); assert.equal(replay.status, 200); assert.equal(replay.body.result, 'duplicate'); const count = psql(`SELECT count(*) FROM payment_transactions WHERE transaction_id = '${transactionId}' AND status = 'paid'`); assert.equal(Number(count), 1, 'a duplicate webhook must never double-process a payment'); }); // ── 12/14/15/16: payment success, registration confirmation, conversion, activation ────── let convertedStudentId; await test('payment success confirms the registration, converts it to a real enrollment, and queues activation', async () => { const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`); assert.equal(reg.body.status, 'enrollment_activated'); convertedStudentId = reg.body.student_id; assert.ok(convertedStudentId); const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${reg.body.enrollment_id}'`); assert.equal(enrollmentStatus, 'active'); const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`); const types = notifs.body.map((n) => n.notification_type); assert.ok(types.includes('registration_received') && types.includes('registration_confirmed') && types.includes('activation'), `expected all three, got ${types}`); // no duplicate registration_confirmed assert.equal(types.filter((t) => t === 'registration_confirmed').length, 1); }); // ── 13: payment failure ──────────────────────────────────────────────────────────────────── await test('a failed payment does not release the seat and leaves the registration retriable', async () => { const failOffering = `off-p9-failtest-${RUN_ID}`, failSlug = `p9-failtest-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: failOffering, slug: failSlug, capacity: 2, requireAgreement: false }); const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-failreg-${RUN_ID}`, offering_slug: failSlug, registrant: { display_name: 'Fail', email: `fail-${RUN_ID}@example.com` } }); const checkout = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`); const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'payment_intent.payment_failed' }); const result = await webhookPost(event.body, event.signature); assert.equal(result.body.result, 'processed'); const offAfter = await owner.request('GET', `/api/admin/offerings/${failOffering}`); assert.equal(offAfter.body.reserved_seats, 1, 'a failed payment must not release the seat'); const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`); assert.equal(regAfter.body.status, 'started', 'a failed payment leaves the registration open to retry, not cancelled'); }); // Phase 9.1 real-Stripe-TEST regression — a real Stripe payment_intent.payment_failed/ // charge.refunded event references the PaymentIntent/Charge, not the Checkout Session, so // StripeProvider.normalizeEvent() resolves those back to OUR OWN transaction_id (never the // stored provider_reference) via payment_intent_data.metadata. This deterministic test // simulates exactly that shape — a webhook event whose providerReference is the transaction's // own UUID, not its provider_reference — proving commerce.js's lookup matches on either // column. Found and fixed only by testing against real Stripe (the deterministic // provider previously always reused one caller-supplied reference, which never exposed this). await test('a webhook event referencing OUR OWN transaction_id (not provider_reference) — the real Stripe payment_intent/charge shape — is still matched', async () => { const idOffering = `off-p9-txidref-${RUN_ID}`, idSlug = `p9-txidref-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: idOffering, slug: idSlug, capacity: 2, requireAgreement: false }); const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-txidref-evt-${RUN_ID}`, offering_slug: idSlug, registrant: { display_name: 'TxIdRef', email: `txidref-${RUN_ID}@example.com` } }); await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); const txRow = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}/payments`); const transactionId = txRow.body[0].transaction_id; assert.notEqual(transactionId, txRow.body[0].provider_reference, 'sanity check — these must be genuinely different values for this test to mean anything'); const event = provider.buildTestEvent({ providerReference: transactionId, eventType: 'payment_intent.payment_failed' }); const result = await webhookPost(event.body, event.signature); assert.equal(result.body.result, 'processed', 'the transaction must be found via transaction_id, not just provider_reference'); const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`); assert.equal(regAfter.body.status, 'started', 'a failed payment (matched via transaction_id) still leaves the registration retriable, not cancelled'); }); // ── 19/21: capacity handling, last-seat race ───────────────────────────────────────────── await test('a last-seat race between two concurrent registration attempts yields exactly one winner', async () => { const raceOffering = `off-p9-race-${RUN_ID}`, raceSlug = `p9-race-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: raceOffering, slug: raceSlug, capacity: 1, requireAgreement: false }); const [a, b] = await Promise.all([ integrationPost('/api/integrations/registrations', { event_id: `p9-race-a-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceA', email: `ra-${RUN_ID}@example.com` } }), integrationPost('/api/integrations/registrations', { event_id: `p9-race-b-${RUN_ID}`, offering_slug: raceSlug, registrant: { display_name: 'RaceB', email: `rb-${RUN_ID}@example.com` } }), ]); const statuses = [a.status, b.status].sort(); assert.deepEqual(statuses, [201, 409]); const off = await owner.request('GET', `/api/admin/offerings/${raceOffering}`); assert.equal(off.body.reserved_seats, 1); }); // ── 20/21: abandoned reservation release ───────────────────────────────────────────────── await test('an expired, unpaid reservation is released by the reaper and its registration cancelled', async () => { const reapOffering = `off-p9-reap-${RUN_ID}`, reapSlug = `p9-reap-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: reapOffering, slug: reapSlug, capacity: 2, requireAgreement: false }); const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-reap-evt-${RUN_ID}`, offering_slug: reapSlug, registrant: { display_name: 'Reap', email: `reap-${RUN_ID}@example.com` } }); psql(`UPDATE offering_registrations SET reservation_expires_at = now() - interval '1 minute' WHERE registration_id = '${reg.body.registration_id}'`); const result = await commerce.reapExpiredReservations(pool); assert.ok(result.released >= 1); const offAfter = await owner.request('GET', `/api/admin/offerings/${reapOffering}`); assert.equal(offAfter.body.reserved_seats, 0); const regAfter = await owner.request('GET', `/api/admin/registrations/${reg.body.registration_id}`); assert.equal(regAfter.body.status, 'cancelled'); }); // ── 22: refund recording ────────────────────────────────────────────────────────────────── await test('a manual refund is recorded without cascading into registration/enrollment cancellation', async () => { const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); const paid = tx.body.find((t) => t.status === 'paid'); const refund = await owner.request('POST', `/api/admin/payments/${paid.transaction_id}/refund`, { status: 'refunded' }); assert.equal(refund.status, 200); assert.equal(refund.body.status, 'refunded'); const regAfter = await owner.request('GET', `/api/admin/registrations/${registrationId}`); assert.equal(regAfter.body.status, 'enrollment_activated', 'a refund must never auto-cancel the registration'); const enrollmentStatus = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${regAfter.body.enrollment_id}'`); assert.equal(enrollmentStatus, 'active', 'a refund must never auto-cancel the academic enrollment'); }); // ── 17: guardian recipient routing ──────────────────────────────────────────────────────── await test('a minor payment confirmation routes to the guardian, never overwriting students.email', async () => { const minorOffering = `off-p9-minor-${RUN_ID}`, minorSlug = `p9-minor-${RUN_ID}`; await setupPaidOffering(owner, { offeringId: minorOffering, slug: minorSlug, capacity: 2, requireAgreement: false }); const submit = await integrationPost('/api/integrations/registrations', { event_id: `p9-minor-evt-${RUN_ID}`, offering_slug: minorSlug, registrant: { display_name: 'Minor Camper', is_minor: true }, guardian: { display_name: 'Minor Guardian', email: `minorguardian-${RUN_ID}@example.com` }, }); assert.equal(submit.status, 201); const checkout = await integrationPost(`/api/integrations/registrations/${submit.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); const txRow = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/payments`); const event = provider.buildTestEvent({ providerReference: txRow.body[0].provider_reference, eventType: 'checkout.session.completed' }); await webhookPost(event.body, event.signature); const regAfter = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}`); const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${regAfter.body.student_id}'`); assert.equal(studentEmail, '', 'a minor student must never get the guardian email written to students.email'); const notifs = await owner.request('GET', `/api/admin/registrations/${submit.body.registration_id}/notifications`); assert.ok(notifs.body.some((n) => n.notification_type === 'activation')); }); // ── 18: admin payment view ──────────────────────────────────────────────────────────────── await test('admin payment view shows amount/status/provider but never card data (none is ever stored)', async () => { const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); assert.ok(tx.body.length > 0); for (const t of tx.body) { for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t)); } }); // ── 24/25: instructor payment-data restrictions, student payment isolation ────────────── await test('a plain instructor cannot see registration payment data; the public API never can either', async () => { const { body: strayCreate } = await owner.request('POST', '/api/admin/instructors', { email: `p9-stray-${RUN_ID}@example.com`, display_name: 'P9 Stray', role: 'instructor', password: 'P9StrayPass123!' }); const stray = makeSession(GW); await stray.request('POST', '/api/auth/instructor/login', { email: `p9-stray-${RUN_ID}@example.com`, password: 'P9StrayPass123!' }); const denied = await stray.request('GET', `/api/admin/registrations/${registrationId}/payments`); assert.equal(denied.status, 403); }); // ── 26/27: audit hygiene, metric hygiene ───────────────────────────────────────────────── await test('commerce audit events never contain secrets/card data, and metrics stay low-cardinality', async () => { const auditRow = psql(`SELECT metadata::text FROM audit_events WHERE action = 'checkout_created' ORDER BY created_at DESC LIMIT 1`); assert.ok(!/sk_|whsec_|4242/.test(auditRow || ''), 'no Stripe secret or fake card number should ever appear in audit metadata'); const metricsBody = await fetch(`${GW}/api/metrics`).catch(() => null); // Metrics may be unauthenticated-admin or absent in some deployments — only assert content // shape when reachable, never fail the whole suite on an unrelated auth requirement. if (metricsBody && metricsBody.ok) { const text = await metricsBody.text(); assert.ok(!new RegExp(registrationId).test(text), 'a registration_id must never appear as a metric label'); } }); await test('the website status-lookup endpoint is safe and minimal — no PII, no amount, no provider details', async () => { const status = await integrationGet(`/api/integrations/registrations/${registrationId}/status`); assert.equal(status.status, 200); assert.deepEqual(Object.keys(status.body).sort(), ['enrollment_activated', 'payment_status', 'registration_id', 'status']); const denied = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`); assert.equal(denied.status, 401, 'no key must be rejected, same as every other integration route'); }); // ── 28/29/30: ROOT V1, Phase 8 registration, classroom/Lab flow all remain intact ─────── await test('an existing ROOT_ V1 self-paced enrollment is completely unaffected by Phase 9', async () => { const { body: create } = await owner.request('POST', '/api/admin/students', { display_name: `P9-RootV1-${RUN_ID}`, login_identifier: `p9-rootv1-${RUN_ID}` }); const s = makeSession(GW); await s.request('POST', '/api/student/activate', { token: create.activation_token, password: 'RootV1Pass123!' }); const { body: enroll } = await owner.request('POST', '/api/admin/enrollments', { student_id: create.student_id, program_id: PROGRAM, status: 'active' }); const r = await s.request('POST', `/api/student/activities/root-01-m00-a01-lesson-welcome/complete`); assert.equal(r.status, 200); }); await test('a free (Phase 8-style) offering registration still works with no price/checkout involved', async () => { const freeOffering = `off-p9-free-${RUN_ID}`, freeSlug = `p9-free-${RUN_ID}`; await owner.request('POST', '/api/admin/offerings', { offeringId: freeOffering, slug: freeSlug, name: 'Free Offering', programId: PROGRAM, deliveryMode: 'self_paced' }); await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'published' }); await owner.request('POST', `/api/admin/offerings/${freeOffering}/transition`, { status: 'enrollment_open' }); const reg = await integrationPost('/api/integrations/registrations', { event_id: `p9-free-evt-${RUN_ID}`, offering_slug: freeSlug, registrant: { display_name: 'Free Student', email: `free-${RUN_ID}@example.com` } }); assert.equal(reg.status, 201); const checkoutAttempt = await integrationPost(`/api/integrations/registrations/${reg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); assert.equal(checkoutAttempt.status, 409); assert.equal(checkoutAttempt.body.error, 'NO_PRICE_CONFIGURED'); }); await test('an existing classroom (cohort) flow still works unaffected', async () => { const cohortId = `p9-cohort-check-${RUN_ID}`; const create = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: 'P9 Cohort Check', tier: 'root' }); assert.equal(create.status, 201); const session = await owner.request('POST', `/api/admin/cohorts/${cohortId}/classroom-sessions`, { title: 'P9 Check Session' }); assert.equal(session.status, 201); }); await pool.end(); console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0); } main().catch(async (e) => { console.error(e); await pool.end().catch(() => {}); process.exit(1); });