Canonical product catalog at src/billing/catalog.js shared by Stripe Checkout client (src/billing/stripe-client.js), webhook bridge (scripts/stripe-license-bridge.js), and pricing page (status/pricing/index.html). One-time payment keyed by productId at $20/$50/$70/$99 — no more monthly/annual subscription drift. Bridge resolves duration via metadata.productId (single contract), requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200), handles async_payment_succeeded for ACH/SEPA delayed-payment flow. License persisted to fulfillment-store BEFORE email — SMTP failure path serves the persisted code via the new /api/v1/billing/lookup/:sessionId endpoint (the documented customer recovery path). Layer-1 (event-id) + layer-2 (session-id) idempotency prevent duplicate issuance. Checkout return URLs derived from STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) — closes host-header-poisoning + session-ID-leak attack class. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Test files: - stripe-license-bridge.test.js (24 tests) - billing-lookup.test.js (8 tests, HTTP-level) - bridge-lookup-http.test.js (5 tests, uses exported createServer) - pricing-page-catalog.test.js (9 tests, per-tier consistency) - checkout-origin.test.js (6 tests, host injection rejection) - stripe-client.test.js (rewrite for productId + mode:payment) Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted (under ESLint complexity=20 cap). New createServer()/createRequestHandler() factories guarded by require.main === module. Removed 3 stale test files from the rolled-back DC-055 attempt.
135 lines
5.5 KiB
JavaScript
135 lines
5.5 KiB
JavaScript
/**
|
|
* DC-057 pricing-page catalog consistency test.
|
|
*
|
|
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
|
* IDs, prices, and labels. This test asserts that those hard-coded values
|
|
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
|
* between the two sources.
|
|
*
|
|
* If a new tier is added to the catalog, this test will fail until the
|
|
* pricing page is updated. If the pricing page is updated, the catalog
|
|
* must change in lockstep (or this test fails the other way).
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const catalog = require('../../src/billing/catalog');
|
|
|
|
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
|
|
|
function extractTiersFromPage(html) {
|
|
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
|
// pull out the dollar amount in the `<div class="price">` element and
|
|
// the durationDays from the "N-day Pro license" string. The regex is
|
|
// anchored on the tier-class open + the matching buy-btn close so we
|
|
// capture the full body of each tier card regardless of how many inner
|
|
// divs it has.
|
|
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
|
const tierBlocks = [...html.matchAll(tierRe)];
|
|
return tierBlocks.map(([, productId, body]) => {
|
|
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
|
const durMatch = body.match(/(\d+)-day Pro license/);
|
|
return {
|
|
productId,
|
|
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
|
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract the HTML body for one specific tier (from open div through the
|
|
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
|
*/
|
|
function extractTierBody(html, productId) {
|
|
const re = new RegExp(
|
|
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
|
'i'
|
|
);
|
|
const m = html.match(re);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
|
let html;
|
|
let pageTiers;
|
|
|
|
beforeAll(() => {
|
|
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
|
pageTiers = extractTiersFromPage(html);
|
|
});
|
|
|
|
test('pricing page exists and is readable', () => {
|
|
expect(html.length).toBeGreaterThan(1000);
|
|
expect(pageTiers.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('every catalog product is rendered on the pricing page', () => {
|
|
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
|
const pageIds = pageTiers.map((t) => t.productId).sort();
|
|
expect(pageIds).toEqual(catalogIds);
|
|
});
|
|
|
|
test('every pricing-page productId appears in the catalog', () => {
|
|
for (const tier of pageTiers) {
|
|
const product = catalog.getProduct(tier.productId);
|
|
expect(product).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
test('pricing-page dollar amounts match catalog amountCents', () => {
|
|
for (const tier of pageTiers) {
|
|
const product = catalog.getProduct(tier.productId);
|
|
const expectedDollars = product.amountCents / 100;
|
|
expect(tier.priceDollars).toBe(expectedDollars);
|
|
}
|
|
});
|
|
|
|
test('pricing-page duration strings match catalog durationDays', () => {
|
|
for (const tier of pageTiers) {
|
|
const product = catalog.getProduct(tier.productId);
|
|
expect(tier.durationDays).toBe(product.durationDays);
|
|
}
|
|
});
|
|
|
|
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
|
// Per-tier priceLabel assertion: each tier card must include its
|
|
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
|
// because the assertion checks the tier's own HTML body, not the page.
|
|
for (const tier of pageTiers) {
|
|
const product = catalog.getProduct(tier.productId);
|
|
const body = extractTierBody(html, tier.productId);
|
|
expect(body).not.toBeNull();
|
|
// The priceLabel appears in the price div of THIS tier only,
|
|
// immediately followed by the closing </div> + the duration block.
|
|
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
|
expect(body).toMatch(labelRegex);
|
|
}
|
|
});
|
|
|
|
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
|
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
|
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
|
// would contradict the spec.
|
|
expect(html).not.toMatch(/period-monthly|period-annual/);
|
|
expect(html).not.toMatch(/Subscribe to Pro/);
|
|
});
|
|
|
|
test('pricing page references the success-page endpoint', () => {
|
|
// The success URL is constructed server-side in stripe-client.js
|
|
// (${origin}/billing/success?session_id=...). The pricing page itself
|
|
// doesn't need to embed it — but the FOOTER must reference it so the
|
|
// customer knows where to go after Stripe redirects.
|
|
expect(html.toLowerCase()).toContain('after payment');
|
|
expect(html).toContain('/admin/license');
|
|
expect(html).toContain('/api/v1/billing/checkout');
|
|
});
|
|
|
|
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
|
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
|
const successHtml = fs.readFileSync(successPath, 'utf8');
|
|
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
|
expect(successHtml.length).toBeGreaterThan(1000);
|
|
});
|
|
});
|