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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
This commit is contained in:
Evan Jarrett
2026-08-25 16:34:26 -05:00
co-authored by Claude Opus 5
parent fa6473a896
commit 18c77ace28
3 changed files with 256 additions and 0 deletions
+160
View File
@@ -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 <option> from the others, so a scraped "active hold" measures the
// first option rather than the current one.
const selected = await page.evaluate(() => {
const sel = document.querySelector('select');
if (!sel) return '(no select found)';
return sel.options[sel.selectedIndex]?.textContent?.trim() ?? '(none selected)';
});
console.log('hold <select> selected option:', selected, '\n');
record(
`[${PHASE}] signed in as a non-captain`,
handle !== 'evan.jarrett.net',
'the hold owner bypasses every gate under test',
);
// --- the billing tab -------------------------------------------------------
await page.goto(`${APPVIEW}/settings/billing`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const billing = await text();
console.log('--- /settings/billing ---\n' + billing + '\n--- end ---\n');
record(
`[${PHASE}] billing page does not render a Captain tier`,
!/\bCaptain\b/.test(billing),
'a Captain tier means the account bypassed billing and the render proves nothing',
);
if (PHASE === 'managed') {
record('[managed] real tiers are offered', /Upgrade/.test(billing), 'no Upgrade control rendered');
record(
'[managed] no self-hosted warning',
!/self-hosted|managed hold/i.test(billing),
'a managed user should not be told they are self-hosted',
);
} else {
record(
'[selfhosted] a self-hosted notice is shown somewhere in settings',
/self-hosted|managed hold/i.test(billing + storage),
'the user loses paid features with no visible explanation',
);
}
// --- the checkout route, which is the gate 2b71be5 added -------------------
const checkoutURL = `${APPVIEW}/settings/subscription/checkout?tier=Pro&interval=monthly`;
const res = await ctx.request.get(checkoutURL, { maxRedirects: 0 });
const loc = res.headers()['location'] ?? '';
console.log(`checkout GET -> ${res.status()} ${loc.slice(0, 120)}\n`);
if (PHASE === 'managed') {
record(
'[managed] checkout redirects to Stripe',
res.status() >= 300 && res.status() < 400 && /stripe\.com/.test(loc),
`expected a 3xx to stripe.com, got ${res.status()} ${loc.slice(0, 80)}`,
);
} else {
record(
'[selfhosted] checkout is refused',
res.status() === 403,
`expected 403, got ${res.status()} ${loc.slice(0, 80)}`,
);
}
// --- the portal, which a self-hosted subscriber must still reach ----------
const portal = await ctx.request.get(`${APPVIEW}/settings/subscription/portal`, { maxRedirects: 0 });
const portalLoc = portal.headers()['location'] ?? '';
console.log(`portal GET -> ${portal.status()} ${portalLoc.slice(0, 120)}\n`);
record(
`[${PHASE}] billing portal is reachable`,
portal.status() >= 300 && portal.status() < 400 && /stripe\.com/.test(portalLoc),
`a subscriber who cannot reach the portal cannot cancel — got ${portal.status()} ${portalLoc.slice(0, 80)}`,
);
// --- the AI advisor, which must say "switch holds" not "upgrade" ----------
// The gate runs before identity resolution, so any repo and digest reach it.
const adv = await ctx.request.get(
`${APPVIEW}/api/image-advisor/${handle}/cuda?digest=sha256:0000000000000000000000000000000000000000000000000000000000000000`,
);
const advBody = await adv.text();
console.log(`advisor GET -> ${adv.status()}; body mentions: ` +
`managed_hold_required=${/managed_hold_required|managed hold/i.test(advBody)} ` +
`upgrade_required=${/upgrade_required|upgrade/i.test(advBody)}\n`);
if (PHASE === 'selfhosted') {
record(
'[selfhosted] advisor says the hold is wrong, not that the plan is',
/managed_hold_required|managed hold/i.test(advBody),
'a paying subscriber told to "upgrade" would buy a tier they already have',
);
}
// --- webhooks stay LISTED after entitlement is lost -----------------------
// 6510c16 introduced a gap between "listed" and "delivered": dispatch caps to
// the current allowance, but the settings page keeps showing every webhook the
// user created. Delivery itself fires from DispatchForScan and needs a scanner,
// which the dev stack does not run, so only the listing half is observable here.
await page.goto(`${APPVIEW}/settings/webhooks`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
const hooks = await text();
const hookRows = (hooks.match(/https?:\/\/[^\s]+/g) ?? []).filter((u) => !u.includes('127.0.0.1:5000'));
console.log(`--- /settings/webhooks --- (${hookRows.length} endpoint URLs visible)\n` + hooks.slice(0, 1200) + '\n--- end ---\n');
summarize();
if (process.env.OPEN_CHECKOUT && PHASE === 'managed') {
console.log('\n >>> Opening Stripe Checkout. Pay with 4242 4242 4242 4242, any future expiry/CVC.');
console.log(' >>> Waiting for the redirect back to the appview.\n');
await page.goto(checkoutURL, { waitUntil: 'domcontentloaded' });
const deadline = Date.now() + 10 * 60 * 1000;
while (Date.now() < deadline && !page.url().startsWith(APPVIEW)) await page.waitForTimeout(2000);
console.log('back at:', page.url());
await page.waitForTimeout(4000);
console.log('--- after checkout ---\n' + (await text()) + '\n--- end ---');
}
console.log('\nBrowser left open. Kill this task when done.');
await new Promise(() => {});
+42
View File
@@ -0,0 +1,42 @@
// batch13-portal-cancel.mjs — drive Stripe's real billing portal.
//
// The batch card calls a subscriber who cannot reach cancel the worst outcome
// in this batch. Asserting the 302 to billing.stripe.com only proves the
// redirect; this walks into the portal and looks for the cancel control that
// the redirect is supposed to lead to.
import { open, reporter, APPVIEW } from './lib.mjs';
const { record, summarize } = reporter();
const ctx = await open();
const page = ctx.pages()[0] ?? (await ctx.newPage());
const text = () => page.evaluate(() => document.body.innerText);
await page.goto(`${APPVIEW}/settings/subscription/portal`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
console.log('portal url:', page.url());
const portal = await text();
console.log('--- portal ---\n' + portal.slice(0, 1500) + '\n--- end ---\n');
record(
'the portal actually loads for this subscriber',
/stripe\.com/.test(page.url()),
`landed on ${page.url()} instead`,
);
const cancelLink = page.getByText(/cancel plan|cancel subscription/i).first();
const hasCancel = (await cancelLink.count()) > 0;
record(
'a cancel control is present in the portal',
hasCancel,
'the redirect leads somewhere with no way to cancel',
);
if (hasCancel) {
await cancelLink.click();
await page.waitForTimeout(4000);
console.log('--- after clicking cancel ---\n' + (await text()).slice(0, 1200) + '\n--- end ---');
console.log('\nurl:', page.url());
}
summarize();
await ctx.close();
+54
View File
@@ -0,0 +1,54 @@
// batch13-webhook-downgrade.mjs — the last item on the billing Drive list.
//
// 6510c16 introduced a gap between "listed" and "delivered": dispatch caps a
// user's webhooks to their CURRENT allowance, while the settings page keeps
// showing every webhook they created. This drives the gap: create three while
// entitled to ten, cancel the subscription through Stripe's real portal, and
// confirm all three are still listed under an allowance of one.
//
// The delivery half is not observable here — it fires from DispatchForScan and
// the dev stack runs no scanner. It is unit-covered by 33c2321's own
// dispatch_entitlement_test.go.
import { open, reporter, APPVIEW } from './lib.mjs';
const { record, summarize } = reporter();
const ctx = await open();
const page = ctx.pages()[0] ?? (await ctx.newPage());
const text = () => page.evaluate(() => document.body.innerText);
const allowance = (s) => (s.match(/(\d+)\s*\/\s*(\d+)\s*webhooks configured/) ?? []).slice(1, 3);
await page.goto(`${APPVIEW}/settings/webhooks`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
let hooks = await text();
console.log('before:', allowance(hooks).join(' of '), 'configured\n');
// Create three webhooks through the form the user would use.
for (const n of [1, 2, 3]) {
const url = `https://example.invalid/hook-${n}`;
const already = (await text()).includes(url);
if (already) { console.log(`hook-${n} already present`); continue; }
// Scope to the webhook form: a bare button[type=submit] also matches the
// nav's hidden logout button, which is the first match in the DOM.
const form = page.locator('form[hx-post="/api/webhooks"]');
await form.locator('input[name="url"]').fill(url);
await form.locator('button[type="submit"]').click();
await page.waitForTimeout(2000);
console.log(`created hook-${n}`);
}
await page.goto(`${APPVIEW}/settings/webhooks`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
hooks = await text();
const [have, cap] = allowance(hooks);
console.log(`after creating: ${have} of ${cap} configured\n`);
record(
'three webhooks exist while entitled',
Number(have) >= 3,
`only ${have} present; the downgrade check needs three`,
);
record('the allowance is the paid one', Number(cap) >= 3, `cap is ${cap}, expected the Pro allowance`);
console.log(hooks.slice(0, 1400));
summarize();
console.log('\nLeaving the browser open for the portal step.');
await new Promise(() => {});