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.
173 lines
6.9 KiB
JavaScript
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['"]/);
|
|
});
|
|
});
|