#!/usr/bin/env node // Phase 9.1 — the permanent, OPT-IN real-Stripe-TEST-mode integration check Phase 9 itself did // not build (Phase 9 tested exclusively against the deterministic TestProvider). This script // makes NO Stripe network call at all unless explicitly enabled, so normal regression runs and // CI stay fully deterministic and independent of Stripe/credential availability. // // Enable with: RUN_STRIPE_INTEGRATION_TEST=true // Requires (same env var names payment-provider.js already uses — never a duplicate config // var): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Both must already be configured on the // RUNNING API process too (PAYMENT_PROVIDER=stripe) — this script is an external verifier, it // does not restart or reconfigure the server. // // Refuses to run against a live-mode secret key (sk_live_...) — TEST MODE ONLY, always. Creates // one disposable, clearly-named TEST offering and TEST registration; attempts to cancel the // offering when done (best-effort cleanup, never fatal if it fails). // // What this script CAN prove by itself, purely via API calls (no browser needed): // - RootNode really creates a real Stripe TEST Checkout Session (verified by retrieving the // session directly from Stripe's own API, not just trusting RootNode's response). // - The amount Stripe actually recorded matches the offering's server-side price exactly, // even when the caller's request body carries a different, tampered amount — proving // server-authoritative pricing against Stripe's OWN object, not just RootNode's database. // - RootNode's real webhook route rejects a garbage/invalid signature cleanly, with no domain // side effect — this does not require an actual completed payment to test honestly. // // What this script deliberately does NOT fake: actually completing Stripe's hosted Checkout // page (filling in a test card) is a real human/browser action — there is no supported pure-API // way to "pay" a Checkout Session, and fabricating a signed "payment succeeded" event for a // session nobody actually paid would be exactly the kind of faked validation Phase 9.1 was told // never to produce. When enabled, this script prints the real checkout_url and polls RootNode's // own status endpoint for STRIPE_INTEGRATION_TEST_WAIT_SECONDS (default 90) while a human // completes it in a browser (test card 4242 4242 4242 4242) with a parallel // `stripe listen --forward-to /api/webhooks/payment` relaying Stripe's real event. // // Once that real payment lands, everything else IS fully automated via real Stripe API calls: // payment-transaction/enrollment/cohort/guardian reconciliation, a genuine duplicate-delivery // proof (re-signing the REAL event Stripe recorded, fetched fresh via stripe.events.retrieve — // never a fabricated one), real student activation + program-visibility proof, a real Stripe // TEST refund with the non-cascade rule reconfirmed, and — needing no browser at all — a real // abandoned/expired-checkout proof via Stripe's own `sessions.expire()` API. Configurable via // STRIPE_TEST_PRICE_CENTS/STRIPE_TEST_CURRENCY (defaults to a cheap $1 generic check) and // STRIPE_TEST_MINOR=true (guardian+minor path instead of adult). See // docs/STRIPE_INTEGRATION_RUNBOOK.md and docs/PHASE9_1_STRIPE_TEST_VALIDATION.md. const assert = require('assert'); const RUN = process.env.RUN_STRIPE_INTEGRATION_TEST === 'true'; if (!RUN) { console.log('Stripe integration test skipped (RUN_STRIPE_INTEGRATION_TEST is not "true") — no Stripe network traffic, no credentials required.'); process.exit(0); } 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'; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const WAIT_SECONDS = Number(process.env.STRIPE_INTEGRATION_TEST_WAIT_SECONDS) || 90; if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one with platform/api/bootstrap-owner.js).'); process.exit(1); } if (!STRIPE_SECRET_KEY) { console.error('RUN_STRIPE_INTEGRATION_TEST=true requires STRIPE_SECRET_KEY (the same variable payment-provider.js reads) so this script can independently verify what Stripe actually recorded.'); process.exit(1); } if (STRIPE_SECRET_KEY.startsWith('sk_live_')) { console.error('REFUSING TO RUN: STRIPE_SECRET_KEY looks like a LIVE-mode key (sk_live_...). This script is TEST MODE ONLY. Use an sk_test_ key.'); process.exit(1); } if (!STRIPE_SECRET_KEY.startsWith('sk_test_')) { console.error('REFUSING TO RUN: STRIPE_SECRET_KEY does not look like a Stripe test key (expected sk_test_...). Refusing out of caution.'); process.exit(1); } const Stripe = require('../api/node_modules/stripe'); const stripe = new Stripe(STRIPE_SECRET_KEY); const { makeSession } = require('./session-helper'); const RUN_ID = Date.now(); const PROGRAM = 'root-kubernetes-foundations'; // Phase 9.1 resume — overridable so a real business price (e.g. the $399/39900 Founding Cohort // tuition) can be validated directly against Stripe's own recorded amount, instead of always // testing an arbitrary $1. Defaults stay cheap/generic for a routine opt-in regression run. const TEST_PRICE_CENTS = Number(process.env.STRIPE_TEST_PRICE_CENTS) || 100; const TEST_CURRENCY = process.env.STRIPE_TEST_CURRENCY || 'usd'; const TEST_MINOR = process.env.STRIPE_TEST_MINOR === 'true'; // guardian+minor path vs. adult const TAMPERED_AMOUNT_CENTS = 1; // what a malicious caller will try to substitute 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++; } } 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(() => ({})) }; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function psql(sql) { const { execSync } = require('child_process'); 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 main() { console.log('Phase 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\n'); console.log(`Price under test: ${TEST_PRICE_CENTS} ${TEST_CURRENCY} (${(TEST_PRICE_CENTS / 100).toFixed(2)}) — ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration path.\n`); const owner = makeSession(GW); const offeringId = `stripe-it-${RUN_ID}`, slug = `stripe-integration-test-${RUN_ID}`; const agreementId = `stripe-it-agreement-${RUN_ID}`; let registrationId, sessionId, checkoutUrl, cohortId; await test('owner logs in', async () => { const r = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD }); assert.equal(r.status, 200, JSON.stringify(r.body)); }); await test('a disposable TEST offering is created with the real TEST price and a cohort', async () => { cohortId = `stripe-it-cohort-${RUN_ID}`; const cohort = await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortId, name: `[TEST] Stripe Integration Cohort ${RUN_ID}`, tier: 'root' }); assert.equal(cohort.status, 201, JSON.stringify(cohort.body)); const create = await owner.request('POST', '/api/admin/offerings', { offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID} — NOT the real Winter Break offering`, programId: PROGRAM, deliveryMode: 'cohort', cohortId, priceAmount: TEST_PRICE_CENTS, priceCurrency: TEST_CURRENCY, }); assert.equal(create.status, 201, JSON.stringify(create.body)); const agreement = await owner.request('POST', '/api/admin/agreements', { agreementId, title: '[TEST] Stripe Integration Waiver', bodySummary: 'Disposable test agreement — Phase 9.1.' }); assert.equal(agreement.status, 201, JSON.stringify(agreement.body)); const link = await owner.request('POST', `/api/admin/offerings/${offeringId}/agreement-requirements`, { agreement_id: agreementId }); assert.equal(link.status, 201, JSON.stringify(link.body)); await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'published' }); const open = await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'enrollment_open' }); assert.equal(open.status, 200); }); await test(`a real ${TEST_MINOR ? 'guardian+minor' : 'adult'} registration is created through the public integration contract`, async () => { const body = TEST_MINOR ? { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Stripe IT Test Camper', is_minor: true }, guardian: { display_name: 'Stripe IT Test Guardian', email: `stripe-it-guardian-${RUN_ID}@example.com`, phone: '555-0199' } } : { event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` } }; const reg = await integrationPost('/api/integrations/registrations', body); assert.equal(reg.status, 201, JSON.stringify(reg.body)); registrationId = reg.body.registration_id; }); await test('checkout is rejected before the required agreement is accepted', async () => { const attempt = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); assert.equal(attempt.status, 409); assert.equal(attempt.body.error, 'AGREEMENTS_INCOMPLETE'); }); await test('accepting the agreement is persisted with a version', async () => { const accept = await integrationPost(`/api/integrations/registrations/${registrationId}/agreements`, { agreement_id: agreementId }); assert.equal(accept.status, 201, JSON.stringify(accept.body)); assert.equal(accept.body.accepted_version, 1); }); await test('RootNode creates a REAL Stripe TEST Checkout Session, and a tampered caller-supplied amount is ignored', async () => { // amount/currency here are not real fields on this endpoint's contract (Step 5's own // "the browser never supplies a price" design) — sending them anyway proves they're inert. const checkout = await integrationPost(`/api/integrations/registrations/${registrationId}/checkout`, { success_url: 'https://example.com/success', cancel_url: 'https://example.com/cancel', amount: TAMPERED_AMOUNT_CENTS, currency: 'eur', }); assert.equal(checkout.status, 201, JSON.stringify(checkout.body)); checkoutUrl = checkout.body.checkout_url; assert.ok(checkoutUrl.startsWith('https://checkout.stripe.com/'), `expected a real Stripe-hosted checkout URL, got: ${checkoutUrl}`); // Independently verify against STRIPE'S OWN API — never trust RootNode's own echo of what // it thinks it sent. const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); sessionId = tx.body[0].provider_reference; const session = await stripe.checkout.sessions.retrieve(sessionId); assert.equal(session.mode, 'payment'); assert.equal(session.amount_total, TEST_PRICE_CENTS, 'Stripe must have recorded the OFFERING price, never the tampered caller amount'); assert.equal(session.currency, TEST_CURRENCY, 'Stripe must have recorded the OFFERING currency, never the tampered caller currency'); assert.equal(session.livemode, false, 'this must be a TEST-mode session — refusing to proceed otherwise'); console.log(` Stripe session ${sessionId}: amount_total=${session.amount_total} ${session.currency}, livemode=${session.livemode}`); }); await test('an invalid webhook signature is rejected with no domain side effect', async () => { const before = await owner.request('GET', `/api/admin/registrations/${registrationId}`); const r = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': 'invalid-signature-not-real' }, body: JSON.stringify({ id: 'evt_fake', type: 'checkout.session.completed', data: { object: { id: sessionId } } }), }); assert.equal(r.status, 400); const after = await owner.request('GET', `/api/admin/registrations/${registrationId}`); assert.equal(after.body.status, before.body.status, 'an invalid signature must never move the registration forward'); }); console.log(`\n${'─'.repeat(78)}`); console.log('Everything above is fully automated and required no human/browser step.'); console.log('What remains needs a real completed Stripe Checkout — this is a genuine human or'); console.log('Stripe-CLI action, never simulated by this script (see the file header comment and'); console.log('docs/PHASE9_1_STRIPE_TEST_VALIDATION.md):\n'); console.log(` 1. Open this REAL Stripe TEST checkout page in a browser:\n ${checkoutUrl}`); console.log(' 2. Pay with Stripe\'s official test card: 4242 4242 4242 4242, any future expiry, any CVC/ZIP.'); console.log(` 3. Make sure Stripe can reach your webhook — e.g. \`stripe listen --forward-to ${GW}/api/webhooks/payment\`.`); console.log(`\nPolling RootNode's own status endpoint for up to ${WAIT_SECONDS}s for a real webhook-confirmed result...\n`); const deadline = Date.now() + WAIT_SECONDS * 1000; let finalStatus = null; while (Date.now() < deadline) { const r = await fetch(`${GW}/api/integrations/registrations/${registrationId}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } }); const status = await r.json(); if (status.payment_status === 'paid' || status.status === 'enrollment_activated') { finalStatus = status; break; } await sleep(3000); } let realEventId = null, realPaymentIntentId = null, enrollmentId = null, studentId = null; if (finalStatus) { console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`); passed++; await test('exactly one payment_transactions record, paid, reconciled against Stripe — no card data anywhere', async () => { const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); assert.equal(tx.body.length, 1, `expected exactly one transaction, got ${tx.body.length}`); const t = tx.body[0]; assert.equal(t.status, 'paid'); assert.equal(t.provider_reference, sessionId); for (const forbidden of ['card_number', 'cvv', 'card_last4', 'payment_method_details']) assert.ok(!(forbidden in t)); const session = await stripe.checkout.sessions.retrieve(sessionId); realPaymentIntentId = session.payment_intent; assert.ok(realPaymentIntentId, 'a paid session must have a real PaymentIntent'); realEventId = psql(`SELECT webhook_event_id FROM payment_webhook_events WHERE provider = 'stripe' AND transaction_id = (SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}') ORDER BY received_at DESC LIMIT 1`); assert.ok(realEventId, 'the real Stripe event id must be recorded'); }); await test('registration confirmed, enrollment converted exactly once, cohort membership created, guardian preserved', async () => { const reg = await owner.request('GET', `/api/admin/registrations/${registrationId}`); assert.equal(reg.body.status, 'enrollment_activated'); enrollmentId = reg.body.enrollment_id; studentId = reg.body.student_id; const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`); assert.equal(enrollmentCount.trim(), '1', 'exactly one enrollment must exist for this student/program'); const cohortCount = psql(`SELECT count(*) FROM cohort_enrollments WHERE cohort_id = '${cohortId}' AND student_id = '${studentId}'`); assert.equal(cohortCount.trim(), '1', 'exactly one cohort membership must exist'); if (TEST_MINOR) { const guardianLink = psql(`SELECT count(*) FROM student_guardians WHERE student_id = '${studentId}'`); assert.equal(guardianLink.trim(), '1', 'the minor student must have exactly one real guardian relationship'); const studentEmail = psql(`SELECT email FROM students WHERE student_id = '${studentId}'`); assert.equal(studentEmail.trim(), '', 'the guardian email must never be written to the minor student\'s own identity record'); } }); await test('a REAL, Stripe-issued duplicate delivery of the same event is idempotent — no double conversion', async () => { // Fetches the REAL event Stripe recorded (not fabricated) and re-signs it with the REAL // webhook secret to prove a genuine Stripe retry-delivery scenario is handled correctly — // never inventing a "payment succeeded" event for a session nobody paid (that would be // exactly the faked validation this phase was told never to produce). This mirrors what // `stripe events resend` does server-side; done locally here because a `stripe listen` // ephemeral forwarding session has no persistent dashboard endpoint id to target. const realEvent = await stripe.events.retrieve(realEventId); const payload = JSON.stringify(realEvent); const header = stripe.webhooks.generateTestHeaderString({ payload, secret: process.env.STRIPE_WEBHOOK_SECRET }); const replay = await fetch(`${GW}/api/webhooks/payment`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, body: payload }); const replayBody = await replay.json(); assert.equal(replay.status, 200); assert.equal(replayBody.result, 'duplicate'); const enrollmentCount = psql(`SELECT count(*) FROM enrollments WHERE student_id = '${studentId}' AND program_id = '${PROGRAM}'`); assert.equal(enrollmentCount.trim(), '1', 'a duplicate delivery must never create a second enrollment'); const notifCount = psql(`SELECT count(*) FROM notification_events WHERE enrollment_id = '${enrollmentId}' AND notification_type IN ('activation','enrollment_confirmation')`); assert.equal(notifCount.trim(), '1', 'a duplicate delivery must never queue a second activation/confirmation email'); }); await test('student access: the real activation token works and the enrolled program is visible', async () => { require('child_process').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' }); await sleep(3000); const notifs = await owner.request('GET', `/api/admin/registrations/${registrationId}/notifications`); const activationNotif = notifs.body.find((n) => n.notification_type === 'activation' || n.notification_type === 'enrollment_confirmation'); assert.ok(activationNotif, `expected a real activation/confirmation notification, got: ${JSON.stringify(notifs.body)}`); if (activationNotif.notification_type === 'activation') { const preview = await owner.request('GET', `/api/admin/notifications/${activationNotif.notification_id}/dev-preview`); const token = preview.body.text.match(/\/\?activate=([A-Za-z0-9_-]+)/)[1]; const s = makeSession(GW); const activate = await s.request('POST', '/api/student/activate', { token, password: 'StripeIT!2026Test' }); assert.equal(activate.status, 200, JSON.stringify(activate.body)); const programs = await s.request('GET', '/api/student/programs'); assert.ok(programs.body.some((p) => p.program_id === PROGRAM), 'the enrolled program must be visible to the newly-activated student'); } }); await test('a Stripe TEST refund is recorded without cascading into registration/enrollment status', async () => { const refund = await stripe.refunds.create({ payment_intent: realPaymentIntentId }); assert.equal(refund.status, 'succeeded'); // Give the (not-yet-forwarded) refund webhook a moment if stripe listen also relays // charge.refunded; either way, prove RootNode's OWN non-cascade rule holds regardless — // record it via the admin route directly if the webhook hasn't landed yet. await sleep(2000); const tx = await owner.request('GET', `/api/admin/registrations/${registrationId}/payments`); const t = tx.body[0]; if (t.status !== 'refunded') { const transactionId = psql(`SELECT transaction_id FROM payment_transactions WHERE registration_id = '${registrationId}'`).trim(); await owner.request('POST', `/api/admin/payments/${transactionId}/refund`, {}); } 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 enrollmentAfter = psql(`SELECT status FROM enrollments WHERE enrollment_id = '${enrollmentId}'`); assert.equal(enrollmentAfter.trim(), 'active', 'a refund must never auto-revoke the enrollment — this is a business-policy question, not something to decide in code'); console.log(` Stripe refund ${refund.id}: status=${refund.status}, amount=${refund.amount} ${refund.currency}`); }); console.log(`\n${'─'.repeat(78)}`); console.log('Stripe <-> RootNode reconciliation for this run:'); console.log(` RootNode registration: ${registrationId}`); console.log(` RootNode enrollment: ${enrollmentId}`); console.log(` RootNode student: ${studentId}`); console.log(` Stripe Checkout Session: ${sessionId}`); console.log(` Stripe PaymentIntent: ${realPaymentIntentId}`); console.log(` Stripe event (webhook): ${realEventId}`); console.log(` Amount / currency: ${TEST_PRICE_CENTS} ${TEST_CURRENCY}`); } else { console.log(' (not completed within the wait window — go complete the checkout in a browser and rerun, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)'); } await test('abandoned/expired checkout (pure API, no browser needed): the seat is released exactly once', async () => { const abReg = await integrationPost('/api/integrations/registrations', { event_id: `stripe-it-abandon-${RUN_ID}`, offering_slug: slug, registrant: { display_name: 'Stripe IT Abandon Tester', email: `stripe-it-abandon-${RUN_ID}@example.com` }, }); assert.equal(abReg.status, 201, JSON.stringify(abReg.body)); const abAccept = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/agreements`, { agreement_id: agreementId }); assert.equal(abAccept.status, 201); const abCheckout = await integrationPost(`/api/integrations/registrations/${abReg.body.registration_id}/checkout`, { success_url: 'https://example.com/s', cancel_url: 'https://example.com/c' }); assert.equal(abCheckout.status, 201, JSON.stringify(abCheckout.body)); const abTx = await owner.request('GET', `/api/admin/registrations/${abReg.body.registration_id}/payments`); const abSessionId = abTx.body[0].provider_reference; // A REAL Stripe API call — no browser needed — asking Stripe to expire an open session, // which makes Stripe itself fire a genuine checkout.session.expired webhook. await stripe.checkout.sessions.expire(abSessionId); const deadline2 = Date.now() + 30000; let released = false; while (Date.now() < deadline2) { const r = await fetch(`${GW}/api/integrations/registrations/${abReg.body.registration_id}/status`, { headers: { 'X-RootNode-Integration-Key': INTEGRATION_KEY } }); const s = await r.json(); if (s.status === 'cancelled') { released = true; break; } await sleep(2000); } assert.ok(released, 'the abandoned/expired checkout must release the seat within 30s of the real Stripe webhook'); }); // Best-effort cleanup — never fatal. try { await owner.request('POST', `/api/admin/offerings/${offeringId}/transition`, { status: 'cancelled' }); } catch { /* ponytail: best-effort cleanup only */ } console.log(`\n${passed} passed, ${failed} failed${finalStatus ? '' : ' (real-payment step not completed this run)'}`); process.exit(failed ? 1 : 0); } main().catch((e) => { console.error(e); process.exit(1); });