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.
523 lines
23 KiB
JavaScript
523 lines
23 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|