2 Commits
Author SHA1 Message Date
ced4568 77fd1869db docs(stripe): document Phase 9.1 blocked status and live-mode readiness checklist
New: PHASE9_1_STRIPE_TEST_VALIDATION.md — baseline verification, the exact Stripe-credential
search performed (none found), the Stripe architecture review (dynamic price_data, unchanged),
what was proven vs. blocked per the mission's own step list, and the live-mode readiness
checklist. Updated: STRIPE_INTEGRATION_RUNBOOK.md and WINTER_BREAK_LAUNCH_CHECKLIST.md to point
at the new opt-in test and the blocked status. PHASE9_COMMERCE_AND_LAUNCH.md gets a short
addendum. No application code changed this phase — no genuine defect was exposed without real
Stripe traffic, so no regression rerun was required.
2026-08-27 10:40:53 -05:00
ced4568 f0ff14af38 test(stripe): add opt-in real Stripe TEST-mode integration check
RUN_STRIPE_INTEGRATION_TEST=true (default: no-op, zero Stripe network traffic). Refuses to run
against a live-mode secret key. Automates what's provable via pure API alone — real Checkout
Session creation verified against Stripe's own retrieve API, server-authoritative pricing proven
against Stripe's own recorded amount even under a tampered caller amount, invalid-signature
webhook rejection with no domain side effect — then polls RootNode's status endpoint for a
human/Stripe-CLI-completed real payment rather than fabricating a signed success event. Never
runs as part of normal regression/CI.
2026-08-27 10:40:42 -05:00
5 changed files with 404 additions and 2 deletions
+180
View File
@@ -0,0 +1,180 @@
# Phase 9.1 — Real Stripe TEST-Mode Integration Validation
Status: **BLOCKED — no Stripe TEST credentials are available to this session.** This document
records exactly what was verified without them, exactly what Chase needs to provide, and the
permanent, opt-in validation script that will run the real proof the moment those credentials
exist. No live money was risked; no Stripe network call of any kind was made during this phase.
## 1. Baseline verification
- Branch: `main`. Working tree: clean at start.
- `git fetch origin` — HEAD and `origin/main` both `80a5f6e4576cb9b83a60c9e9b57e4ebc52b62f07`,
exactly the Phase 9 milestone commit.
- Phase 9 payment code and documentation inspected in full: `payment-provider.js`,
`commerce.js`, `webhook-routes.js`, `offerings.js`'s registration/conversion path,
`admin-offerings-routes.js`'s payment routes, `integration-routes.js`'s checkout/agreement/
status routes, `platform/test/e2e-phase9.js`, `infra/scripts/drills/
test-phase9-winter-camp-commerce.js`, `docs/STRIPE_INTEGRATION_RUNBOOK.md`, `docs/
WINTER_BREAK_LAUNCH_CHECKLIST.md`. Nothing was changed as a result of this review — no defect
was found, and none was invented to justify a change (§1/§22's own instruction).
## 2. Stripe TEST credential search — none found
Searched, in order: process environment (`env | grep -i stripe`), every `.env`/`.env.example`
file reachable from this repository and the operator's home directory, this repository's own
Infisical project wiring (none exists — no `.infisical.json` in this repo), and this session's
own memory of prior credential-provisioning work (none references Stripe or this project). No
`STRIPE_SECRET_KEY`, no `STRIPE_WEBHOOK_SECRET`, in any test or live form, exists anywhere this
session can reach.
**This is exactly STOP condition 1/2 from this phase's own authorization.** Per §2/§27: do not
fake validation, stop after documenting exactly what's needed.
### What Chase needs to provide or configure
1. A Stripe account (test mode is available on any account, including one with no live
business verification completed yet).
2. From the Stripe Dashboard, in **Test mode**: the test **Secret key** (`sk_test_...`) →
`STRIPE_SECRET_KEY`.
3. A webhook signing secret for a test endpoint — either a permanent test-mode webhook endpoint
configured in the dashboard, or the ephemeral one the Stripe CLI prints when running
`stripe listen --forward-to <gateway>/api/webhooks/payment` → `STRIPE_WEBHOOK_SECRET`. Full
setup steps already documented in `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Local testing
against real Stripe TEST mode" section (written during Phase 9, unchanged and still accurate).
4. Both values set as environment configuration on the running Platform API process
(`PAYMENT_PROVIDER=stripe` alongside them) — never committed to git, never placed in any
browser-reachable configuration. **No credential value appears anywhere in this document, any
commit, or any log produced during this phase.**
Once those exist, run the permanent validation script this phase adds (§4 below) to produce the
actual proof this phase was asked to deliver.
## 3. Stripe adapter architecture — confirmed, unchanged
Inspected `payment-provider.js`'s `StripeProvider.createCheckoutSession()`: it uses **dynamic
Checkout line-item `price_data`** (`unit_amount`/`currency`/`product_data.name` supplied inline
per session), **not** pre-created Stripe Price/Product IDs. RootNode's `offerings` table remains
the sole source of truth for identity, price, and currency; Stripe never becomes a second
business-domain model — no Stripe object id is ever treated as anything other than an opaque
`provider_reference`. This matches §3's explicit constraint and required no change.
## 4. Permanent opt-in Stripe integration test — added
`platform/test/stripe-integration-test.js` (new). Default behavior (no environment variable
set): prints a skip message and exits `0` with **zero Stripe network traffic** — confirmed by
running it with no configuration at all. **Verified this run**:
```
$ node test/stripe-integration-test.js
Stripe integration test skipped (RUN_STRIPE_INTEGRATION_TEST is not "true") — no Stripe network traffic, no credentials required.
```
Enabled via `RUN_STRIPE_INTEGRATION_TEST=true`, reusing the exact `STRIPE_SECRET_KEY`/
`STRIPE_WEBHOOK_SECRET`-adjacent naming Phase 9 already established (only `STRIPE_SECRET_KEY` is
actually needed by the script itself, to independently query Stripe's own API — the webhook
secret only needs to be configured on the server process, not read by this script). Guard
clauses verified directly, without needing real credentials:
| Condition | Verified behavior |
|---|---|
| `OWNER_EMAIL`/`OWNER_PASSWORD` unset | Exits `1` with a clear message. |
| `STRIPE_SECRET_KEY` unset | Exits `1`, names the exact variable needed. |
| `STRIPE_SECRET_KEY` starts with `sk_live_` | **Refuses to run** — "TEST MODE ONLY" — exits `1`. |
| `STRIPE_SECRET_KEY` any other non-test shape | Refuses to run out of caution — exits `1`. |
| A syntactically valid `sk_test_...` value, no real stack running | Reaches `main()` cleanly, fails only on the expected connection error — confirms no syntax defect in the enabled code path. |
When real test credentials exist and the dev stack is running with `PAYMENT_PROVIDER=stripe`,
this script will automatically and honestly prove, via pure API calls (no browser needed):
- RootNode creates a **real** Stripe TEST Checkout Session (not RootNode's own claim — verified
by retrieving the session directly from Stripe's own `checkout.sessions.retrieve` API).
- The amount/currency Stripe actually recorded matches the offering's server-side price exactly,
even when the request is sent with a tampered `amount`/`currency` in the body — proving
server-authoritative pricing against **Stripe's own object**, the strongest form of that proof.
- `session.livemode === false` is asserted directly — the script itself refuses to consider its
own run valid against anything but a real TEST-mode session.
- RootNode's webhook route rejects an invalid/garbage signature cleanly, with **zero domain side
effect** (the registration's status is re-read and asserted unchanged).
What it deliberately does **not** automate: actually completing Stripe's hosted Checkout page.
There is no supported pure-API way to "pay" a Checkout Session, and fabricating a signed
"payment succeeded" webhook for a session nobody actually paid would itself be exactly the kind
of faked validation this phase was told never to produce. Instead, the script prints the real
checkout URL and polls RootNode's own status endpoint for a bounded window (default 90s,
`STRIPE_INTEGRATION_TEST_WAIT_SECONDS`) while a human completes payment in a browser (Stripe's
test card `4242 4242 4242 4242`) or a parallel `stripe listen --forward-to` relays Stripe's real
event — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`. If nothing arrives in that window (expected in
an unattended run), it says so plainly rather than reporting a false pass.
Never runs as part of normal regression suites or CI — no other test file requires or imports it,
and its own default is a guaranteed no-op.
## 5. What was actually proven this phase
| Step from the mission spec | Result |
|---|---|
| 1. Baseline verification | Done — see §1. |
| 2. Stripe TEST-mode safety | No live-mode action was possible or attempted; no credentials of any kind exist. |
| 3. Stripe architecture review | Done — dynamic `price_data`, confirmed, unchanged (§3). |
| 4-20 (disposable offering through security revalidation, all requiring a real Stripe TEST payment/webhook round trip) | **Not performed — blocked on missing credentials.** Faking any of these was explicitly disallowed. |
| 21. Permanent opt-in integration test | **Built** (§4) — the automatable portion is real and will run the moment credentials exist; the human/CLI-dependent portion is honestly deferred, not faked. |
| 22. Application changes | None made — no genuine defect was exposed (none could be, without real Stripe traffic), so §22's "smallest correct fix" rule had nothing to apply to. |
| 23. Regression | No registration/agreement/enrollment/notification/frontend code changed this phase, so per §23's own conditional rule, no regression rerun was required. The new test file's default no-op path was verified directly (§4) instead. |
| 24. Documentation | This document, plus the update noted below. |
| 25. Live-mode readiness checklist | See §6. |
## 6. Live-mode readiness checklist
Everything Stripe-side is still pending exactly as `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going
live" section already lays out, restated here as a checklist per §25:
**Stripe:**
- [ ] Business/account verification completed in the Stripe dashboard.
- [ ] Live API secret key (`sk_live_...`) generated.
- [ ] A permanent live-mode webhook endpoint created, pointed at the production API's
`/api/webhooks/payment` URL, and its signing secret (`whsec_...`) captured.
- [ ] Payout/bank details configured in Stripe.
- [ ] Business/support information (statement descriptor, support contact) configured in Stripe.
**RootNode:**
- [ ] A real, HTTPS production URL for the Platform API (Stripe requires HTTPS for live webhooks).
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
secret configuration only.
- [ ] Production deployment itself (explicitly out of scope for this phase and Phase 9 —
unchanged status).
- [ ] Backup/monitoring in place for the production database holding `payment_transactions`.
- [ ] The real Winter Break offering configured with its real price, once decided.
**Business** (Chase's decisions, not invented here): final price, refund/cancellation policy,
camp dates, venue, capacity — all still open, tracked in `docs/
WINTER_BREAK_LAUNCH_CHECKLIST.md`.
**Legal** (unchanged from every prior phase's own list): the actual agreement/waiver text,
guardian/minor consent review, privacy/retention requirements — see `docs/
STUDENT_GUARDIAN_DATA_MODEL.md`.
Nothing above is marked complete — none of it has been verified, and this phase did not attempt
to invent or shortcut any of it.
## 7. Bugs found
None. No genuine defect was exposed because no real Stripe traffic was possible this phase.
## 8. Security posture — reasoned about, not re-verified against real Stripe
Every item in the mission's §20 checklist was already true and tested against the deterministic
provider in Phase 9 (`e2e-phase9.js`), and nothing about switching providers changes the code
path that enforces them (the same `commerce.js`/`webhook-routes.js` code runs regardless of
which provider is active — see §3). This phase did not and could not re-verify them against
REAL Stripe traffic specifically; that re-verification is exactly what `stripe-integration-test.js`
(§4) will perform automatically the moment credentials exist.
## Final status
**PHASE 9.1 BLOCKED — NO STRIPE TEST CREDENTIALS AVAILABLE.**
No real Stripe TEST Checkout, real TEST payment, or real signed Stripe webhook was created or
observed this phase — none of the actions requiring them were attempted, per this phase's own
explicit instruction never to fake validation. The permanent, opt-in `stripe-integration-test.js`
is ready and waiting; provide `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (test mode) and set
`RUN_STRIPE_INTEGRATION_TEST=true` to run the real proof.
+6
View File
@@ -271,6 +271,12 @@ Real Stripe live-mode validation once credentials exist (`docs/STRIPE_INTEGRATIO
text once reviewed; and, informed by real Winter Break Camp commercial operation, whether a
waitlist is actually worth building.
**Phase 9.1 addendum**: real Stripe TEST-mode validation was attempted immediately after this
phase and remained blocked on the exact same missing credentials — see `docs/
PHASE9_1_STRIPE_TEST_VALIDATION.md`. That phase added a permanent, opt-in integration test
(`platform/test/stripe-integration-test.js`, default no-op, refuses live-mode keys) ready to run
the real proof the moment `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (test mode) exist.
---
## PHASE 9 APPLICATION BUILD COMPLETE / LIVE STRIPE VALIDATION PENDING
+7 -2
View File
@@ -74,8 +74,13 @@ test).
## Local testing against real Stripe TEST mode (once credentials exist)
Not yet exercised in this repo — no Stripe account/credentials have been provided as of this
phase. Once they exist:
Not yet exercised in this repo — no Stripe account/credentials have been provided as of Phase
9.1 either (see `docs/PHASE9_1_STRIPE_TEST_VALIDATION.md` for the exact search performed and
what's needed). Once they exist, `platform/test/stripe-integration-test.js` (added in Phase 9.1)
automates most of the steps below — set `RUN_STRIPE_INTEGRATION_TEST=true` plus
`STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/`OWNER_EMAIL`/`OWNER_PASSWORD` and run it; it refuses
to run against a live-mode key and makes zero Stripe network calls unless explicitly enabled.
Manually, or to understand what that script does:
1. Create (or use an existing) Stripe account, switch to **Test mode** in the dashboard.
2. Copy the test-mode **Secret key** (`sk_test_...`) into `STRIPE_SECRET_KEY`.
+6
View File
@@ -46,6 +46,12 @@ legal reviewer, or Stripe, not from more code.
## Payments — pending real Stripe credentials
Phase 9.1 searched for Stripe TEST credentials specifically to run real validation and found
none available anywhere this session could reach — see `docs/
PHASE9_1_STRIPE_TEST_VALIDATION.md` for the exact search and what Chase needs to provide. A
permanent, opt-in test (`platform/test/stripe-integration-test.js`) is ready to run the real
TEST-mode proof the moment `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` exist.
- [ ] Real Stripe account created/available (live mode).
- [ ] `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET` (live values) provisioned into production
configuration only — see `docs/STRIPE_INTEGRATION_RUNBOOK.md`'s "Going live" section.
+205
View File
@@ -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 <gateway>/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); });