From 18c77ace28eee10768a2171faec5eb31359ed083 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 24 Aug 2026 21:07:54 -0500 Subject: [PATCH] test/e2e: drive the billing Drive list against the Stripe sandbox Three scripts covering what only a browser can see, run end to end against the sandbox with a real checkout and a real portal cancellation. batch13-billing-drive.mjs runs the same account either side of one config change -- whether its default hold appears in server.managed_holds. Managed: the billing tab offers real tiers, checkout 302s to Stripe, the portal is reachable. Self-hosted: checkout 403s, the portal still 302s, and the advisor answers managed_hold_required rather than upgrade_required, which matters because telling a paying subscriber to "upgrade" would sell them a tier they already hold. batch13-portal-cancel.mjs walks into Stripe's portal instead of asserting the redirect, because the batch card calls a subscriber who cannot cancel the worst outcome here and a 302 does not prove a cancel control exists at the far end. batch13-webhook-downgrade.mjs creates three webhooks under an allowance of ten, then reads the page back after the downgrade. Every one of these is invisible on a hold owner's account: GetSubscriptionInfo returns a synthetic "Captain" tier before any Stripe lookup, and GetWebhookLimits / HasAIAdvisor / GetSupporterBadge bypass on the same first line. The first run used the shared e2e profile, which still had the owner signed in, and reported a clean pass built entirely on that bypass. The scripts now assert the page does not render "Captain", and take a separate profile. Two traps worth keeping: a bare button[type=submit] matches the nav's hidden logout button before the form's own submit, and hx-confirm here renders a custom modal whose backdrop swallows clicks -- strip the attribute rather than trying to dismiss a dialog that never fires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB --- test/e2e/batch13-billing-drive.mjs | 160 +++++++++++++++++++++++++ test/e2e/batch13-portal-cancel.mjs | 42 +++++++ test/e2e/batch13-webhook-downgrade.mjs | 54 +++++++++ 3 files changed, 256 insertions(+) create mode 100644 test/e2e/batch13-billing-drive.mjs create mode 100644 test/e2e/batch13-portal-cancel.mjs create mode 100644 test/e2e/batch13-webhook-downgrade.mjs diff --git a/test/e2e/batch13-billing-drive.mjs b/test/e2e/batch13-billing-drive.mjs new file mode 100644 index 0000000..b947d40 --- /dev/null +++ b/test/e2e/batch13-billing-drive.mjs @@ -0,0 +1,160 @@ +// batch13-billing-drive.mjs — the billing batch's Drive list. +// +// Every check here is invisible on a hold owner's account: GetSubscriptionInfo +// short-circuits for a captain and returns a synthetic "Captain" tier before any +// Stripe lookup, and GetWebhookLimits / HasAIAdvisor / GetSupporterBadge bypass +// on the same first line. Run this as a NON-CAPTAIN. +// +// PHASE=managed node test/e2e/batch13-billing-drive.mjs +// PHASE=selfhosted node test/e2e/batch13-billing-drive.mjs +// +// The two phases are the same account either side of one config change: whether +// its default hold appears in server.managed_holds. +import { open, reporter, APPVIEW } from './lib.mjs'; + +const PHASE = process.env.PHASE ?? 'managed'; +const { record, summarize } = reporter(); + +const ctx = await open(); +const page = ctx.pages()[0] ?? (await ctx.newPage()); +const text = () => page.evaluate(() => document.body.innerText); +const atLogin = (u) => /\/login|\/auth\/oauth|\/oauth\/authorize|jarrett\.app|bsky\./.test(u); + +await page.goto(`${APPVIEW}/settings`, { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(2500); + +// Wait for a human to finish OAuth. Deliberately does NOT navigate: reloading +// would wipe out a half-typed password form. +if (atLogin(page.url())) { + console.log('\n >>> Sign in as the NON-CAPTAIN account in the open browser window.\n'); + const deadline = Date.now() + 10 * 60 * 1000; + while (Date.now() < deadline && atLogin(page.url())) await page.waitForTimeout(2000); + if (atLogin(page.url())) { console.error('Timed out.'); await ctx.close(); process.exit(2); } + console.log('Signed in.\n'); +} + +// --- who, and on what hold ------------------------------------------------- +await page.goto(`${APPVIEW}/settings/storage`, { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(1500); +const storage = await text(); +const handle = (storage.match(/@([\w.-]+)/) ?? [])[1] ?? '(unknown)'; +const activeHold = (storage.match(/Active Hold:\s*\n?\s*([^\n]+)/) ?? [])[1] ?? '(unknown)'; +console.log(`account: @${handle}\nactive hold (scraped): ${activeHold}\n`); +console.log('--- /settings/storage ---\n' + storage + '\n--- end ---\n'); +// Read the hold selector from the DOM. innerText cannot distinguish the +// selected