Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/__tests__/ipAllowlist.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import request from 'supertest';
import express from 'express';
import { createIpAllowlist, createAdminIpAllowlist, createGatewayIpAllowlist } from '../middleware/ipAllowlist.js';
import { requestLogger } from '../middleware/logging.js';
import { logger } from '../middleware/logging.js';

// Mock the logger to avoid actual logging during tests
jest.mock('../middleware/logging.js');
const mockLogger = requestLogger as jest.Mocked<typeof requestLogger>;
const mockLogger = logger as jest.Mocked<typeof logger>;

describe('IP Allowlist Middleware', () => {
let testApp: express.Application;
Expand Down Expand Up @@ -345,7 +345,7 @@

// Mock req.ip to return localhost
testApp.use((req, res, next) => {
(req as any).ip = '127.0.0.1';

Check warning on line 348 in src/__tests__/ipAllowlist.test.ts

View workflow job for this annotation

GitHub Actions / build (20)

Unexpected any. Specify a different type
next();
});

Expand All @@ -370,7 +370,7 @@

// Mock req.ip to return localhost
testApp.use((req, res, next) => {
(req as any).ip = '127.0.0.1';

Check warning on line 373 in src/__tests__/ipAllowlist.test.ts

View workflow job for this annotation

GitHub Actions / build (20)

Unexpected any. Specify a different type
next();
});

Expand Down Expand Up @@ -417,7 +417,7 @@

// Mock req.ip to return localhost
testApp.use((req, res, next) => {
(req as any).ip = '127.0.0.1';

Check warning on line 420 in src/__tests__/ipAllowlist.test.ts

View workflow job for this annotation

GitHub Actions / build (20)

Unexpected any. Specify a different type
next();
});

Expand Down
36 changes: 23 additions & 13 deletions src/middleware/ipAllowlist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Request, Response, NextFunction } from 'express';
import ipRangeCheck from 'ip-range-check';
import { requestLogger } from './logging.js';
import { logger } from './logging.js';

/**
* Configuration for IP allowlist middleware
Expand Down Expand Up @@ -96,7 +96,7 @@
}

// Log configuration for security audit
requestLogger.info('IP allowlist middleware configured', {
logger.info('IP allowlist middleware configured', {

Check failure on line 99 in src/middleware/ipAllowlist.ts

View workflow job for this annotation

GitHub Actions / build (20)

No overload matches this call.
allowedRangesCount: allowedRanges.length,
trustProxy,
proxyHeaders,
Expand All @@ -114,7 +114,7 @@

// Validate extracted IP format
if (!isValidIp(clientIp)) {
requestLogger.warn('Invalid IP format detected', {
logger.warn('Invalid IP format detected', {
ip: clientIp,
userAgent: req.get('User-Agent'),
path: req.path,
Expand All @@ -132,7 +132,7 @@

if (!isAllowed) {
// Log blocked attempt for security monitoring
requestLogger.warn('IP allowlist blocked request', {
logger.warn('IP allowlist blocked request', {
clientIp,
path: req.path,
method: req.method,
Expand All @@ -148,7 +148,7 @@
}

// Log successful allowlist check for audit trail
requestLogger.debug('IP allowlist check passed', {
logger.debug('IP allowlist check passed', {
clientIp,
path: req.path,
method: req.method,
Expand All @@ -164,15 +164,20 @@
*/
export function createAdminIpAllowlist() {
const allowedRanges = process.env.ADMIN_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) || [];

const trustProxy = process.env.TRUST_PROXY_HEADERS === 'true';
const enabled = process.env.ADMIN_IP_ALLOWLIST_ENABLED !== 'false';

if (allowedRanges.length === 0) {
requestLogger.warn('Admin IP allowlist is empty - allowing all IPs');
logger.warn('Admin IP allowlist is empty - allowing all IPs');
return (_req: Request, _res: Response, next: NextFunction): void => {
next();
};
}

return createIpAllowlist({
allowedRanges,
trustProxy: process.env.TRUST_PROXY_HEADERS === 'true',
enabled: process.env.ADMIN_IP_ALLOWLIST_ENABLED !== 'false',
trustProxy,
enabled,
});
}

Expand All @@ -182,14 +187,19 @@
*/
export function createGatewayIpAllowlist() {
const allowedRanges = process.env.GATEWAY_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) || [];

const trustProxy = process.env.TRUST_PROXY_HEADERS === 'true';
const enabled = process.env.GATEWAY_IP_ALLOWLIST_ENABLED !== 'false';

if (allowedRanges.length === 0) {
requestLogger.warn('Gateway IP allowlist is empty - allowing all IPs');
logger.warn('Gateway IP allowlist is empty - allowing all IPs');
return (_req: Request, _res: Response, next: NextFunction): void => {
next();
};
}

return createIpAllowlist({
allowedRanges,
trustProxy: process.env.TRUST_PROXY_HEADERS === 'true',
enabled: process.env.GATEWAY_IP_ALLOWLIST_ENABLED !== 'false',
trustProxy,
enabled,
});
}
16 changes: 8 additions & 8 deletions src/middleware/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ValidationSchemas {
/**
* Interface for validation error details
*/
export interface ValidationError {
export interface ValidationErrorDetail {
field: string;
message: string;
code: string;
Expand All @@ -26,7 +26,7 @@ export interface ValidationError {
export interface ValidationErrorResponse {
error: string;
code: string;
details: ValidationError[];
details: ValidationErrorDetail[];
}

/**
Expand Down Expand Up @@ -60,7 +60,7 @@ export interface ValidationErrorResponse {
*/
export function validate(schemas: ValidationSchemas) {
return (req: Request, _res: Response, next: NextFunction): void => {
const errors: ValidationError[] = [];
const errors: ValidationErrorDetail[] = [];

// Validate request body
if (schemas.body) {
Expand Down Expand Up @@ -132,8 +132,8 @@ export function validate(schemas: ValidationSchemas) {
* @param location - Location of the validation error ('body', 'query', or 'params')
* @returns Array of formatted validation errors
*/
function formatZodErrors(error: ZodError, location: string): ValidationError[] {
return error.errors.map((err): ValidationError => {
function formatZodErrors(error: ZodError, location: string): ValidationErrorDetail[] {
return error.errors.map((err): ValidationErrorDetail => {
const field = err.path.join('.');
const code = err.code.toUpperCase();

Expand Down Expand Up @@ -172,9 +172,9 @@ function formatZodErrors(error: ZodError, location: string): ValidationError[] {
* This can be used when you need to access validation error details in error handling
*/
export class ValidationError extends BadRequestError {
public readonly details: ValidationError[];
public readonly details: ValidationErrorDetail[];

constructor(details: ValidationError[]) {
constructor(details: ValidationErrorDetail[]) {
super('Request validation failed', 'VALIDATION_ERROR');
this.details = details;
}
Expand All @@ -189,7 +189,7 @@ export class ValidationError extends BadRequestError {
*/
export function validateWithDetails(schemas: ValidationSchemas) {
return (req: Request, res: Response, next: NextFunction): void => {
const errors: ValidationError[] = [];
const errors: ValidationErrorDetail[] = [];

// Validate request body
if (schemas.body) {
Expand Down
80 changes: 54 additions & 26 deletions tests/integration/health.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Health Check Integration Tests
*
* Tests the health endpoint with real database integration
* Uses pg-mem for in-memory PostgreSQL testing
*
* Tests the health endpoint with real database integration via pg-mem.
*/

import assert from 'node:assert/strict';
Expand All @@ -14,9 +13,11 @@ jest.mock('uuid', () => ({ v4: () => 'mock-uuid-1234' }));
// Mock better-sqlite3 to prevent native binding errors on Windows
jest.mock('better-sqlite3', () => {
return class MockDatabase {
prepare() { return { get: () => null }; }
exec() { }
close() { }
prepare() {
return { get: () => null };
}
exec() {}
close() {}
};
});

Expand Down Expand Up @@ -50,10 +51,12 @@ describe('GET /api/health - Integration Tests', () => {

test('returns 503 when database is down', async () => {
const testDb = createTestDb();
await testDb.end(); // Close pool to simulate database down

// pg-mem doesn't throw on query after end(), so we manually force it:
testDb.pool.query = async () => { throw new Error('Connection terminated'); };
await testDb.end();

// pg-mem doesn't always throw after end(), so force query failure.
testDb.pool.query = async () => {
throw new Error('Connection terminated');
};

const config: HealthCheckConfig = {
version: '1.0.0',
Expand All @@ -68,64 +71,90 @@ describe('GET /api/health - Integration Tests', () => {
assert.equal(response.body.checks.database, 'down');
});

test('executes SELECT 1 query successfully', async () => {
test('returns 200 with degraded status when soroban rpc is unreachable', async () => {
const testDb = createTestDb();

try {
// Verify SELECT 1 works directly
const result = await testDb.pool.query('SELECT 1 as result');
assert.equal(result.rows[0].result, 1);
const config: HealthCheckConfig = {
version: '1.0.0',
database: { pool: testDb.pool },
sorobanRpc: {
url: 'http://localhost:0',
timeout: 200,
},
};

const app = createApp({ healthCheckConfig: config });
const response = await request(app).get('/api/health');

// Verify health check uses it correctly
assert.equal(response.status, 200);
assert.equal(response.body.status, 'degraded');
assert.equal(response.body.checks.database, 'ok');
assert.equal(response.body.checks.soroban_rpc, 'down');
} finally {
await testDb.end();
}
});

test('returns 200 with degraded status when horizon is unreachable', async () => {
const testDb = createTestDb();

try {
const config: HealthCheckConfig = {
version: '1.0.0',
database: { pool: testDb.pool },
horizon: {
url: 'http://localhost:0',
timeout: 200,
},
};

const app = createApp({ healthCheckConfig: config });
const response = await request(app).get('/api/health');

assert.equal(response.status, 200);
assert.equal(response.body.status, 'degraded');
assert.equal(response.body.checks.database, 'ok');
assert.equal(response.body.checks.horizon, 'down');
} finally {
await testDb.end();
}
});

test('aggregates status correctly with multiple components', async () => {
test('returns 200 when both optional deps fail but database is ok', async () => {
const testDb = createTestDb();

try {
const config: HealthCheckConfig = {
version: '1.0.0',
database: { pool: testDb.pool },
// Soroban and Horizon not configured - should be omitted
sorobanRpc: { url: 'http://localhost:0', timeout: 200 },
horizon: { url: 'http://localhost:0', timeout: 200 },
};

const app = createApp({ healthCheckConfig: config });
const response = await request(app).get('/api/health');

assert.equal(response.status, 200);
assert.equal(response.body.status, 'ok');
assert.equal(response.body.checks.api, 'ok');
assert.equal(response.body.status, 'degraded');
assert.equal(response.body.checks.database, 'ok');
assert.equal(response.body.checks.soroban_rpc, undefined);
assert.equal(response.body.checks.horizon, undefined);
assert.equal(response.body.checks.soroban_rpc, 'down');
assert.equal(response.body.checks.horizon, 'down');
} finally {
await testDb.end();
}
});

test('returns simple health check when no config provided', async () => {
const app = createApp(); // No health check config
test('returns simple health check when no config is provided', async () => {
const app = createApp();
const response = await request(app).get('/api/health');

assert.equal(response.status, 200);
assert.equal(response.body.status, 'ok');
assert.equal(response.body.service, 'callora-backend');
});

test('handles health check errors gracefully without exposing internals', async () => {
// Create a pool that will throw an error
test('does not expose sensitive error details in response body', async () => {
const badPool = {
query: async () => {
throw new Error('Internal database error with sensitive info');
Expand All @@ -141,7 +170,6 @@ describe('GET /api/health - Integration Tests', () => {

assert.equal(response.status, 503);
assert.equal(response.body.status, 'down');
// Should not expose internal error message
assert.ok(!JSON.stringify(response.body).includes('sensitive info'));
});

Expand Down
Loading