Files
dashcaddy/dashcaddy-api/__tests__/billing/stripe-client.test.js
T
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

235 lines
9.1 KiB
JavaScript

/**
* DC-055 + DC-057 billing/stripe-client tests.
*
* Strategy: inject a mock Stripe SDK via _setStripeSdk so no real network
* calls ever happen. Cover the key behaviors of the one-time payment flow:
*
* 1. Configuration validation — missing STRIPE_SECRET_KEY fails loudly with 503.
* 2. productId validation — unknown productId returns 400 INVALID_PRODUCT_ID.
* 3. Product not configured — Stripe Price ID env var unset returns 503.
* 4. Happy path — creates a session with mode:payment + correct price ID + URLs.
* 5. Stripe SDK errors — surface as 502 to the customer, not 500.
* 6. Metadata contract — emits metadata.productId that the bridge can read back.
* 7. payment_intent_data — also carries productId metadata for downstream consumers.
* 8. Catalog drives everything — _resolveProduct reads the catalog, not env.
*/
const stripeClient = require('../../src/billing/stripe-client');
const catalog = require('../../src/billing/catalog');
const REQUIRED_ENV = {
STRIPE_SECRET_KEY: '«redacted:sk_test_…»',
STRIPE_PRICE_PRO_30D: 'price_30d_test',
STRIPE_PRICE_PRO_90D: 'price_90d_test',
STRIPE_PRICE_PRO_180D: 'price_180d_test',
STRIPE_PRICE_PRO_365D: 'price_365d_test',
};
function setEnv(overrides = {}) {
const all = { ...REQUIRED_ENV, ...overrides };
for (const [k, v] of Object.entries(all)) {
process.env[k] = v;
}
}
function clearEnv() {
for (const k of Object.keys(REQUIRED_ENV)) delete process.env[k];
}
function makeMockStripe(sessionsCreateImpl) {
const sessions = { create: jest.fn().mockImplementation(sessionsCreateImpl) };
return jest.fn().mockReturnValue({ checkout: { sessions } });
}
describe('billing/stripe-client', () => {
afterEach(() => {
clearEnv();
stripeClient._setStripeSdk(null);
jest.restoreAllMocks();
});
test('throws STRIPE_NOT_CONFIGURED when STRIPE_SECRET_KEY is missing', async () => {
setEnv({ STRIPE_SECRET_KEY: '' });
await expect(
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
).rejects.toMatchObject({
code: 'STRIPE_NOT_CONFIGURED',
statusCode: 503,
missing: expect.arrayContaining(['STRIPE_SECRET_KEY']),
});
});
test('throws STRIPE_NOT_CONFIGURED when 30d product Stripe Price ID is missing', async () => {
setEnv({ STRIPE_PRICE_PRO_30D: '' });
await expect(
stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' })
).rejects.toMatchObject({
code: 'STRIPE_NOT_CONFIGURED',
missing: expect.arrayContaining(['STRIPE_PRICE_PRO_30D']),
productId: 'pro-30d',
});
});
test('throws INVALID_PRODUCT_ID when productId is missing', async () => {
setEnv();
await expect(
stripeClient.createCheckoutSession({ productId: '', origin: 'https://status.sami' })
).rejects.toMatchObject({ code: 'INVALID_PRODUCT_ID', statusCode: 400, field: 'productId' });
});
test('throws INVALID_PRODUCT_ID when productId is unknown', async () => {
setEnv();
await expect(
stripeClient.createCheckoutSession({ productId: 'pro-1000d', origin: 'https://status.sami' })
).rejects.toMatchObject({
code: 'INVALID_PRODUCT_ID',
statusCode: 400,
field: 'productId',
});
});
test('happy path: pro-30d creates session with mode=payment + correct params', async () => {
setEnv();
const mockSession = { id: 'cs_test_abc123', url: 'https://checkout.stripe.com/c/pay/cs_test_abc123' };
const mockStripe = makeMockStripe(async (params) => {
// DC-057: one-time payment, NOT subscription.
expect(params.mode).toBe('payment');
expect(params.line_items).toEqual([{ price: 'price_30d_test', quantity: 1 }]);
expect(params.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
expect(params.cancel_url).toBe('https://status.sami/pricing');
// The bridge reads this metadata back to map session → product → duration.
expect(params.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
// payment_intent_data.metadata mirrors it for downstream Stripe→bridge consumers.
expect(params.payment_intent_data).toBeDefined();
expect(params.payment_intent_data.metadata).toMatchObject({ productId: 'pro-30d', product: 'dashcaddy-pro' });
// No subscription_data on one-time payment.
expect(params.subscription_data).toBeUndefined();
return mockSession;
});
stripeClient._setStripeSdk(mockStripe);
const result = await stripeClient.createCheckoutSession({
productId: 'pro-30d',
origin: 'https://status.sami',
});
expect(result).toEqual({ id: 'cs_test_abc123', url: mockSession.url });
expect(mockStripe).toHaveBeenCalledWith('«redacted:sk_test_…»');
});
test('happy path: pro-365d uses 365d price ID', async () => {
setEnv();
const mockStripe = makeMockStripe(async (params) => {
expect(params.line_items[0].price).toBe('price_365d_test');
expect(params.metadata.productId).toBe('pro-365d');
return { id: 'cs_365_xyz', url: 'https://checkout.stripe.com/c/pay/cs_365_xyz' };
});
stripeClient._setStripeSdk(mockStripe);
const result = await stripeClient.createCheckoutSession({
productId: 'pro-365d',
origin: 'https://status.sami',
});
expect(result.id).toBe('cs_365_xyz');
});
test('forwards customerEmail when provided', async () => {
setEnv();
const mockStripe = makeMockStripe(async (params) => {
expect(params.customer_email).toBe('alice@example.com');
return { id: 'cs_emailed', url: 'https://checkout.stripe.com/c/pay/cs_emailed' };
});
stripeClient._setStripeSdk(mockStripe);
await stripeClient.createCheckoutSession({
productId: 'pro-90d',
customerEmail: 'alice@example.com',
origin: 'https://status.sami',
});
});
test('omits customer_email when not provided (no undefined leakage to Stripe)', async () => {
setEnv();
const mockStripe = makeMockStripe(async (params) => {
expect('customer_email' in params).toBe(false);
return { id: 'cs_no_email', url: 'https://checkout.stripe.com/c/pay/cs_no_email' };
});
stripeClient._setStripeSdk(mockStripe);
await stripeClient.createCheckoutSession({
productId: 'pro-30d',
origin: 'https://status.sami',
});
});
test('uses STRIPE_SUCCESS_URL override when set', async () => {
setEnv({ STRIPE_SUCCESS_URL: 'https://custom.example.com/thanks' });
const mockStripe = makeMockStripe(async (params) => {
expect(params.success_url).toBe('https://custom.example.com/thanks');
return { id: 'cs_custom', url: 'x' };
});
stripeClient._setStripeSdk(mockStripe);
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
});
test('uses STRIPE_CANCEL_URL override when set', async () => {
setEnv({ STRIPE_CANCEL_URL: 'https://custom.example.com/back' });
const mockStripe = makeMockStripe(async (params) => {
expect(params.cancel_url).toBe('https://custom.example.com/back');
return { id: 'cs_cancel', url: 'x' };
});
stripeClient._setStripeSdk(mockStripe);
await stripeClient.createCheckoutSession({ productId: 'pro-30d', origin: 'https://status.sami' });
});
test('works with relative origin (no host header)', async () => {
setEnv();
const mockStripe = makeMockStripe(async () => ({ id: 'x', url: 'x' }));
stripeClient._setStripeSdk(mockStripe);
const result = await stripeClient.createCheckoutSession({ productId: 'pro-30d' });
expect(result.id).toBe('x');
});
test('each catalog product drives a different price ID', async () => {
setEnv();
for (const product of catalog.PRODUCTS) {
const mockStripe = makeMockStripe(async (params) => {
expect(params.line_items[0].price).toBe(REQUIRED_ENV[product.priceEnv]);
expect(params.metadata.productId).toBe(product.id);
return { id: `cs_${product.id}`, url: 'x' };
});
stripeClient._setStripeSdk(mockStripe);
await stripeClient.createCheckoutSession({ productId: product.id, origin: 'https://status.sami' });
}
});
});
describe('billing/stripe-client — _resolveProduct unit', () => {
test('resolves known productId with configured price', () => {
setEnv();
const result = stripeClient._resolveProduct('pro-30d');
expect(result.product.id).toBe('pro-30d');
expect(result.priceId).toBe('price_30d_test');
});
test('returns INVALID_PRODUCT_ID error for unknown productId', () => {
setEnv();
expect(() => stripeClient._resolveProduct('pro-1000d')).toThrow();
try { stripeClient._resolveProduct('pro-1000d'); } catch (e) {
expect(e.code).toBe('INVALID_PRODUCT_ID');
expect(e.statusCode).toBe(400);
}
});
test('returns STRIPE_NOT_CONFIGURED error when product price is unset', () => {
setEnv({ STRIPE_PRICE_PRO_180D: '' });
try { stripeClient._resolveProduct('pro-180d'); } catch (e) {
expect(e.code).toBe('STRIPE_NOT_CONFIGURED');
expect(e.statusCode).toBe(503);
expect(e.missing).toContain('STRIPE_PRICE_PRO_180D');
}
});
});