/** * End-to-end billing integration test. * * Exercises the FULL purchase → fulfillment → activation → Pro unlock flow: * * 1. POST /api/v1/billing/checkout → mock Stripe SDK → session { id, url } * 2. Simulate webhook delivery → bridge.handleWebhook() with a signed * checkout.session.completed payload * 3. GET /api/v1/billing/lookup/:sessionId → verify license code returned * 4. POST /api/v1/license/activate → verify code activates, Pro unlocks * * The bridge and the API billing routes communicate through a SHARED * fulfillment-store file (the production IPC channel — a bind-mounted JSON * file). This test wires both sides to the same tmp file so the lookup * endpoint sees the license the bridge persisted, exactly as in production. * * The REAL license-keygen + LicenseManager are used (no HMAC mock) so the * code generated by the bridge is cryptographically valid and activates * through the real LicenseManager.verifyCode() path. Only Stripe's network * surface and nodemailer are mocked. */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const express = require('express'); const request = require('supertest'); // ── jest.mock must be hoisted before any require() ───────────────────────── // Mock nodemailer so the bridge never opens a real SMTP connection. SMTP is // left unconfigured (no SMTP_HOST/SMTP_FROM) so deliverCode() falls back to // dev-console mode — the documented dev/test path where the license is marked // `delivered` without actually sending email. jest.mock('nodemailer', () => ({ createTransport: jest.fn(() => ({ sendMail: jest.fn() })), })); // ── Isolated tmp state (set BEFORE requiring the bridge + routes) ────────── const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-e2e-billing-')); // Shared fulfillment-store file — the IPC channel between bridge and API. process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json'); process.env.STRIPE_BRIDGE_STATE_DIR = TMP; process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json'); process.env.STRIPE_WEBHOOK_SECRET = 'whsec_e2e_' + crypto.randomBytes(8).toString('hex'); // Configure Stripe products so the catalog + stripe-client can resolve price IDs. process.env.STRIPE_SECRET_KEY = 'sk_test_e2e'; process.env.STRIPE_PRICE_PRO_30D = 'price_30d_e2e'; process.env.STRIPE_PRICE_PRO_90D = 'price_90d_e2e'; process.env.STRIPE_PRICE_PRO_180D = 'price_180d_e2e'; process.env.STRIPE_PRICE_PRO_365D = 'price_365d_e2e'; process.env.STRIPE_PUBLIC_ORIGIN = 'https://status.test'; // No SMTP → bridge uses dev-console delivery (license marked delivered, no email). delete process.env.SMTP_HOST; delete process.env.SMTP_FROM; // ── Real license-keygen with a known master secret ───────────────────────── // We write a real secret file so the bridge's loadSecret() + generateCodes() // produce HMAC-valid codes that the LicenseManager can verify with the SAME // secret. This makes the activation step exercise the real cryptographic path. const E2E_SECRET = crypto.randomBytes(32).toString('hex'); const SECRET_FILE = path.join(TMP, '.license-secret'); fs.writeFileSync(SECRET_FILE, E2E_SECRET, { mode: 0o600 }); process.env.LICENSE_SECRET_FILE = SECRET_FILE; // Real keygen — no mock. The counter file is isolated to the tmp dir. process.env.LICENSE_COUNTER_FILE = path.join(TMP, '.license-counter'); // Now require modules (after env + mock setup). const keygen = require('../../license-keygen'); const catalog = require('../../src/billing/catalog'); const stripeClient = require('../../src/billing/stripe-client'); const bridge = require('../../scripts/stripe-license-bridge'); const billingRoutesFactory = require('../../routes/billing'); const licenseRoutesFactory = require('../../routes/license'); const { LicenseManager } = require('../../src/managers/license-manager'); const { createFulfillmentStore } = require('../../src/billing/fulfillment-store'); // ── Test app: mounts billing + license routes the same way app.js does ───── function makeApp(licenseManager) { const app = express(); app.use(express.json()); function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } app.use('/api/v1/billing', billingRoutesFactory({ asyncHandler })); app.use('/api/v1/license', licenseRoutesFactory({ licenseManager, asyncHandler })); // Jest/express error handler — surfaces route errors as JSON so supertest // can assert on the body. app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ success: false, error: err.message }); }); return app; } // ── Helpers ──────────────────────────────────────────────────────────────── /** * Build a signed Stripe webhook payload for checkout.session.completed. */ function buildSignedWebhook(sessionId, productId, customerEmail, opts = {}) { const product = catalog.getProduct(productId); const event = { id: opts.eventId || `evt_e2e_${crypto.randomBytes(6).toString('hex')}`, type: opts.type || 'checkout.session.completed', data: { object: { id: sessionId, customer_email: customerEmail, customer_details: { email: customerEmail }, payment_status: 'paid', amount_total: product ? product.amountCents : 0, currency: 'usd', metadata: { productId, product: 'dashcaddy-pro' }, }, }, }; const rawBody = Buffer.from(JSON.stringify(event)); const ts = Math.floor(Date.now() / 1000); const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET) .update(`${ts}.${rawBody}`, 'utf8').digest('hex'); return { rawBody, signatureHeader: `t=${ts},v1=${sig}`, event }; } /** * Install a mock Stripe SDK that returns a checkout session with a * caller-chosen id + url. Captures the params passed to sessions.create(). */ function installMockStripe(sessionId, sessionUrl) { let capturedParams; const mockStripe = jest.fn().mockReturnValue({ checkout: { sessions: { create: jest.fn().mockImplementation(async (params) => { capturedParams = params; return { id: sessionId, url: sessionUrl }; }), }, }, }); stripeClient._setStripeSdk(mockStripe); return { capturedParams: () => capturedParams }; } // ── Cleanup ──────────────────────────────────────────────────────────────── afterAll(() => { stripeClient._setStripeSdk(null); try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) { /* best effort */ } }); // ═══════════════════════════════════════════════════════════════════════════ // THE END-TO-END FLOW // ═══════════════════════════════════════════════════════════════════════════ describe('end-to-end billing flow: checkout → webhook → lookup → activate → Pro', () => { const PRODUCT_ID = 'pro-90d'; const CUSTOMER_EMAIL = 'alice@example.com'; const SESSION_ID = `cs_e2e_${crypto.randomBytes(6).toString('hex')}`; const CHECKOUT_URL = `https://checkout.stripe.com/c/pay/${SESSION_ID}`; let app; let licenseManager; let activationCode; // captured during the flow beforeAll(() => { // Real LicenseManager, configured with the same secret the bridge uses. licenseManager = new LicenseManager( { store: jest.fn().mockResolvedValue(undefined), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(undefined), }, path.join(TMP, 'config.json'), { info: () => {}, warn: () => {}, error: () => {} } ); // loadSecret reads the file and stores it as masterSecretHash for verifyCode(). licenseManager.loadSecret(SECRET_FILE); app = makeApp(licenseManager); }); // ── Step 1: POST /api/v1/billing/checkout ────────────────────────────── test('Step 1: checkout creates a Stripe session via the mock SDK', async () => { const stripe = installMockStripe(SESSION_ID, CHECKOUT_URL); const res = await request(app) .post('/api/v1/billing/checkout') .send({ productId: PRODUCT_ID, customerEmail: CUSTOMER_EMAIL }) .expect(200); expect(res.body.success).toBe(true); expect(res.body.data.id).toBe(SESSION_ID); expect(res.body.data.url).toBe(CHECKOUT_URL); // The mock Stripe SDK was called with the correct product + metadata. const params = stripe.capturedParams(); expect(params.mode).toBe('payment'); expect(params.metadata.productId).toBe(PRODUCT_ID); expect(params.line_items[0].price).toBe('price_90d_e2e'); expect(params.customer_email).toBe(CUSTOMER_EMAIL); }); // ── Step 2: Simulate Stripe webhook delivery ─────────────────────────── test('Step 2: webhook generates + persists + delivers the license', async () => { const { rawBody, signatureHeader, event } = buildSignedWebhook( SESSION_ID, PRODUCT_ID, CUSTOMER_EMAIL ); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); expect(result.body.delivered).toBe(true); expect(result.body.productId).toBe(PRODUCT_ID); expect(result.body.durationDays).toBe(90); expect(result.body.codeId).toBeTruthy(); expect(result.body.deliveredVia).toBe('dev-console'); // Capture the code for subsequent steps. const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const record = store.readBySession(SESSION_ID); expect(record).toBeTruthy(); expect(record.status).toBe('delivered'); expect(record.code).toBeTruthy(); activationCode = record.code; }); // ── Step 3: GET /api/v1/billing/lookup/:sessionId ────────────────────── test('Step 3: lookup returns the delivered license code', async () => { const res = await request(app) .get(`/api/v1/billing/lookup/${SESSION_ID}`) .expect(200); expect(res.body.success).toBe(true); expect(res.body.data.status).toBe('delivered'); expect(res.body.data.code).toBe(activationCode); expect(res.body.data.codeId).toBeTruthy(); expect(res.body.data.productId).toBe(PRODUCT_ID); expect(res.body.data.durationDays).toBe(90); expect(res.body.data.deliveredVia).toBe('dev-console'); // Bearer-style secret — must never be cached. expect(res.headers['cache-control']).toBe('no-store'); }); // ── Step 4: POST /api/v1/license/activate → Pro unlock ───────────────── test('Step 4: activate the license → Pro tier unlocks', async () => { expect(activationCode).toBeTruthy(); const res = await request(app) .post('/api/v1/license/activate') .send({ code: activationCode }) .expect(200); expect(res.body.success).toBe(true); expect(res.body.license).toBeDefined(); expect(res.body.license.active).toBe(true); expect(res.body.license.tier).toBe('premium'); expect(res.body.license.durationDays).toBe(90); expect(res.body.license.expired).toBe(false); // The LicenseManager itself now reports Pro (this is what gates features // elsewhere in the app via licenseManager.isPro()). expect(licenseManager.isPro()).toBe(true); expect(licenseManager.hasFeature('sso')).toBe(true); }); // ── Bonus: GET /api/v1/license/status reflects the active Pro license ── test('Step 5: license status confirms Pro is active', async () => { const res = await request(app) .get('/api/v1/license/status') .expect(200); expect(res.body.success).toBe(true); expect(res.body.license.active).toBe(true); expect(res.body.license.tier).toBe('premium'); expect(res.body.license.expired).toBe(false); expect(res.body.license.features).toEqual( expect.arrayContaining(['sso', 'recipes', 'swarm']) ); }); }); // ═══════════════════════════════════════════════════════════════════════════ // Additional e2e scenarios // ═══════════════════════════════════════════════════════════════════════════ describe('e2e: lookup returns 404 before webhook delivers the license', () => { test('lookup before webhook → 404 not found', async () => { const app = makeApp(null); const sessionId = `cs_notyet_${crypto.randomBytes(4).toString('hex')}`; const res = await request(app) .get(`/api/v1/billing/lookup/${sessionId}`) .expect(404); expect(res.body.success).toBe(false); }); }); describe('e2e: each catalog product flows through to a valid activatable license', () => { // Use a fresh app + licenseManager per product to avoid activation conflicts. for (const product of catalog.PRODUCTS) { test(`product ${product.id} (${product.durationDays}d) activates and unlocks Pro`, async () => { const sessionId = `cs_e2e_${product.id}_${crypto.randomBytes(4).toString('hex')}`; const email = `buyer_${product.id}@example.com`; const lm = new LicenseManager( { store: jest.fn().mockResolvedValue(undefined), retrieve: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(undefined), }, path.join(TMP, `config-${product.id}.json`), { info: () => {}, warn: () => {}, error: () => {} } ); lm.loadSecret(SECRET_FILE); const app = makeApp(lm); // Checkout installMockStripe(sessionId, `https://checkout.stripe.com/c/pay/${sessionId}`); const checkoutRes = await request(app) .post('/api/v1/billing/checkout') .send({ productId: product.id, customerEmail: email }) .expect(200); expect(checkoutRes.body.data.id).toBe(sessionId); // Webhook const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, product.id, email); const whResult = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(whResult.status).toBe(200); expect(whResult.body.delivered).toBe(true); expect(whResult.body.durationDays).toBe(product.durationDays); // Lookup const lookupRes = await request(app) .get(`/api/v1/billing/lookup/${sessionId}`) .expect(200); expect(lookupRes.body.data.status).toBe('delivered'); expect(lookupRes.body.data.code).toBeTruthy(); const code = lookupRes.body.data.code; // Activate → Pro const activateRes = await request(app) .post('/api/v1/license/activate') .send({ code }) .expect(200); expect(activateRes.body.license.tier).toBe('premium'); expect(activateRes.body.license.durationDays).toBe(product.durationDays); expect(lm.isPro()).toBe(true); }); } }); describe('e2e: webhook idempotency — duplicate delivery reuses the same license', () => { test('a second webhook for the same session does not mint a new code', async () => { const sessionId = `cs_e2e_dedup_${crypto.randomBytes(4).toString('hex')}`; const productId = 'pro-30d'; const email = 'dedup@example.com'; // First delivery. const payload1 = buildSignedWebhook(sessionId, productId, email); const r1 = await bridge.handleWebhook({ rawBody: payload1.rawBody, signatureHeader: payload1.signatureHeader, }); expect(r1.status).toBe(200); expect(r1.body.delivered).toBe(true); const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const firstCode = store.readBySession(sessionId).code; expect(firstCode).toBeTruthy(); // Same eventId (Stripe retry) → layer-1 idempotency, no regeneration. const r2 = await bridge.handleWebhook({ rawBody: payload1.rawBody, signatureHeader: payload1.signatureHeader, }); expect(r2.status).toBe(200); expect(r2.body.deduplicated).toBe(true); const secondCode = store.readBySession(sessionId).code; expect(secondCode).toBe(firstCode); }); }); describe('e2e: the license code generated by the bridge verifies via the real keygen', () => { test('bridge-generated code is cryptographically valid', async () => { const sessionId = `cs_e2e_crypto_${crypto.randomBytes(4).toString('hex')}`; const { rawBody, signatureHeader } = buildSignedWebhook(sessionId, 'pro-365d', 'crypto@example.com'); const result = await bridge.handleWebhook({ rawBody, signatureHeader }); expect(result.status).toBe(200); const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE }); const code = store.readBySession(sessionId).code; // verifyCode with the SAME secret the bridge used — this is exactly what // LicenseManager._validateOffline does during activation. const verification = keygen.verifyCode(E2E_SECRET, code); expect(verification.valid).toBe(true); expect(verification.durationDays).toBe(365); expect(verification.expired).toBe(false); }); });