Files
dashcaddy/dashcaddy-api/__tests__/billing/bridge-lookup-http.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

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