diff --git a/backend/src/config/__tests__/cors-config.spec.ts b/backend/src/config/__tests__/cors-config.spec.ts new file mode 100644 index 00000000..f8551302 --- /dev/null +++ b/backend/src/config/__tests__/cors-config.spec.ts @@ -0,0 +1,97 @@ +import configuration from '../configuration'; + +describe('CORS Configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return default CORS config when env vars are not set', () => { + delete process.env.CORS_ENABLED; + delete process.env.CORS_ORIGINS; + delete process.env.CORS_METHODS; + delete process.env.CORS_ALLOWED_HEADERS; + delete process.env.CORS_CREDENTIALS; + delete process.env.CORS_MAX_AGE; + + const config = configuration(); + expect(config.cors.enabled).toBe(true); + expect(config.cors.origins).toEqual(['http://localhost:3000']); + expect(config.cors.methods).toEqual([ + 'GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS', + ]); + expect(config.cors.allowedHeaders).toEqual([ + 'Content-Type', 'Authorization', 'Accept', + ]); + expect(config.cors.credentials).toBe(true); + expect(config.cors.maxAge).toBe(86400); + }); + + it('should parse comma-separated CORS_ORIGINS', () => { + process.env.CORS_ORIGINS = 'https://app.nestera.io,https://admin.nestera.io'; + + const config = configuration(); + expect(config.cors.origins).toEqual([ + 'https://app.nestera.io', + 'https://admin.nestera.io', + ]); + }); + + it('should disable CORS when CORS_ENABLED is set to false string', () => { + process.env.CORS_ENABLED = String(false); + + const config = configuration(); + expect(config.cors.enabled).toBe(false); + }); + + it('should parse custom CORS_METHODS', () => { + process.env.CORS_METHODS = 'GET,POST,OPTIONS'; + + const config = configuration(); + expect(config.cors.methods).toEqual(['GET', 'POST', 'OPTIONS']); + }); + + it('should parse custom CORS_ALLOWED_HEADERS', () => { + process.env.CORS_ALLOWED_HEADERS = 'Content-Type,Authorization,X-Custom-Header'; + + const config = configuration(); + expect(config.cors.allowedHeaders).toEqual([ + 'Content-Type', + 'Authorization', + 'X-Custom-Header', + ]); + }); + + it('should parse CORS_MAX_AGE as integer', () => { + process.env.CORS_MAX_AGE = '3600'; + + const config = configuration(); + expect(config.cors.maxAge).toBe(3600); + }); + + it('should trim whitespace from origins', () => { + process.env.CORS_ORIGINS = ' https://app.nestera.io , https://admin.nestera.io '; + + const config = configuration(); + expect(config.cors.origins).toEqual([ + 'https://app.nestera.io', + 'https://admin.nestera.io', + ]); + }); + + it('should filter empty origins from comma-separated list', () => { + process.env.CORS_ORIGINS = 'https://app.nestera.io,,,https://admin.nestera.io'; + + const config = configuration(); + expect(config.cors.origins).toEqual([ + 'https://app.nestera.io', + 'https://admin.nestera.io', + ]); + }); +}); diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index c995fed9..2bf91c92 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -117,6 +117,21 @@ export default () => ({ awsSecretAccessKey: process.env.AUDIT_AWS_SECRET_ACCESS_KEY, }, }, + cors: { + enabled: process.env.CORS_ENABLED !== 'false', + origins: (process.env.CORS_ORIGINS || 'http://localhost:3000') + .split(',') + .map((o) => o.trim()) + .filter(Boolean), + methods: ( + process.env.CORS_METHODS || 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS' + ).split(','), + allowedHeaders: ( + process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization,Accept' + ).split(','), + credentials: process.env.CORS_CREDENTIALS === 'true', + maxAge: parseInt(process.env.CORS_MAX_AGE || '86400', 10), + }, balanceSync: { cacheTtlSeconds: parseInt( process.env.BALANCE_CACHE_TTL_SECONDS || '300', diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 1c965a57..c25dab89 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -63,4 +63,13 @@ export const envValidationSchema = Joi.object({ BACKUP_TEST_DB_PORT: Joi.number().port().default(5432).optional(), BACKUP_TEST_DB_USER: Joi.string().optional(), BACKUP_TEST_DB_PASSWORD: Joi.string().optional(), - BACKUP_TEST_DB_NAME: Joi.string().default('nestera_restore_test').optional(),}).or('DATABASE_URL', 'DB_HOST'); // enforce at least one DB connection strategy + BACKUP_TEST_DB_NAME: Joi.string().default('nestera_restore_test').optional(), + + // ── CORS ─────────────────────────────────────────────────────────────────── + CORS_ENABLED: Joi.boolean().default(true).optional(), + CORS_ORIGINS: Joi.string().optional(), + CORS_METHODS: Joi.string().optional(), + CORS_ALLOWED_HEADERS: Joi.string().optional(), + CORS_CREDENTIALS: Joi.boolean().default(true).optional(), + CORS_MAX_AGE: Joi.number().integer().min(0).default(86400).optional(), +}).or('DATABASE_URL', 'DB_HOST'); // enforce at least one DB connection strategy diff --git a/backend/src/main.ts b/backend/src/main.ts index 7f2921d1..b34db4dd 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -32,6 +32,22 @@ async function bootstrap() { defaultVersion: CURRENT_VERSION, }); + // CORS configuration — environment-based allowed origins + const corsOrigins = configService.get('cors.origins'); + const corsEnabled = configService.get('cors.enabled'); + if (corsEnabled) { + app.enableCors({ + origin: corsOrigins, + methods: configService.get('cors.methods'), + allowedHeaders: configService.get('cors.allowedHeaders'), + credentials: configService.get('cors.credentials'), + maxAge: configService.get('cors.maxAge'), + }); + logger.log(`CORS enabled for origins: ${corsOrigins.join(', ')}`); + } else { + logger.warn('CORS is disabled — not recommended for production'); + } + // Apply security headers middleware app.use(helmet.default()); app.use(createSecurityHeadersMiddleware());