Files
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

173 lines
6.9 KiB
JavaScript

/**
* DC-057 billing lookup endpoint tests.
*
* Tests the GET /api/v1/billing/lookup/:sessionId route handler with a
* real fulfillment store on disk. Covers:
*
* - 404 for unknown sessionId
* - processing state (record exists, no code yet)
* - pending_email state — license persisted, email failed (SMTP recovery path)
* - delivered state
* - 404 past the 24h TTL
* - Cache-Control: no-store on all responses
* - Parameterized PUBLIC_ROUTES entry exists for this path
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const express = require('express');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-lookup-'));
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
const billingRoutes = require('../../routes/billing');
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
function makeApp() {
const app = express();
// Mock asyncHandler that calls the inner fn synchronously.
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
const router = billingRoutes({ asyncHandler });
app.use('/api/v1/billing', router);
return app;
}
function seedRecord(sessionId, overrides = {}) {
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
// Plant a record directly via the mutation API.
return store.claim({
eventId: overrides.eventId || 'evt_seed',
sessionId,
productId: overrides.productId || 'pro-30d',
durationDays: overrides.durationDays || 30,
email: overrides.email || 'alice@example.com',
});
}
describe('GET /api/v1/billing/lookup/:sessionId', () => {
let app;
beforeAll(() => {
app = makeApp();
});
function get(sessionId) {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const port = server.address().port;
const http = require('http');
http.get(`http://127.0.0.1:${port}/api/v1/billing/lookup/${encodeURIComponent(sessionId)}`, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => {
server.close();
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
});
}).on('error', reject);
});
});
}
test('returns 404 for unknown sessionId', async () => {
const res = await get('cs_unknown_session');
expect(res.status).toBe(404);
expect(res.body).toMatchObject({ success: false });
expect(res.headers['cache-control']).toBe('no-store');
});
test('returns 400 for invalid sessionId (too long)', async () => {
const longId = 'x'.repeat(300);
const res = await get(longId);
expect(res.status).toBe(400);
expect(res.headers['cache-control']).toBe('no-store');
});
test('returns processing state when record has no code yet', async () => {
const sessionId = `cs_proc_${crypto.randomBytes(4).toString('hex')}`;
await seedRecord(sessionId);
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toMatchObject({ status: 'processing', durationDays: 30, productId: 'pro-30d' });
expect(res.headers['cache-control']).toBe('no-store');
});
test('returns pending_email state with the persisted code (SMTP recovery)', async () => {
const sessionId = `cs_pending_${crypto.randomBytes(4).toString('hex')}`;
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
await store.claim({ eventId: 'evt_1', sessionId, productId: 'pro-90d', durationDays: 90, email: 'a@b.c' });
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-TEST-CODE-90D', codeId: 'cid_1' });
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_1', error: 'smtp-down' });
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
status: 'pending_email',
durationDays: 90,
productId: 'pro-90d',
code: 'DC-TEST-CODE-90D',
codeId: 'cid_1',
});
expect(res.body.data.lastError).toMatch(/smtp-down/);
});
test('returns delivered state with the code + deliveredVia', async () => {
const sessionId = `cs_delivered_${crypto.randomBytes(4).toString('hex')}`;
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
await store.claim({ eventId: 'evt_2', sessionId, productId: 'pro-365d', durationDays: 365, email: 'a@b.c' });
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-TEST-CODE-365D', codeId: 'cid_2' });
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
await store.markDelivered({ sessionId, ownerToken: 'evt_2', deliveredVia: 'smtp' });
const res = await get(sessionId);
expect(res.status).toBe(200);
expect(res.body.data).toMatchObject({
status: 'delivered',
durationDays: 365,
productId: 'pro-365d',
code: 'DC-TEST-CODE-365D',
codeId: 'cid_2',
deliveredVia: 'smtp',
});
});
test('returns 404 past the 24h TTL', async () => {
const sessionId = `cs_old_${crypto.randomBytes(4).toString('hex')}`;
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
await store.claim({ eventId: 'evt_old', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
await store.saveLicense({ eventId: 'evt_old', sessionId, code: 'DC-OLD', codeId: 'cid_old' });
await store.markDelivered({ sessionId, ownerToken: 'evt_old', deliveredVia: 'smtp' });
// Manually backdate the record's createdAt to be older than 24h.
const fs = require('fs');
const file = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE;
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
const r = state.bySessionId[sessionId];
r.createdAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
fs.writeFileSync(file, JSON.stringify(state, null, 2));
const res = await get(sessionId);
expect(res.status).toBe(404);
});
});
describe('PUBLIC_ROUTES + CSRF allowlist for billing/lookup', () => {
const fs = require('fs');
test('PUBLIC_ROUTES includes /api/v1/billing/lookup/:sessionId', () => {
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'utilities', 'middleware.js'), 'utf8');
expect(content).toMatch(/path:\s*['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
});
test('CSRF excludedPaths includes /api/v1/billing/lookup/:sessionId', () => {
const content = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'security', 'csrf-protection.js'), 'utf8');
expect(content).toMatch(/['"]\/api\/v1\/billing\/lookup\/:sessionId['"]/);
});
});