diff --git a/platform/test/stripe-integration-test.js b/platform/test/stripe-integration-test.js new file mode 100644 index 0000000..8a63df9 --- /dev/null +++ b/platform/test/stripe-integration-test.js @@ -0,0 +1,205 @@ +#!/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 (or a Stripe CLI `stripe +// trigger` against Stripe's own backend) — there is no supported pure-API way to "complete" a +// Checkout Session without one of those, 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 pauses, +// polling 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) or a parallel +// `stripe listen --forward-to /api/webhooks/payment` relays Stripe's real event — 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'; +const TEST_PRICE_CENTS = 100; // $1.00 — small, non-zero, clearly a TEST amount +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)); } + +async function main() { + console.log('Phase 9.1 real-Stripe-TEST-mode integration check (opt-in, real Stripe API calls, TEST MODE ONLY)\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; + + 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 a real, small, non-zero TEST price', async () => { + const create = await owner.request('POST', '/api/admin/offerings', { + offeringId, slug, name: `[TEST] Stripe Integration Check ${RUN_ID}`, + programId: PROGRAM, deliveryMode: 'self_paced', + priceAmount: TEST_PRICE_CENTS, priceCurrency: 'usd', + }); + 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 registration is created through the public integration contract', async () => { + const reg = await integrationPost('/api/integrations/registrations', { + event_id: `stripe-it-reg-${RUN_ID}`, offering_slug: slug, + registrant: { display_name: 'Stripe Integration Tester', email: `stripe-it-${RUN_ID}@example.com` }, + }); + 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, 'usd', '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'); + }); + + 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); + } + + if (finalStatus) { + console.log(` ok — a REAL Stripe TEST payment was completed and confirmed by a REAL signed webhook: ${JSON.stringify(finalStatus)}`); + passed++; + } else { + console.log(' (not completed within the wait window — this is expected in an unattended/CI run; rerun interactively to complete the manual step, or increase STRIPE_INTEGRATION_TEST_WAIT_SECONDS)'); + } + + // 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); });