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.
159 lines
6.3 KiB
JavaScript
159 lines
6.3 KiB
JavaScript
/**
|
|
* DC-057 bridge HTTP /lookup/:sessionId endpoint tests.
|
|
*
|
|
* Tests the bridge's own GET /lookup/:sessionId endpoint (separate from
|
|
* the API route). The bridge endpoint is for out-of-band operator use —
|
|
* the production customer lookup goes through routes/billing.js (covered
|
|
* by billing-lookup.test.js). But the bridge must still serve /lookup/*
|
|
* correctly for operator workflows and incident recovery.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-http-'));
|
|
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
|
|
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
|
|
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
|
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
|
delete process.env.SMTP_HOST;
|
|
delete process.env.SMTP_FROM;
|
|
|
|
jest.mock('../../license-keygen', () => {
|
|
// Use the built-in Date + Math.random instead of crypto so the jest.mock
|
|
// factory stays in scope (jest.mock factory bodies cannot reference
|
|
// outer-scope identifiers like `crypto`).
|
|
const mockRandom = () => Math.random().toString(16).slice(2, 10).toUpperCase();
|
|
let mockCounter = 0;
|
|
return {
|
|
VALID_DURATIONS: [30, 90, 180, 365],
|
|
loadSecret: () => 'mock-secret',
|
|
generateCodes: jest.fn(({ durationDays, count }) => {
|
|
const codes = [];
|
|
for (let i = 0; i < count; i++) {
|
|
codes.push({
|
|
code: `DC-TEST-${durationDays}D-${mockRandom()}`,
|
|
codeId: `cid_${Date.now()}_${i}_${++mockCounter}`,
|
|
});
|
|
}
|
|
return codes;
|
|
}),
|
|
};
|
|
});
|
|
jest.mock('nodemailer', () => ({
|
|
createTransport: () => ({ sendMail: jest.fn() }),
|
|
}));
|
|
|
|
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
|
const bridge = require('../../scripts/stripe-license-bridge');
|
|
|
|
let server;
|
|
let port;
|
|
|
|
beforeAll((done) => {
|
|
// Use the bridge's own createServer() factory so the test exercises the
|
|
// SAME request dispatcher the production server uses (no duplicated
|
|
// route decoding / status mapping in test code).
|
|
server = bridge.createServer();
|
|
server.listen(0, () => {
|
|
port = server.address().port;
|
|
done();
|
|
});
|
|
});
|
|
|
|
afterAll((done) => {
|
|
server.close(done);
|
|
});
|
|
|
|
function get(path) {
|
|
return new Promise((resolve, reject) => {
|
|
http.get(`http://127.0.0.1:${port}${path}`, (res) => {
|
|
let body = '';
|
|
res.on('data', (c) => { body += c; });
|
|
res.on('end', () => {
|
|
resolve({ status: res.statusCode, headers: res.headers, body: body ? JSON.parse(body) : null });
|
|
});
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
describe('bridge GET /lookup/:sessionId', () => {
|
|
test('returns 404 for unknown sessionId', async () => {
|
|
const res = await get('/lookup/cs_unknown_session');
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ status: 'not_found' });
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
});
|
|
|
|
test('returns 400 for malformed percent-encoded sessionId', async () => {
|
|
// %ZZ is not valid hex.
|
|
const res = await get('/lookup/cs_%ZZ_bad');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.reason).toBe('invalid-session-id');
|
|
});
|
|
|
|
test('returns delivered state with code + deliveredVia for planted record', async () => {
|
|
const sessionId = `cs_test_delivered_${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-30d', durationDays: 30, email: 'a@b.c' });
|
|
await store.saveLicense({ eventId: 'evt_1', sessionId, code: 'DC-X', codeId: 'cid_1' });
|
|
await store.claimDelivery({ sessionId, ownerToken: 'evt_1' });
|
|
await store.markDelivered({ sessionId, ownerToken: 'evt_1', deliveredVia: 'smtp' });
|
|
|
|
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: 'delivered',
|
|
durationDays: 30,
|
|
productId: 'pro-30d',
|
|
code: 'DC-X',
|
|
codeId: 'cid_1',
|
|
deliveredVia: 'smtp',
|
|
});
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
});
|
|
|
|
test('returns pending_email state (SMTP recovery)', async () => {
|
|
const sessionId = `cs_test_pending_${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-90d', durationDays: 90, email: 'a@b.c' });
|
|
await store.saveLicense({ eventId: 'evt_2', sessionId, code: 'DC-Y', codeId: 'cid_2' });
|
|
await store.claimDelivery({ sessionId, ownerToken: 'evt_2' });
|
|
await store.markDeliveryFailed({ sessionId, ownerToken: 'evt_2', error: 'smtp-down' });
|
|
|
|
const res = await get(`/lookup/${encodeURIComponent(sessionId)}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
status: 'pending_email',
|
|
durationDays: 90,
|
|
productId: 'pro-90d',
|
|
code: 'DC-Y',
|
|
codeId: 'cid_2',
|
|
});
|
|
expect(res.body.lastError).toMatch(/smtp-down/);
|
|
});
|
|
|
|
test('returns 404 past the 24h TTL', async () => {
|
|
const sessionId = `cs_test_old_${crypto.randomBytes(4).toString('hex')}`;
|
|
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
|
await store.claim({ eventId: 'evt_3', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
|
await store.saveLicense({ eventId: 'evt_3', sessionId, code: 'DC-OLD', codeId: 'cid_3' });
|
|
await store.claimDelivery({ sessionId, ownerToken: 'evt_3' });
|
|
await store.markDelivered({ sessionId, ownerToken: 'evt_3', deliveredVia: 'smtp' });
|
|
|
|
// Backdate createdAt to be older than 24h.
|
|
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(`/lookup/${encodeURIComponent(sessionId)}`);
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ status: 'expired' });
|
|
});
|
|
});
|