/** * 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['"]/); }); });