Files
dashcaddy/dashcaddy-api/__tests__/billing/pricing-page-catalog.test.js
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
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.
2026-08-04 14:18:49 -07:00

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);
});
});