[object Object]

← 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

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 →