← back to NationalPaperHangers
e2e-stripe-flow: real installer-session fixture + Connect URL hardening
c8e0e1a2422284083e4d48c57b446e9916b6b5fd · 2026-05-10 14:07:34 -0700 · SteveStudio2
Phases 1-3 now drive the actual Stripe Connect + checkout flow with a
playwright-logged-in installer (rivera-installs-miami / demo+rivera).
Form submits go through the page (csrf + cookies ride along) but don't
follow Stripe's cross-origin redirects — instead they assert on the DB
side-effect (stripe_account_id populated, tier flipped, etc.) so the
test never has to load connect.stripe.com or checkout.stripe.com.
Safety gates:
- STRIPE_KEY_LIVE detection: when the test env has sk_live_ keys, phases
2 + 3 SKIP cleanly with a clear message — they would otherwise create
REAL Connect accounts + Checkout sessions in Steve's live Stripe. Opt
in with STRIPE_E2E_FULL=1 only against a sk_test_ server.
- Webhook probe: phase 5 sends a no-op probe event first; if the server
rejects with 400 (sig enforced, no STRIPE_DEV_ACCEPT_UNSIGNED on the
server side), phases 5+6 SKIP cleanly instead of failing.
- Output format aligned with run-e2e.js parser ([result] N pass · N fail).
Real bug fixed in lib/stripe.js — Connect account creation passed
installer.website straight through as business_profile.url, which
Stripe rejects with url_invalid for placeholder URLs (example.com,
localhost, .test). New validBusinessUrl() filter parses + whitelists
real public hosts only. Caught by trying to onboard rivera (seeded
with https://example.com/rivera) → 500 from Stripe.
Local suite now: 109 pass · 0 fail · 3 skipped (claim/template/stripe
skip when login limiter is exhausted from chained runs — pre-existing
soft-skip behavior).
Files touched
M lib/stripe.jsM tests/e2e-stripe-flow.js
Diff
commit c8e0e1a2422284083e4d48c57b446e9916b6b5fd
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Sun May 10 14:07:34 2026 -0700
e2e-stripe-flow: real installer-session fixture + Connect URL hardening
Phases 1-3 now drive the actual Stripe Connect + checkout flow with a
playwright-logged-in installer (rivera-installs-miami / demo+rivera).
Form submits go through the page (csrf + cookies ride along) but don't
follow Stripe's cross-origin redirects — instead they assert on the DB
side-effect (stripe_account_id populated, tier flipped, etc.) so the
test never has to load connect.stripe.com or checkout.stripe.com.
Safety gates:
- STRIPE_KEY_LIVE detection: when the test env has sk_live_ keys, phases
2 + 3 SKIP cleanly with a clear message — they would otherwise create
REAL Connect accounts + Checkout sessions in Steve's live Stripe. Opt
in with STRIPE_E2E_FULL=1 only against a sk_test_ server.
- Webhook probe: phase 5 sends a no-op probe event first; if the server
rejects with 400 (sig enforced, no STRIPE_DEV_ACCEPT_UNSIGNED on the
server side), phases 5+6 SKIP cleanly instead of failing.
- Output format aligned with run-e2e.js parser ([result] N pass · N fail).
Real bug fixed in lib/stripe.js — Connect account creation passed
installer.website straight through as business_profile.url, which
Stripe rejects with url_invalid for placeholder URLs (example.com,
localhost, .test). New validBusinessUrl() filter parses + whitelists
real public hosts only. Caught by trying to onboard rivera (seeded
with https://example.com/rivera) → 500 from Stripe.
Local suite now: 109 pass · 0 fail · 3 skipped (claim/template/stripe
skip when login limiter is exhausted from chained runs — pre-existing
soft-skip behavior).
---
lib/stripe.js | 18 +++-
tests/e2e-stripe-flow.js | 269 +++++++++++++++++++++++++++++++++++++----------
2 files changed, 231 insertions(+), 56 deletions(-)
diff --git a/lib/stripe.js b/lib/stripe.js
index f5657eb..a903513 100644
--- a/lib/stripe.js
+++ b/lib/stripe.js
@@ -192,6 +192,22 @@ async function createConnectAccount({ installer }) {
payouts_enabled: false
};
}
+ // Stripe rejects placeholder/test URLs (example.com, localhost) with
+ // url_invalid. Only pass `url` when it looks like a real public site.
+ const validBusinessUrl = (() => {
+ const u = (installer.website || '').trim();
+ if (!u) return undefined;
+ try {
+ const parsed = new URL(u);
+ if (!/^https?:$/.test(parsed.protocol)) return undefined;
+ const host = parsed.hostname.toLowerCase();
+ if (/^(localhost|127\.|0\.0\.0\.0)/.test(host)) return undefined;
+ if (/(^|\.)example\.(com|org|net)$/.test(host)) return undefined;
+ if (/(^|\.)test$/.test(host)) return undefined;
+ return parsed.toString();
+ } catch { return undefined; }
+ })();
+
const acct = await c.accounts.create({
type: 'express',
country: 'US',
@@ -203,7 +219,7 @@ async function createConnectAccount({ installer }) {
business_type: 'company',
business_profile: {
name: installer.business_name,
- url: installer.website || undefined,
+ url: validBusinessUrl,
mcc: '1771'
},
metadata: {
diff --git a/tests/e2e-stripe-flow.js b/tests/e2e-stripe-flow.js
index e39765f..bf54431 100644
--- a/tests/e2e-stripe-flow.js
+++ b/tests/e2e-stripe-flow.js
@@ -4,19 +4,19 @@
// connect/billing routes + routes/webhooks.js + routes/api.js
// createBookingDepositIntent integration) without sending money to Stripe.
//
-// Phases (each is a self-contained set of assertions; a phase that requires
-// Stripe-live env returns SKIP=78 if STRIPE_SECRET_KEY is unset):
+// Phases:
//
// 1. /admin/billing renders for a paid-tier installer
// · price grid, Connect callout, and per-installer marketplace stat-grid
// all present.
// 2. /admin/connect/onboard POST returns either a Stripe redirect URL (live
// mode) or a mock-redirect to /admin?mock=connect (no STRIPE_SECRET_KEY).
+// DB row gets stripe_account_id populated. Cleaned up after.
// 3. POST /admin/billing/checkout returns a Stripe Checkout URL or, in mock
// mode, flips installers.tier locally and redirects to /admin/billing?ok=1.
-// 4. Booking flow: POST a booking via /api/installers/:slug/book and assert
-// the response includes deposit.client_secret (live) or deposit_status
-// flips to 'requires_payment' on the row (mock).
+// DB row tier flips signature → pro. Cleaned up after.
+// 4. Booking flow — SKIP (depends on consumer signed-in session; covered
+// by e2e-buyer-wizard at the wizard layer).
// 5. Webhook fan-in: POST a forged checkout.session.completed event to
// /webhooks/stripe with STRIPE_DEV_ACCEPT_UNSIGNED=1 and assert the
// installer row reflects subscription_status='active'.
@@ -27,21 +27,39 @@
// Run: node tests/e2e-stripe-flow.js
// Env: BASE=http://localhost:9765 (default)
// SLUG=<paid-tier studio slug> (default rivera-installs-miami)
+// EMAIL=<installer email> (default demo+rivera@example.com)
+// PW=<installer pw> (default demo1234)
// STRIPE_DEV_ACCEPT_UNSIGNED=1 to enable phases 5+6 in mock mode
// STRIPE_SECRET_KEY=sk_test_... to run live-mode assertions
//
+// Phases 1-3 require local DB access for cleanup — skipped cleanly when BASE
+// is remote.
+//
// Exit codes: 0 = all phases passed, 1 = at least one assertion failed,
// 2 = setup failed (no browser, target not reachable),
// 78 = phase skipped due to missing env (treated as SKIP by run-e2e.js)
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+
const http = require('http');
const https = require('https');
const { chromium } = require('playwright-core');
const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = process.env.SLUG || 'rivera-installs-miami';
+const EMAIL = process.env.EMAIL || 'demo+rivera@example.com';
+const PW = process.env.PW || 'demo1234';
const STRIPE_LIVE = !!process.env.STRIPE_SECRET_KEY;
const ACCEPT_UNSIGNED = process.env.STRIPE_DEV_ACCEPT_UNSIGNED === '1';
+const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
+// Safety gate — phases 2/3/5/6 either write to Stripe (creating real Connect
+// accounts + Checkout sessions in Steve's live Stripe account) or require the
+// SERVER to accept unsigned webhook payloads. Both are unsafe against a
+// production-keyed pm2 process. Default to Phase-1-only; opt in with
+// STRIPE_E2E_FULL=1 (requires a test/mock-mode server, not live keys).
+const FULL = process.env.STRIPE_E2E_FULL === '1';
+const STRIPE_KEY_LIVE = /^sk_live_/.test(process.env.STRIPE_SECRET_KEY || '');
function findChromium() {
const paths = [
@@ -53,6 +71,26 @@ function findChromium() {
return null;
}
+// DB helper — only loaded when IS_LOCAL so remote runs don't try to connect.
+let _db = null;
+function db() {
+ if (_db) return _db;
+ _db = require('../lib/db');
+ return _db;
+}
+
+async function loginAsInstaller(page) {
+ const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
+ if (resp && resp.status() === 429) return { rateLimited: true };
+ await page.fill('input[name="email"]', EMAIL);
+ await page.fill('input[name="password"]', PW);
+ await Promise.all([
+ page.waitForURL(/\/admin/, { timeout: 8000 }),
+ page.click('form[action="/login"] button[type="submit"]'),
+ ]);
+ return { rateLimited: false };
+}
+
function postJson(url, body, headers = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
@@ -87,37 +125,149 @@ function postJson(url, body, headers = {}) {
const skipPhase = (msg) => { console.log(` ⤷ SKIP — ${msg}`); skip++; };
try {
- // ─── Phase 1 — /admin/billing renders for paid-tier installer ─────────
- console.log(`\n[phase 1] /admin/billing renders for ${SLUG}`);
- await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });
- const t = await page.title();
- assert(/Billing/i.test(t), `page title contains "Billing" (got "${t}")`);
-
- const priceCards = await page.$$eval('.price-card', els => els.length);
- assert(priceCards >= 1, `at least 1 price card (got ${priceCards})`);
-
- const connectCallout = await page.$('.callout-warn, .callout-ok');
- assert(!!connectCallout, 'Connect onboarding callout present (warn or ok)');
-
- const statGrid = await page.$('.stat-grid');
- if (statGrid) {
- const stats = await page.$$eval('.stat-grid .stat-label', els => els.map(e => e.textContent.trim()));
- assert(stats.some(s => /Deposits/i.test(s)), 'installer marketplace shows "Deposits" stat');
- assert(stats.some(s => /Platform fee|fee/i.test(s)), 'installer marketplace shows fee stat');
+ // ─── Phase 1 — login + /admin/billing renders for paid-tier installer ─
+ console.log(`\n[phase 1] login as ${EMAIL} → /admin/billing renders for ${SLUG}`);
+ const { rateLimited } = await loginAsInstaller(page);
+ if (rateLimited) {
+ console.log(' ⊘ /login returned 429 — skipping all installer-session phases');
+ skipPhase('login rate-limit (429) tripped from prior runs; wait ~15min');
+ // jump straight to phase 5 by closing browser early
} else {
- skipPhase('no installer-market stats — installer has no paid bookings yet');
- }
+ assert(/\/admin/.test(page.url()), `landed on /admin after login (got ${page.url()})`);
+
+ await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });
+ const t = await page.title();
+ assert(/Billing/i.test(t), `page title contains "Billing" (got "${t}")`);
+
+ const priceCards = await page.$$eval('.price-card', els => els.length);
+ assert(priceCards >= 1, `at least 1 price card (got ${priceCards})`);
+
+ const connectCallout = await page.$('.callout-warn, .callout-ok');
+ assert(!!connectCallout, 'Connect onboarding callout present (warn or ok)');
+
+ const statGrid = await page.$('.stat-grid');
+ if (statGrid) {
+ const stats = await page.$$eval('.stat-grid .stat-label', els => els.map(e => e.textContent.trim()));
+ assert(stats.some(s => /Deposits/i.test(s)), 'installer marketplace shows "Deposits" stat');
+ assert(stats.some(s => /Platform fee|fee/i.test(s)), 'installer marketplace shows fee stat');
+ } else {
+ console.log(' ⤷ no installer-market stat-grid (installer has no paid bookings yet)');
+ }
- // ─── Phase 2 — /admin/connect/onboard returns redirect ────────────────
- console.log(`\n[phase 2] POST /admin/connect/onboard → redirect`);
- // Need session cookie from a logged-in installer; in CI this requires
- // a test-fixture login. Skip if no STRIPE_SECRET_KEY (mock path returns
- // /admin?mock=connect) until login fixture lands.
- skipPhase('requires installer-session fixture — track in followup');
+ // Helper — programmatic POST from inside the page so cookies + same-origin
+ // headers ride along but we never follow Stripe's redirect (which would
+ // load connect.stripe.com / checkout.stripe.com over the network and
+ // flake/timeout the test). manual-redirect lets us assert on the 302
+ // Location header directly.
+ async function postFormWithCsrf(action, extraFields = {}) {
+ return await page.evaluate(async ({ action, extraFields }) => {
+ // Pull csrf token from any form on the current page that has it
+ const tokenInput = document.querySelector('input[name="_csrf"]');
+ const token = tokenInput ? tokenInput.value : '';
+ const body = new URLSearchParams();
+ body.set('_csrf', token);
+ for (const [k, v] of Object.entries(extraFields)) body.set(k, v);
+ const r = await fetch(action, {
+ method: 'POST',
+ redirect: 'manual',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: body.toString()
+ });
+ // With redirect: 'manual', cross-origin 302 surfaces type='opaqueredirect'
+ // and same-origin redirect surfaces as 0 + redirected=true. Either way,
+ // the Location header is not exposed to JS — so we capture status only
+ // and rely on the DB write as the assertion surface for the side-effect.
+ return { status: r.status, type: r.type, redirected: r.redirected };
+ }, { action, extraFields });
+ }
- // ─── Phase 3 — /admin/billing/checkout returns Checkout URL ───────────
- console.log(`\n[phase 3] POST /admin/billing/checkout → checkout URL`);
- skipPhase('requires installer-session fixture — track in followup');
+ // ─── Phase 2 — /admin/connect/onboard returns redirect ──────────────
+ console.log(`\n[phase 2] POST /admin/connect/onboard → redirect`);
+ if (!IS_LOCAL) {
+ skipPhase('phase 2 requires local DB cleanup — BASE is remote');
+ } else if (STRIPE_KEY_LIVE && !FULL) {
+ skipPhase('STRIPE_SECRET_KEY is sk_live_ — would create REAL Connect accounts. Set STRIPE_E2E_FULL=1 only against a sk_test_ server');
+ } else {
+ await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });
+
+ const before = await db().query(
+ `SELECT id, stripe_account_id FROM installers WHERE slug=$1`, [SLUG]
+ );
+ const installerId = before.rows[0].id;
+ const priorAcct = before.rows[0].stripe_account_id;
+
+ const r = await postFormWithCsrf('/admin/connect/onboard');
+ assert(r.type === 'opaqueredirect' || r.status === 302 || r.status === 0,
+ `connect/onboard responded with redirect (status=${r.status} type=${r.type})`);
+
+ const after = await db().query(
+ `SELECT stripe_account_id FROM installers WHERE id=$1`, [installerId]
+ );
+ const newAcct = after.rows[0].stripe_account_id;
+ assert(!!newAcct, `stripe_account_id populated after onboard (got "${newAcct}")`);
+ if (!STRIPE_LIVE) {
+ assert(/^acct_mock_/.test(newAcct), `mock account_id has acct_mock_ prefix (got "${newAcct}")`);
+ } else {
+ assert(/^acct_/.test(newAcct), `live Stripe account_id has acct_ prefix (got "${newAcct}")`);
+ }
+
+ // Cleanup — restore prior account_id (likely NULL) so the test is rerunnable
+ await db().query(
+ `UPDATE installers SET stripe_account_id=$2,
+ stripe_account_charges_enabled=false,
+ stripe_account_payouts_enabled=false
+ WHERE id=$1`,
+ [installerId, priorAcct]
+ );
+ }
+
+ // ─── Phase 3 — /admin/billing/checkout returns Checkout URL ─────────
+ console.log(`\n[phase 3] POST /admin/billing/checkout → checkout URL`);
+ if (!IS_LOCAL) {
+ skipPhase('phase 3 requires local DB cleanup — BASE is remote');
+ } else if (STRIPE_KEY_LIVE && !FULL) {
+ skipPhase('STRIPE_SECRET_KEY is sk_live_ — would create REAL Checkout sessions. Set STRIPE_E2E_FULL=1 only against a sk_test_ server');
+ } else {
+ const before = await db().query(
+ `SELECT id, tier FROM installers WHERE slug=$1`, [SLUG]
+ );
+ const installerId = before.rows[0].id;
+ const priorTier = before.rows[0].tier;
+ // Pick a different tier so the mock-mode flip is observable.
+ // Rivera seeds at 'pro' → flip to 'signature'.
+ const targetTier = priorTier === 'signature' ? 'pro' : 'signature';
+
+ // Need a fresh /admin/billing page so the csrf token is current
+ await page.goto(`${BASE}/admin/billing`, { waitUntil: 'domcontentloaded' });
+
+ const r = await postFormWithCsrf('/admin/billing/checkout', {
+ tier: targetTier,
+ cadence: 'monthly'
+ });
+ assert(r.type === 'opaqueredirect' || r.status === 302 || r.status === 0,
+ `billing/checkout responded with redirect (status=${r.status} type=${r.type})`);
+
+ if (!STRIPE_LIVE) {
+ // mock mode flips the tier locally
+ const after = await db().query(
+ `SELECT tier, subscription_status FROM installers WHERE id=$1`, [installerId]
+ );
+ assert(after.rows[0].tier === targetTier,
+ `tier flipped to ${targetTier} (got "${after.rows[0].tier}")`);
+ assert(after.rows[0].subscription_status === 'active',
+ `subscription_status='active' (got "${after.rows[0].subscription_status}")`);
+ } else {
+ // live mode — checkout session created, but tier not flipped until webhook
+ console.log(' ⤷ LIVE mode: tier flip waits for checkout.session.completed webhook');
+ }
+
+ // Cleanup — restore prior tier
+ await db().query(
+ `UPDATE installers SET tier=$2 WHERE id=$1`,
+ [installerId, priorTier]
+ );
+ }
+ }
// ─── Phase 4 — Booking flow: createBookingDepositIntent fires ────────
console.log(`\n[phase 4] POST /api/installers/${SLUG}/book → deposit intent`);
@@ -129,29 +279,36 @@ function postJson(url, body, headers = {}) {
// ─── Phase 5 — Webhook fan-in: forged checkout.session.completed ──────
console.log(`\n[phase 5] POST /webhooks/stripe (forged event)`);
if (!ACCEPT_UNSIGNED) {
- skipPhase('STRIPE_DEV_ACCEPT_UNSIGNED!=1 — webhook fail-closed in non-dev env (correct)');
+ skipPhase('STRIPE_DEV_ACCEPT_UNSIGNED!=1 in test env — webhook fail-closed in non-dev (correct)');
} else {
- const eventId = `evt_test_${Date.now()}`;
- const event = {
- id: eventId,
- type: 'checkout.session.completed',
- data: { object: {
- customer: 'cus_test_e2e',
- subscription: 'sub_test_e2e',
- metadata: { installer_id: '0', tier: 'pro' } // installer_id=0 → no real row touched
- } }
- };
- const r = await postJson(`${BASE}/webhooks/stripe`, event);
- assert(r.status === 200, `webhook returns 200 (got ${r.status})`);
- const j = JSON.parse(r.body || '{}');
- assert(j.received === true, 'webhook responds {received:true}');
-
- // ─── Phase 6 — Idempotency: replay same event ─────────────────────
- console.log(`\n[phase 6] POST same event again → idempotent short-circuit`);
- const r2 = await postJson(`${BASE}/webhooks/stripe`, event);
- assert(r2.status === 200, `replay returns 200 (got ${r2.status})`);
- const j2 = JSON.parse(r2.body || '{}');
- assert(j2.idempotent === true, 'replay responds {idempotent:true}');
+ // Probe — if SERVER process doesn't have STRIPE_DEV_ACCEPT_UNSIGNED set,
+ // this will 400 with bad signature. Skip cleanly instead of failing.
+ const probe = await postJson(`${BASE}/webhooks/stripe`, { id: 'evt_probe', type: 'ping' });
+ if (probe.status === 400) {
+ skipPhase('SERVER process lacks STRIPE_DEV_ACCEPT_UNSIGNED=1 (signature enforced) — phases 5+6 require dev-mode server');
+ } else {
+ const eventId = `evt_test_${Date.now()}`;
+ const event = {
+ id: eventId,
+ type: 'checkout.session.completed',
+ data: { object: {
+ customer: 'cus_test_e2e',
+ subscription: 'sub_test_e2e',
+ metadata: { installer_id: '0', tier: 'pro' } // installer_id=0 → no real row touched
+ } }
+ };
+ const r = await postJson(`${BASE}/webhooks/stripe`, event);
+ assert(r.status === 200, `webhook returns 200 (got ${r.status})`);
+ const j = JSON.parse(r.body || '{}');
+ assert(j.received === true, 'webhook responds {received:true}');
+
+ // ─── Phase 6 — Idempotency: replay same event ─────────────────────
+ console.log(`\n[phase 6] POST same event again → idempotent short-circuit`);
+ const r2 = await postJson(`${BASE}/webhooks/stripe`, event);
+ assert(r2.status === 200, `replay returns 200 (got ${r2.status})`);
+ const j2 = JSON.parse(r2.body || '{}');
+ assert(j2.idempotent === true, 'replay responds {idempotent:true}');
+ }
}
} catch (err) {
@@ -162,5 +319,7 @@ function postJson(url, body, headers = {}) {
}
console.log(`\n[e2e-stripe] ${pass} passed · ${fail} failed · ${skip} skipped`);
+ // Format the runner parses: "[result] N pass · N fail"
+ console.log(`[result] ${pass} pass · ${fail} fail`);
process.exit(fail > 0 ? 1 : (pass === 0 && skip > 0 ? 78 : 0));
})();
← 6f348e2 nph-stripe-plumbing: scaffold e2e-stripe-flow.js + synthesis
·
back to NationalPaperHangers
·
Live COI request flow (UX backlog #5) 66c6c0d →