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