mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
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:
co-authored by
Claude Opus 5
parent
fa6473a896
commit
18c77ace28
@@ -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(() => {});
|
||||
@@ -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();
|
||||
@@ -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(() => {});
|
||||
Reference in New Issue
Block a user