Skip to content
Open
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
97 changes: 97 additions & 0 deletions backend/src/config/__tests__/cors-config.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
15 changes: 15 additions & 0 deletions backend/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ async function bootstrap() {
defaultVersion: CURRENT_VERSION,
});

// CORS configuration — environment-based allowed origins
const corsOrigins = configService.get<string[]>('cors.origins');
const corsEnabled = configService.get<boolean>('cors.enabled');
if (corsEnabled) {
app.enableCors({
origin: corsOrigins,
methods: configService.get<string[]>('cors.methods'),
allowedHeaders: configService.get<string[]>('cors.allowedHeaders'),
credentials: configService.get<boolean>('cors.credentials'),
maxAge: configService.get<number>('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());
Expand Down