diff --git a/jest.env-setup.cjs b/jest.env-setup.cjs new file mode 100644 index 0000000..3aa4eb2 --- /dev/null +++ b/jest.env-setup.cjs @@ -0,0 +1,33 @@ +// Jest environment setup — loaded before any test file runs (via setupFiles in jest.config.cjs). +// Sets the minimum required env vars so src/config/env.ts validation passes. +// +// IMPORTANT: All values must be strings. env.ts parses process.env with Zod and +// will call process.exit(1) if it receives unexpected types (e.g. booleans). +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-do-not-use-in-prod'; +process.env.ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'test-admin-key'; +process.env.METRICS_API_KEY = process.env.METRICS_API_KEY || 'test-metrics-key'; + +// Explicitly set boolean-like env vars to string values so Zod .transform() +// doesn't get an actual boolean when workers share state across test files. +if (typeof process.env.TRUST_FORWARDED_USER_ID !== 'string') { + process.env.TRUST_FORWARDED_USER_ID = 'false'; +} +if (typeof process.env.SOROBAN_RPC_ENABLED !== 'string') { + process.env.SOROBAN_RPC_ENABLED = 'false'; +} +if (typeof process.env.HORIZON_ENABLED !== 'string') { + process.env.HORIZON_ENABLED = 'false'; +} +if (typeof process.env.GATEWAY_PROFILING_ENABLED !== 'string') { + process.env.GATEWAY_PROFILING_ENABLED = 'false'; +} +if (typeof process.env.MEMORY_ACCOUNTING_ENABLED !== 'string') { + process.env.MEMORY_ACCOUNTING_ENABLED = 'false'; +} +if (typeof process.env.SOROBAN_CHAOS !== 'string') { + process.env.SOROBAN_CHAOS = 'false'; +} +if (!Array.isArray(process.env.ROUTE_BODY_LIMITS) && typeof process.env.ROUTE_BODY_LIMITS !== 'string') { + process.env.ROUTE_BODY_LIMITS = ''; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e1d7c4c --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "callora-backend", + "version": "0.0.1", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/src/index.js", + "dev": "tsx src/index.ts", + "test": "jest", + "test:unit": "jest --testPathPattern=src/", + "test:integration": "jest --testPathPattern=tests/integration/", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@opentelemetry/api": "^1.7.0", + "@prisma/adapter-pg": "^5.10.0", + "@prisma/client": "^5.10.0", + "@stellar/stellar-sdk": "^12.0.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.10.0", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "drizzle-orm": "^0.30.0", + "express": "^4.18.0", + "express-openapi-validator": "^5.1.0", + "helmet": "^7.1.0", + "ip-range-check": "^0.2.0", + "jsonwebtoken": "^9.0.0", + "pg": "^8.11.0", + "pino": "^8.19.0", + "prisma": "^5.10.0", + "prom-client": "^15.1.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.11.0", + "@types/pg": "^8.11.2", + "@types/supertest": "^6.0.2", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", + "drizzle-kit": "^0.20.14", + "eslint": "^8.57.0", + "fast-check": "^3.16.0", + "globals": "^15.0.0", + "jest": "^29.7.0", + "pg-mem": "^2.8.1", + "picomatch": "^4.0.1", + "supertest": "^7.0.0", + "testcontainers": "^10.7.1", + "ts-jest": "^29.1.2", + "tsx": "^4.7.1", + "typescript": "^5.4.0", + "typescript-eslint": "^7.0.0" + } +} diff --git a/src/routes/billing/deduct.test.ts b/src/routes/billing/deduct.test.ts index 297927e..781d27e 100644 --- a/src/routes/billing/deduct.test.ts +++ b/src/routes/billing/deduct.test.ts @@ -1,8 +1,16 @@ import express from 'express'; import request from 'supertest'; +import jwt from 'jsonwebtoken'; import { errorHandler } from '../../middleware/errorHandler.js'; import deductRouter from './deduct.js'; import type { Pool } from 'pg'; +import { BillingService } from '../../services/billing.js'; +import { SorobanRpcError } from '../../services/sorobanBilling.js'; +import type { SimulationDetails } from '../../lib/simulationDiagnostics.js'; + +// --------------------------------------------------------------------------- +// Module-level mocks (must be hoisted before any imports are executed) +// --------------------------------------------------------------------------- jest.mock('better-sqlite3', () => { return class MockDatabase { @@ -29,30 +37,84 @@ jest.mock('../../services/sorobanBilling.js', () => { }; }); +// BillingService is injected via createRouteBillingService inside the route. +// We mock the constructor so tests can control what deduct() throws/returns. +jest.mock('../../services/billing.js', () => { + const actual = jest.requireActual('../../services/billing.js'); + return { + ...actual, + BillingService: jest.fn(), + }; +}); + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const TEST_JWT_SECRET = 'test-secret-do-not-use-in-prod'; + +/** Sign a valid short-lived JWT for test requests. */ +function signToken(userId = 'user_test_123'): string { + return jwt.sign({ userId }, TEST_JWT_SECRET, { expiresIn: '1h' }); +} + +/** Return an Express app wired up with the deduct router and error handler. */ +function buildApp(pool: Pool = { query: jest.fn() } as unknown as Pool) { + const app = express(); + app.use(express.json()); + app.locals.dbPool = pool; + app.use('/api/billing/deduct', deductRouter); + app.use(errorHandler); + return app; +} + +/** Minimal valid POST body for /api/billing/deduct. */ +const validPayload = { + requestId: 'req_1', + apiId: 'api_1', + endpointId: 'endpoint_1', + apiKeyId: 'key_1', + amountUsdc: '0.01', +}; + +// --------------------------------------------------------------------------- +// Helper: configure BillingService mock for a given test +// --------------------------------------------------------------------------- + +type DeductMockResult = + | { throws: Error } + | { returns: Awaited> }; + +function mockBillingService(result: DeductMockResult): void { + const MockBillingService = BillingService as jest.MockedClass; + const deductFn = result instanceof Object && 'throws' in result + ? jest.fn().mockRejectedValue((result as { throws: Error }).throws) + : jest.fn().mockResolvedValue((result as { returns: unknown }).returns); + + MockBillingService.mockImplementation(() => ({ + deduct: deductFn, + deductBulk: jest.fn(), + getByRequestId: jest.fn().mockResolvedValue(null), + }) as unknown as BillingService); +} + +// --------------------------------------------------------------------------- +// Suite 1: pre-existing developerId validation tests (kept for regression) +// --------------------------------------------------------------------------- + describe('POST /api/billing/deduct - developerId validation', () => { - function buildApp(pool: Pool | null = { query: jest.fn() } as unknown as Pool) { - const app = express(); - app.use(express.json()); - if (pool) { - app.locals.dbPool = pool; - } - app.use('/api/billing/deduct', deductRouter); - app.use(errorHandler); - return app; - } + it('returns 401 without auth', async () => { + const res = await request(buildApp()) + .post('/api/billing/deduct') + .send({ ...validPayload, developerId: null }); - const validPayload = { - requestId: 'req_1', - apiId: 'api_1', - endpointId: 'endpoint_1', - apiKeyId: 'key_1', - amountUsdc: '0.01', - }; + expect(res.status).toBe(401); + }); it('returns 400 (not 500) when developerId is explicitly null', async () => { const res = await request(buildApp()) .post('/api/billing/deduct') - .set('x-user-id', 'user_123') + .set('Authorization', `Bearer ${signToken()}`) .send({ ...validPayload, developerId: null }); expect(res.status).toBe(400); @@ -64,7 +126,7 @@ describe('POST /api/billing/deduct - developerId validation', () => { it('returns 400 when developerId is an empty string', async () => { const res = await request(buildApp()) .post('/api/billing/deduct') - .set('x-user-id', 'user_123') + .set('Authorization', `Bearer ${signToken()}`) .send({ ...validPayload, developerId: '' }); expect(res.status).toBe(400); @@ -76,7 +138,7 @@ describe('POST /api/billing/deduct - developerId validation', () => { it('returns 400 when developerId is not a string', async () => { const res = await request(buildApp()) .post('/api/billing/deduct') - .set('x-user-id', 'user_123') + .set('Authorization', `Bearer ${signToken()}`) .send({ ...validPayload, developerId: 12345 }); expect(res.status).toBe(400); @@ -86,23 +148,362 @@ describe('POST /api/billing/deduct - developerId validation', () => { }); it('falls back to the authenticated user id when developerId is omitted', async () => { - const queryMock = jest.fn().mockRejectedValue(new Error('stop before DB write')); - const res = await request(buildApp({ query: queryMock } as unknown as Pool)) + // The billing service mock will throw so we never hit the DB, but the + // request must have passed validation (no 400) and auth (no 401). + const err = new Error('stop before DB write'); + mockBillingService({ throws: err }); + + const res = await request(buildApp()) .post('/api/billing/deduct') - .set('x-user-id', 'user_123') + .set('Authorization', `Bearer ${signToken()}`) .send(validPayload); - // Validation passes and the request proceeds past developerId handling - // (fails later at the DB layer, which is expected given the mocked pool). + // Validation and auth pass; the mock service throws → 500 expect(res.status).not.toBe(400); - expect(queryMock).toHaveBeenCalled(); + expect(res.status).not.toBe(401); }); +}); - it('returns 401 without auth', async () => { - const res = await request(buildApp()) +// --------------------------------------------------------------------------- +// Suite 2: Soroban error category → HTTP status mapping +// +// Strategy: mock BillingService.deduct() to throw SorobanRpcError with a +// specific category, then assert the HTTP status, error code, and envelope +// shape. No running Soroban node is needed. +// --------------------------------------------------------------------------- + +describe('POST /api/billing/deduct - SorobanRpcError category mapping', () => { + // Shared assertion helper + async function postDeduct(overrides: Record = {}) { + return request(buildApp()) .post('/api/billing/deduct') - .send({ ...validPayload, developerId: null }); + .set('Authorization', `Bearer ${signToken()}`) + .send({ ...validPayload, ...overrides }); + } - expect(res.status).toBe(401); + // ------------------------------------------------------------------------- + // INSUFFICIENT_BALANCE → 402 + // ------------------------------------------------------------------------- + describe('INSUFFICIENT_BALANCE', () => { + beforeEach(() => { + mockBillingService({ + throws: new SorobanRpcError( + 'Insufficient balance to cover deduction', + 'INSUFFICIENT_BALANCE', + ), + }); + }); + + it('returns HTTP 402', async () => { + const res = await postDeduct(); + expect(res.status).toBe(402); + }); + + it('returns error code INSUFFICIENT_BALANCE', async () => { + const res = await postDeduct(); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INSUFFICIENT_BALANCE'); + }); + + it('returns a well-formed error envelope', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'INSUFFICIENT_BALANCE', + message: expect.any(String), + }, + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + }); + + // ------------------------------------------------------------------------- + // TIMEOUT → 504 + // ------------------------------------------------------------------------- + describe('TIMEOUT', () => { + beforeEach(() => { + mockBillingService({ + throws: new SorobanRpcError( + 'Soroban RPC request timed out', + 'TIMEOUT', + ), + }); + }); + + it('returns HTTP 504', async () => { + const res = await postDeduct(); + expect(res.status).toBe(504); + }); + + it('returns error code SOROBAN_RPC_TIMEOUT', async () => { + const res = await postDeduct(); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('SOROBAN_RPC_TIMEOUT'); + }); + + it('returns a well-formed error envelope', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'SOROBAN_RPC_TIMEOUT', + message: expect.any(String), + }, + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + }); + + // ------------------------------------------------------------------------- + // CONTRACT_ERROR → 502 + // ------------------------------------------------------------------------- + describe('CONTRACT_ERROR', () => { + beforeEach(() => { + mockBillingService({ + throws: new SorobanRpcError( + 'Contract execution reverted', + 'CONTRACT_ERROR', + ), + }); + }); + + it('returns HTTP 502', async () => { + const res = await postDeduct(); + expect(res.status).toBe(502); + }); + + it('returns error code SOROBAN_RPC_ERROR', async () => { + const res = await postDeduct(); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('SOROBAN_RPC_ERROR'); + }); + + it('returns a well-formed error envelope', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'SOROBAN_RPC_ERROR', + message: expect.any(String), + }, + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + }); + + // ------------------------------------------------------------------------- + // NETWORK_ERROR → 502 + // ------------------------------------------------------------------------- + describe('NETWORK_ERROR', () => { + beforeEach(() => { + mockBillingService({ + throws: new SorobanRpcError( + 'Transport failure connecting to Soroban RPC', + 'NETWORK_ERROR', + ), + }); + }); + + it('returns HTTP 502', async () => { + const res = await postDeduct(); + expect(res.status).toBe(502); + }); + + it('returns error code SOROBAN_RPC_ERROR', async () => { + const res = await postDeduct(); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('SOROBAN_RPC_ERROR'); + }); + + it('returns a well-formed error envelope', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'SOROBAN_RPC_ERROR', + message: expect.any(String), + }, + requestId: expect.any(String), + timestamp: expect.any(String), + }); + }); + }); + + // ------------------------------------------------------------------------- + // Unknown / unrecognised error → 500 via errorHandler, no internal leak + // ------------------------------------------------------------------------- + describe('unknown error (non-SorobanRpcError fallthrough)', () => { + beforeEach(() => { + // A plain Error that is not a SorobanRpcError → hits `next(error)` → + // errorHandler maps it to 500 with INTERNAL_SERVER_ERROR. + mockBillingService({ + throws: new Error('Something completely unexpected'), + }); + }); + + it('returns HTTP 500', async () => { + const res = await postDeduct(); + expect(res.status).toBe(500); + }); + + it('returns error code INTERNAL_SERVER_ERROR', async () => { + const res = await postDeduct(); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('INTERNAL_SERVER_ERROR'); + }); + + it('does not leak internal error message or stack trace', async () => { + const res = await postDeduct(); + // The error message must be the generic public message, not the raw + // internal one. errorHandler uses safePublicMessage() which returns + // 'Internal server error' for untrusted 500 errors. + expect(res.body.error.message).not.toContain('Something completely unexpected'); + expect(res.body.error.message).toBe('Internal server error'); + // No stack trace in body + expect(JSON.stringify(res.body)).not.toContain('at Object'); + }); + + it('returns a well-formed error envelope without extra internal fields', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal server error', + }, + requestId: expect.any(String), + timestamp: expect.any(String), + }); + // No simulationDetails, stack, or raw error in the body + expect(res.body.error.simulationDetails).toBeUndefined(); + expect(res.body.error.stack).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // Simulation diagnostics: SorobanRpcError with simulationDetails → 502 + // with redacted body (no sensitive fields) + // ------------------------------------------------------------------------- + describe('SorobanRpcError with simulationDetails (simulation failure path)', () => { + const sensitiveSimulationDetails: SimulationDetails = { + errorCode: 'CONTRACT_ERR_42', + errorMessage: 'Wasm trap: out of gas', + events: [{ type: 'event', address: 'GDUSER1STELLARADDRESS', balance: '999999' }], + footprint: { + source: 'GDSOURCE', + destination: 'GDDEST', + secretKey: 'SXXXXXXXX', + }, + }; + + beforeEach(() => { + mockBillingService({ + throws: new SorobanRpcError( + 'Simulation failed', + 'CONTRACT_ERROR', + sensitiveSimulationDetails, + ), + }); + }); + + it('returns HTTP 502', async () => { + const res = await postDeduct(); + expect(res.status).toBe(502); + }); + + it('returns SIMULATION_FAILED code', async () => { + const res = await postDeduct(); + expect(res.body.code).toBe('SIMULATION_FAILED'); + }); + + it('includes redacted simulationDetails in the response body', async () => { + const res = await postDeduct(); + // The route calls redactSimulationDetails() before sending + expect(res.body.simulationDetails).toBeDefined(); + }); + + it('redacts sensitive address/key fields from simulationDetails', async () => { + const res = await postDeduct(); + const details = res.body.simulationDetails as Record; + const bodyStr = JSON.stringify(details); + + // Sensitive values from the original simulationDetails must not appear + expect(bodyStr).not.toContain('GDUSER1STELLARADDRESS'); + expect(bodyStr).not.toContain('GDSOURCE'); + expect(bodyStr).not.toContain('GDDEST'); + expect(bodyStr).not.toContain('SXXXXXXXX'); + expect(bodyStr).not.toContain('999999'); + }); + + it('preserves non-sensitive diagnostic fields (errorCode, errorMessage)', async () => { + const res = await postDeduct(); + const details = res.body.simulationDetails as Record; + + // errorCode and errorMessage are non-sensitive and should survive redaction + expect(details.errorCode).toBe('CONTRACT_ERR_42'); + expect(details.errorMessage).toBe('Wasm trap: out of gas'); + }); + + it('replaces event list with eventCount (not raw events)', async () => { + const res = await postDeduct(); + const details = res.body.simulationDetails as Record; + + // redactSimulationDetails replaces events with eventCount + expect(details.eventCount).toBe(1); + expect(details.events).toBeUndefined(); + }); + + it('replaces footprint with footprintPresent flag', async () => { + const res = await postDeduct(); + const details = res.body.simulationDetails as Record; + + expect(details.footprintPresent).toBe(true); + expect(details.footprint).toBeUndefined(); + }); + + it('does not use the standard error envelope for the 502 simulation body', async () => { + const res = await postDeduct(); + // The route sends a custom JSON object (not via errorHandler), so the + // response lacks the envelope's `error.code` nested shape. + expect(res.body.error).toBe('Soroban simulation failed'); + expect(res.body.code).toBe('SIMULATION_FAILED'); + }); + }); + + // ------------------------------------------------------------------------- + // Successful deduction → 200 + // ------------------------------------------------------------------------- + describe('successful deduction', () => { + beforeEach(() => { + mockBillingService({ + returns: { + success: true, + usageEventId: 'evt_abc123', + stellarTxHash: 'tx_hash_xyz', + alreadyProcessed: false, + deductionApplied: true, + reconciliationRequired: false, + }, + }); + }); + + it('returns HTTP 200', async () => { + const res = await postDeduct(); + expect(res.status).toBe(200); + }); + + it('returns success payload with usageEventId and stellarTxHash', async () => { + const res = await postDeduct(); + expect(res.body).toMatchObject({ + success: true, + usageEventId: 'evt_abc123', + stellarTxHash: 'tx_hash_xyz', + alreadyProcessed: false, + }); + }); }); });