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.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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['"]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* DC-057 billing checkout origin resolution tests.
|
||||
*
|
||||
* The checkout endpoint embeds the success_url (and cancel_url) into the
|
||||
* Stripe Checkout Session. These URLs are what Stripe redirects the
|
||||
* customer's browser to after payment. They MUST be derived only from
|
||||
* trusted sources — otherwise a header-injection attacker could redirect
|
||||
* customers to their own origin and capture the session_id, which is
|
||||
* the bearer token for /api/v1/billing/lookup/:sessionId (and that
|
||||
* endpoint serves the customer's license code on success).
|
||||
*
|
||||
* The origin is resolved in this priority order:
|
||||
* 1. STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)
|
||||
* 2. Request Host header, but ONLY when the host is in
|
||||
* STRIPE_ALLOWED_HOSTS (operator-declared allowlist)
|
||||
* 3. undefined (Stripe falls back to its own defaults)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
// Save the fulfillment-store path so the route module captures the same
|
||||
// path the route would in production. (Tests below exercise the
|
||||
// stripe-client, not the fulfillment store, so the lookup endpoint can
|
||||
// share the same file.)
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-origin-'));
|
||||
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
|
||||
|
||||
const billingRoutes = require('../../routes/billing');
|
||||
const stripeClient = require('../../src/billing/stripe-client');
|
||||
|
||||
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];
|
||||
delete process.env.STRIPE_PUBLIC_ORIGIN;
|
||||
delete process.env.STRIPE_ALLOWED_HOSTS;
|
||||
delete process.env.NODE_ENV;
|
||||
}
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(require('express').json());
|
||||
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 postCheckout(req, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = req.listen(0, () => {
|
||||
const port = server.address().port;
|
||||
const data = JSON.stringify(body);
|
||||
const headerLines = Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers })
|
||||
.map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
||||
const req2 = http.request({
|
||||
hostname: '127.0.0.1', port, path: '/api/v1/billing/checkout', method: 'POST',
|
||||
headers: Object.fromEntries(Object.entries({ 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), ...headers }).map(([k, v]) => [k.toLowerCase(), v])),
|
||||
}, (res) => {
|
||||
let buf = '';
|
||||
res.on('data', (c) => { buf += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: buf ? JSON.parse(buf) : null });
|
||||
});
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.write(data);
|
||||
req2.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/v1/billing/checkout — origin resolution (DC-057 security)', () => {
|
||||
let app;
|
||||
beforeAll(() => {
|
||||
app = makeApp();
|
||||
setEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearEnv();
|
||||
setEnv();
|
||||
stripeClient._setStripeSdk(null);
|
||||
});
|
||||
|
||||
test('uses STRIPE_PUBLIC_ORIGIN env var (canonical deployment shape)', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'https://status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_1', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_1' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
// The captured Stripe params must include the success_url + cancel_url
|
||||
// built from the operator-declared origin — NOT from the request's Host
|
||||
// header. This is the canonical deployment shape.
|
||||
expect(capturedParams.success_url).toBe('https://status.sami/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
expect(capturedParams.cancel_url).toBe('https://status.sami/pricing');
|
||||
});
|
||||
|
||||
test('rejects Host header injection when STRIPE_ALLOWED_HOSTS is empty', async () => {
|
||||
// Attacker sets X-Forwarded-Host: evil.com. The request reaches our
|
||||
// endpoint. Without STRIPE_PUBLIC_ORIGIN + without STRIPE_ALLOWED_HOSTS,
|
||||
// the origin must be undefined — we MUST NOT trust the attacker header.
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: '' });
|
||||
const mockSession = { id: 'cs_test_orig_2', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_2' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin must be undefined when allowlist is empty — the Stripe SDK
|
||||
// is called with undefined origin and the stripe-client falls back to
|
||||
// relative '/billing/success' which is safe (no host poisoning).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
});
|
||||
|
||||
test('accepts Host header when STRIPE_ALLOWED_HOSTS includes it', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami,dashcaddy.net' });
|
||||
const mockSession = { id: 'cs_test_orig_3', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_3' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'status.sami',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedParams.success_url).toContain('status.sami');
|
||||
expect(capturedParams.success_url).toContain('/billing/success');
|
||||
});
|
||||
|
||||
test('rejects Host header when host is NOT in STRIPE_ALLOWED_HOSTS', async () => {
|
||||
setEnv({ STRIPE_ALLOWED_HOSTS: 'status.sami' });
|
||||
const mockSession = { id: 'cs_test_orig_4', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_4' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' }, {
|
||||
'X-Forwarded-Host': 'evil.com',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// origin is undefined → relative /billing/success URL (safe).
|
||||
expect(capturedParams.success_url).toMatch(/^\/billing\/success/);
|
||||
expect(capturedParams.success_url).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
test('rejects javascript: scheme injection via STRIPE_PUBLIC_ORIGIN', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'javascript:alert(1)' });
|
||||
const mockSession = { id: 'cs_test_orig_5', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_5' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// javascript: scheme is rejected; origin falls through to header-based
|
||||
// resolution, which is also gated by STRIPE_ALLOWED_HOSTS (empty here).
|
||||
expect(capturedParams.success_url).not.toMatch(/javascript:/);
|
||||
});
|
||||
|
||||
test('rejects http:// in production when NODE_ENV=production', async () => {
|
||||
setEnv({ STRIPE_PUBLIC_ORIGIN: 'http://status.sami', NODE_ENV: 'production' });
|
||||
const mockSession = { id: 'cs_test_orig_6', url: 'https://checkout.stripe.com/c/pay/cs_test_orig_6' };
|
||||
let capturedParams;
|
||||
stripeClient._setStripeSdk(jest.fn().mockReturnValue({
|
||||
checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => {
|
||||
capturedParams = params;
|
||||
return mockSession;
|
||||
}) } },
|
||||
}));
|
||||
|
||||
const res = await postCheckout(app, { productId: 'pro-30d' });
|
||||
expect(res.status).toBe(200);
|
||||
// http:// rejected in production; origin falls back to undefined.
|
||||
expect(capturedParams.success_url).not.toMatch(/^http:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* DC-057 pricing-page catalog consistency test.
|
||||
*
|
||||
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
||||
* IDs, prices, and labels. This test asserts that those hard-coded values
|
||||
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
||||
* between the two sources.
|
||||
*
|
||||
* If a new tier is added to the catalog, this test will fail until the
|
||||
* pricing page is updated. If the pricing page is updated, the catalog
|
||||
* must change in lockstep (or this test fails the other way).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
||||
|
||||
function extractTiersFromPage(html) {
|
||||
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
||||
// pull out the dollar amount in the `<div class="price">` element and
|
||||
// the durationDays from the "N-day Pro license" string. The regex is
|
||||
// anchored on the tier-class open + the matching buy-btn close so we
|
||||
// capture the full body of each tier card regardless of how many inner
|
||||
// divs it has.
|
||||
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
||||
const tierBlocks = [...html.matchAll(tierRe)];
|
||||
return tierBlocks.map(([, productId, body]) => {
|
||||
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
||||
const durMatch = body.match(/(\d+)-day Pro license/);
|
||||
return {
|
||||
productId,
|
||||
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
||||
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML body for one specific tier (from open div through the
|
||||
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
||||
*/
|
||||
function extractTierBody(html, productId) {
|
||||
const re = new RegExp(
|
||||
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
||||
'i'
|
||||
);
|
||||
const m = html.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
||||
let html;
|
||||
let pageTiers;
|
||||
|
||||
beforeAll(() => {
|
||||
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
||||
pageTiers = extractTiersFromPage(html);
|
||||
});
|
||||
|
||||
test('pricing page exists and is readable', () => {
|
||||
expect(html.length).toBeGreaterThan(1000);
|
||||
expect(pageTiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every catalog product is rendered on the pricing page', () => {
|
||||
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
||||
const pageIds = pageTiers.map((t) => t.productId).sort();
|
||||
expect(pageIds).toEqual(catalogIds);
|
||||
});
|
||||
|
||||
test('every pricing-page productId appears in the catalog', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(product).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page dollar amounts match catalog amountCents', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const expectedDollars = product.amountCents / 100;
|
||||
expect(tier.priceDollars).toBe(expectedDollars);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page duration strings match catalog durationDays', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(tier.durationDays).toBe(product.durationDays);
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
||||
// Per-tier priceLabel assertion: each tier card must include its
|
||||
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
||||
// because the assertion checks the tier's own HTML body, not the page.
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const body = extractTierBody(html, tier.productId);
|
||||
expect(body).not.toBeNull();
|
||||
// The priceLabel appears in the price div of THIS tier only,
|
||||
// immediately followed by the closing </div> + the duration block.
|
||||
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
||||
expect(body).toMatch(labelRegex);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
||||
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
||||
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
||||
// would contradict the spec.
|
||||
expect(html).not.toMatch(/period-monthly|period-annual/);
|
||||
expect(html).not.toMatch(/Subscribe to Pro/);
|
||||
});
|
||||
|
||||
test('pricing page references the success-page endpoint', () => {
|
||||
// The success URL is constructed server-side in stripe-client.js
|
||||
// (${origin}/billing/success?session_id=...). The pricing page itself
|
||||
// doesn't need to embed it — but the FOOTER must reference it so the
|
||||
// customer knows where to go after Stripe redirects.
|
||||
expect(html.toLowerCase()).toContain('after payment');
|
||||
expect(html).toContain('/admin/license');
|
||||
expect(html).toContain('/api/v1/billing/checkout');
|
||||
});
|
||||
|
||||
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
||||
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
||||
const successHtml = fs.readFileSync(successPath, 'utf8');
|
||||
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
||||
expect(successHtml.length).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* DC-054 + DC-057 stripe-license-bridge tests.
|
||||
*
|
||||
* Strategy: no live network, no live Stripe SDK. We use `jest.mock` to
|
||||
* substitute license-keygen + nodemailer before the bridge loads, drive
|
||||
* handleWebhook() with crafted raw bodies + signatures.
|
||||
*
|
||||
* Coverage:
|
||||
* - Signature validation (pass / missing / wrong / out-of-tolerance)
|
||||
* - JSON parse failure
|
||||
* - Duplicate event-id → 200 idempotent
|
||||
* - Two different events for the SAME session → single license (layer-2 idempotency)
|
||||
* - License persisted BEFORE email (crash-safety)
|
||||
* - Email failure → markDeliveryFailed → returns 500 → customer can retrieve via lookup
|
||||
* - Retry from pending_email delivers the SAME code
|
||||
* - Concurrent lease (busy) returns 409
|
||||
* - Catalog resolution: missing productId → 400; unknown productId → 400;
|
||||
* product-not-configured → 400
|
||||
* - Lookup endpoint: not_found, processing, pending_email, delivered, expired TTL
|
||||
* - Layer-1 + Layer-2 idempotency under Stripe retry
|
||||
*/
|
||||
|
||||
// jest.mock must be hoisted before any require.
|
||||
jest.mock('../../license-keygen', () => {
|
||||
const crypto = require('crypto');
|
||||
let calls = 0;
|
||||
return {
|
||||
VALID_DURATIONS: [30, 90, 180, 365],
|
||||
loadSecret: () => 'mock-license-secret-' + crypto.randomBytes(8).toString('hex'),
|
||||
generateCodes: jest.fn(({ durationDays, count }) => {
|
||||
calls++;
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
codes.push({
|
||||
code: `DC-TEST-${durationDays}D-${crypto.randomBytes(4).toString('hex').toUpperCase()}`,
|
||||
codeId: `codeid_${Date.now()}_${i}_${calls}`,
|
||||
});
|
||||
}
|
||||
return codes;
|
||||
}),
|
||||
__resetGenerateCalls() { calls = 0; },
|
||||
__getGenerateCalls() { return calls; },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(() => ({
|
||||
sendMail: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Set up isolated tmp dirs BEFORE requiring the bridge (it captures paths at require time).
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-'));
|
||||
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');
|
||||
// Use a unique webhook secret so tests don't pollute each other.
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
|
||||
// Configure all Stripe Prices so catalog.getConfiguredProducts() returns them.
|
||||
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_test';
|
||||
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_test';
|
||||
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_test';
|
||||
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_test';
|
||||
// Disable SMTP so the bridge falls back to dev-console unless a test
|
||||
// explicitly injects nodemailer.
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
|
||||
const licenseKeygenMock = require('../../license-keygen');
|
||||
const nodemailerMock = require('nodemailer');
|
||||
const bridge = require('../../scripts/stripe-license-bridge');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_FROM;
|
||||
licenseKeygenMock.__resetGenerateCalls();
|
||||
// Reset nodemailer.sendMail mock implementations between tests.
|
||||
nodemailerMock.createTransport.mockClear();
|
||||
});
|
||||
|
||||
// Helper: build a signed Stripe webhook payload.
|
||||
function buildSignedPayload(body, opts = {}) {
|
||||
const secret = opts.secret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
const ts = opts.timestamp || Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(body));
|
||||
const sig = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const header = `t=${ts},v1=${sig}`;
|
||||
return { rawBody, signatureHeader: header };
|
||||
}
|
||||
|
||||
function buildSessionEvent({ productId = 'pro-30d', sessionId, customerEmail = 'alice@example.com',
|
||||
eventId, lineItems, paymentStatus = 'paid' }) {
|
||||
const product = catalog.getProduct(productId);
|
||||
// For tests of "unknown productId" the catalog.getProduct returns null —
|
||||
// we still build a valid event so the bridge can return its own 400.
|
||||
const priceId = product ? catalog.getConfiguredPrice(product) : 'price_unconfigured';
|
||||
return {
|
||||
id: eventId || `evt_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId || `cs_test_${crypto.randomBytes(6).toString('hex')}`,
|
||||
customer_email: customerEmail,
|
||||
customer_details: { email: customerEmail },
|
||||
payment_status: paymentStatus,
|
||||
amount_total: product ? product.amountCents : 0,
|
||||
currency: 'usd',
|
||||
metadata: { productId, product: 'dashcaddy-pro' },
|
||||
line_items: { data: lineItems || [{ price: { id: priceId } }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function injectSmtp(impl) {
|
||||
nodemailerMock.createTransport.mockImplementation(() => ({
|
||||
sendMail: jest.fn().mockImplementation(impl),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('stripe-license-bridge signature verification', () => {
|
||||
test('rejects missing signature header', async () => {
|
||||
const { rawBody } = buildSignedPayload({ id: 'evt_1', type: 'x' });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: '' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-missing-signature');
|
||||
});
|
||||
|
||||
test('rejects wrong signature', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const rawBody = Buffer.from(JSON.stringify(event));
|
||||
const sig = crypto.createHmac('sha256', 'wrong').update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toMatch(/^signature-/);
|
||||
});
|
||||
|
||||
test('rejects out-of-tolerance timestamp', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const oldTs = Math.floor(Date.now() / 1000) - 3600; // 1h ago, > 300s tolerance
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event, { timestamp: oldTs });
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('signature-timestamp-out-of-tolerance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge event parsing', () => {
|
||||
test('rejects invalid JSON', async () => {
|
||||
const rawBody = Buffer.from('not json');
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-json');
|
||||
});
|
||||
|
||||
test('rejects event without id', async () => {
|
||||
const event = { type: 'checkout.session.completed', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('invalid-event');
|
||||
});
|
||||
|
||||
test('acks unknown event types with 200 (so Stripe stops retrying)', async () => {
|
||||
const event = { id: 'evt_unknown', type: 'customer.created', data: { object: {} } };
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.reason).toBe('ignored-event-type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge catalog resolution', () => {
|
||||
test('rejects session without productId metadata', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.metadata.productId;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-productId');
|
||||
});
|
||||
|
||||
test('rejects unknown productId', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-1000d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('unknown-productId');
|
||||
});
|
||||
|
||||
test('rejects when product Stripe Price is unconfigured', async () => {
|
||||
const productId = 'pro-30d';
|
||||
const saved = process.env.STRIPE_PRICE_PRO_30D;
|
||||
delete process.env.STRIPE_PRICE_PRO_30D;
|
||||
try {
|
||||
const event = buildSessionEvent({ productId });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('product-not-configured');
|
||||
} finally {
|
||||
process.env.STRIPE_PRICE_PRO_30D = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects when customer email is missing', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', customerEmail: '' });
|
||||
delete event.data.object.customer_email;
|
||||
delete event.data.object.customer_details.email;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.body.reason).toBe('missing-customer-email');
|
||||
});
|
||||
|
||||
test('accepts sessions without expanded line_items (Stripe webhook default)', async () => {
|
||||
// DC-057 acceptance: Stripe does NOT expand line_items in webhooks by
|
||||
// default — the bridge must accept the canonical metadata.productId
|
||||
// even when line_items is absent. (Price verification, when added,
|
||||
// should be an optional belt-and-suspenders via a separate API call,
|
||||
// not a hard requirement.)
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
delete event.data.object.line_items;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-30d');
|
||||
expect(result.body.durationDays).toBe(30);
|
||||
});
|
||||
|
||||
test('rejects unpaid sessions (no license until payment clears)', async () => {
|
||||
// DC-057: a checkout.session.completed event with payment_status='unpaid'
|
||||
// arrives when the customer closes the browser mid-checkout or for
|
||||
// delayed-payment methods (ACH/SEPA) before they clear. The bridge
|
||||
// MUST ack 200 (so Stripe stops retrying) but MUST NOT generate a
|
||||
// license. The async_payment_succeeded event will fire later.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'unpaid' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-unpaid');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects no_payment_required sessions (DashCaddy does not sell free products)', async () => {
|
||||
// 'no_payment_required' is a Stripe-internal edge case for free
|
||||
// sessions. DashCaddy has no $0 product, so reject explicitly.
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'no_payment_required' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-no_payment_required');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects sessions with missing payment_status', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: '' });
|
||||
delete event.data.object.payment_status;
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('payment-not-confirmed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
|
||||
test('fulfills async_payment_succeeded events for delayed-payment methods', async () => {
|
||||
// ACH/SEPA: Stripe first sends checkout.session.completed (unpaid),
|
||||
// then async_payment_succeeded (paid) when the bank clears. The
|
||||
// bridge generates the license on the second event.
|
||||
const sessionId = `cs_test_ach_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const event = {
|
||||
id: `evt_ach_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: sessionId,
|
||||
customer_email: 'alice@example.com',
|
||||
customer_details: { email: 'alice@example.com' },
|
||||
payment_status: 'paid',
|
||||
amount_total: 5000,
|
||||
currency: 'usd',
|
||||
metadata: { productId: 'pro-90d', product: 'dashcaddy-pro' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
});
|
||||
|
||||
test('acks async_payment_failed events without generating a license', async () => {
|
||||
const event = {
|
||||
id: `evt_ach_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
type: 'checkout.session.async_payment_failed',
|
||||
data: {
|
||||
object: {
|
||||
id: `cs_test_fail_${crypto.randomBytes(6).toString('hex')}`,
|
||||
payment_status: 'unpaid',
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(false);
|
||||
expect(result.body.reason).toBe('async-payment-failed');
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge happy path', () => {
|
||||
test('generates + persists + delivers license (dev-console SMTP fallback)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-90d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.productId).toBe('pro-90d');
|
||||
expect(result.body.durationDays).toBe(90);
|
||||
expect(result.body.codeId).toBeTruthy();
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Fulfillment record exists.
|
||||
const record = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE })
|
||||
.readBySession(event.data.object.id);
|
||||
expect(record.status).toBe('delivered');
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.codeId).toBe(result.body.codeId);
|
||||
expect(record.deliveredVia).toBe('dev-console');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge idempotency', () => {
|
||||
test('duplicate eventId (Stripe retry) returns 200 without regenerating', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.delivered).toBe(true);
|
||||
|
||||
const second = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.deduplicated).toBe(true);
|
||||
// generateCodes called exactly once across both deliveries.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('two events for the same session reuse the same license (layer-2 idempotency)', async () => {
|
||||
const sessionId = `cs_test_shared_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
|
||||
expect(rA.status).toBe(200);
|
||||
expect(rA.body.delivered).toBe(true);
|
||||
// Second event hits layer-1 idempotency by eventId — different eventId,
|
||||
// so falls through to layer-2 by sessionId; sees existing delivered record.
|
||||
expect(rB.status).toBe(200);
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
expect(rB.body.codeId).toBe(rA.body.codeId); // SAME license code
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // only one code generated
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge SMTP failure recovery (DC-057 acceptance)', () => {
|
||||
beforeEach(() => {
|
||||
// Inject SMTP BEFORE each test so SMTP_HOST is set when deliverCode runs.
|
||||
injectSmtp(async () => { throw new Error('smtp-down'); });
|
||||
process.env.SMTP_HOST = 'smtp.example.com';
|
||||
process.env.SMTP_FROM = 'noreply@example.com';
|
||||
});
|
||||
|
||||
test('SMTP failure persists license, returns 500, but customer can retrieve via lookup', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-180d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.body.reason).toBe('email-failed');
|
||||
|
||||
// License IS persisted (the documented SMTP-failure recovery path).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
expect(record.lastError).toMatch(/smtp-down/);
|
||||
|
||||
// The lookup endpoint serves the persisted code ANYWAY.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.status).toBe('pending_email');
|
||||
expect(lookup.code).toBe(record.code);
|
||||
expect(lookup.durationDays).toBe(180);
|
||||
expect(lookup.productId).toBe('pro-180d');
|
||||
});
|
||||
|
||||
test('Stripe retry after SMTP failure keeps retrying (customer recovers via lookup)', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-365d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.status).toBe(500);
|
||||
|
||||
// Stripe retries with the SAME eventId. SMTP is still down → bridge
|
||||
// keeps retrying (returns 500) until either SMTP recovers or Stripe
|
||||
// gives up. The customer recovery path is via the lookup endpoint —
|
||||
// the license IS persisted in the fulfillment store regardless.
|
||||
const retry = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(retry.status).toBe(500);
|
||||
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const record = store.readBySession(event.data.object.id);
|
||||
expect(record.code).toBeTruthy();
|
||||
expect(record.status).toBe('pending_email');
|
||||
|
||||
// Lookup serves the persisted code.
|
||||
const lookup = bridge.lookupSession(event.data.object.id);
|
||||
expect(lookup.code).toBe(record.code);
|
||||
|
||||
// Only one license generated across the retries (layer-2 idempotency).
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
});
|
||||
|
||||
test('SMTP recovers on a subsequent attempt (different eventId, same session) — still reuses the persisted code', async () => {
|
||||
let smtpCalls = 0;
|
||||
injectSmtp(async () => {
|
||||
smtpCalls++;
|
||||
if (smtpCalls === 1) throw new Error('smtp-temp-down');
|
||||
return { messageId: 'msg-ok' };
|
||||
});
|
||||
|
||||
const sessionId = `cs_test_recover_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });
|
||||
|
||||
const payloadA = buildSignedPayload(eventA);
|
||||
const payloadB = buildSignedPayload(eventB);
|
||||
|
||||
const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
|
||||
expect(rA.status).toBe(500); // first attempt: SMTP down
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
|
||||
// Read the persisted code from the store (rA.body doesn't include it on
|
||||
// failure — by design, we don't leak license material in error responses).
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const persistedCode = store.readBySession(sessionId).code;
|
||||
expect(persistedCode).toBeTruthy();
|
||||
|
||||
const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
|
||||
expect(rB.status).toBe(200); // second event, same session: reuses persisted code, delivery succeeds
|
||||
expect(rB.body.delivered).toBe(true);
|
||||
// Same code reused, NOT a fresh generation.
|
||||
expect(rB.body.codeId).toBeTruthy();
|
||||
|
||||
// The store's codeId matches rB.body.codeId (proves reuse, not regeneration).
|
||||
expect(rB.body.codeId).toBe(store.readBySession(sessionId).codeId);
|
||||
|
||||
// No new license generated.
|
||||
expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
|
||||
expect(smtpCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge lookupSession', () => {
|
||||
test('returns not_found for unknown sessionId', () => {
|
||||
expect(bridge.lookupSession('cs_unknown')).toEqual({ status: 'not_found' });
|
||||
});
|
||||
|
||||
test('returns expired for record past TTL', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
|
||||
// Far-future "now" past the 24h TTL.
|
||||
const future = Date.now() + 25 * 60 * 60 * 1000;
|
||||
const lookup = bridge.lookupSession(event.data.object.id, { nowMs: future });
|
||||
expect(lookup.status).toBe('expired');
|
||||
});
|
||||
|
||||
test('returns processing state for fresh claim without code', async () => {
|
||||
const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
|
||||
const sessionId = `cs_test_processing_${crypto.randomBytes(4).toString('hex')}`;
|
||||
await store.claim({ eventId: 'evt_pend', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
|
||||
const lookup = bridge.lookupSession(sessionId);
|
||||
expect(lookup.status).toBe('processing');
|
||||
expect(lookup.durationDays).toBe(30);
|
||||
expect(lookup.productId).toBe('pro-30d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge constants', () => {
|
||||
test('LOOKUP_TTL_MS defaults to 24h', () => {
|
||||
expect(bridge.LOOKUP_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('DELIVERY_LEASE_MS is exported', () => {
|
||||
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user