mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 19:24:16 +00:00
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
161 lines
7.3 KiB
JavaScript
161 lines
7.3 KiB
JavaScript
// 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(() => {});
|