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
3 changes: 3 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import dotenv from 'dotenv';
import { z } from 'zod';
import { applyEnvironmentFileDefaults } from './config/environments/index.js';

dotenv.config();
// Apply environment-specific defaults before validation (environment file < process.env)
applyEnvironmentFileDefaults();

const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production', 'test']).default('development'),
Expand Down
138 changes: 138 additions & 0 deletions backend/src/config/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { validateEnv, config, clearEnvCache } from '../env.js';

describe('Config Module', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
clearEnvCache();
vi.stubGlobal('process', {
...process,
env: process.env,
exit: vi.fn() as any,
});
});

afterEach(() => {
process.env = originalEnv;
vi.unstubAllGlobals();
});

describe('validateEnv', () => {
it('throws and exits when OPENAI_API_KEY is missing', () => {
delete process.env.OPENAI_API_KEY;

expect(() => validateEnv()).toThrow();
expect(process.exit).toHaveBeenCalledWith(1);
});

it('successfully parses valid environment variables', () => {
process.env.OPENAI_API_KEY = 'test-key';
process.env.PORT = '4000';
process.env.STELLAR_NETWORK = 'public';

const parsed = validateEnv();

expect(parsed.OPENAI_API_KEY).toBe('test-key');
expect(parsed.PORT).toBe(4000);
expect(parsed.STELLAR_NETWORK).toBe('public');
});

it('uses default values for optional variables', () => {
process.env.OPENAI_API_KEY = 'test-key';
delete process.env.PORT;
delete process.env.STELLAR_NETWORK;

const parsed = validateEnv();

expect(parsed.PORT).toBe(3001);
expect(parsed.STELLAR_NETWORK).toBe('testnet');
});

it('transforms JOBS_ENABLED correctly', () => {
process.env.OPENAI_API_KEY = 'test-key';

process.env.JOBS_ENABLED = 'false';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(false);

process.env.JOBS_ENABLED = 'true';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(true);

process.env.JOBS_ENABLED = 'any-other-string';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(true);
});

it('transforms QUEUE_ENABLED correctly', () => {
process.env.OPENAI_API_KEY = 'test-key';

process.env.QUEUE_ENABLED = 'false';
clearEnvCache();
expect(validateEnv().QUEUE_ENABLED).toBe(false);

process.env.QUEUE_ENABLED = 'true';
clearEnvCache();
expect(validateEnv().QUEUE_ENABLED).toBe(true);
});

it('validates CORS_ALLOWED_ORIGINS', () => {
process.env.OPENAI_API_KEY = 'test-key';
process.env.CORS_ALLOWED_ORIGINS = 'https://example.com';

const parsed = validateEnv();
expect(parsed.CORS_ALLOWED_ORIGINS).toBe('https://example.com');
});

it('validates RATE_LIMIT values', () => {
process.env.OPENAI_API_KEY = 'test-key';
process.env.RATE_LIMIT_FREE = '50';
process.env.RATE_LIMIT_PRO = '200';
process.env.RATE_LIMIT_ENTERPRISE = '500';
process.env.RATE_LIMIT_WINDOW_MS = '60000';

const parsed = validateEnv();

expect(parsed.RATE_LIMIT_FREE).toBe(50);
expect(parsed.RATE_LIMIT_PRO).toBe(200);
expect(parsed.RATE_LIMIT_ENTERPRISE).toBe(500);
expect(parsed.RATE_LIMIT_WINDOW_MS).toBe(60000);
});

it('validates IP_ALLOWLIST settings', () => {
process.env.OPENAI_API_KEY = 'test-key';
process.env.IP_ALLOWLIST = '192.168.1.1,10.0.0.1';
process.env.IP_ALLOWLIST_ENABLED = 'true';
process.env.IP_ALLOWLIST_BYPASS_ENABLED = 'true';
process.env.IP_ALLOWLIST_BYPASS_EXPIRY_MS = '1800000';

const parsed = validateEnv();

expect(parsed.IP_ALLOWLIST).toBe('192.168.1.1,10.0.0.1');
expect(parsed.IP_ALLOWLIST_ENABLED).toBe(true);
expect(parsed.IP_ALLOWLIST_BYPASS_ENABLED).toBe(true);
expect(parsed.IP_ALLOWLIST_BYPASS_EXPIRY_MS).toBe(1800000);
});
});

describe('config', () => {
it('returns cached config after first call', () => {
process.env.OPENAI_API_KEY = 'test-key';

const first = config();
const second = config();

expect(first).toBe(second);
});

it('validates on first call', () => {
process.env.OPENAI_API_KEY = 'test-key';

const parsed = config();

expect(parsed.OPENAI_API_KEY).toBe('test-key');
});
});
});
19 changes: 10 additions & 9 deletions backend/src/config/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { validateEnv, config } from '../env.js';
import { validateEnv, config, clearEnvCache } from '../env.js';
import { z } from 'zod';

describe('Environment Validation', () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
// Clear the cached config
clearEnvCache();
vi.stubGlobal('process', {
...process,
exit: vi.fn() as any,
Expand All @@ -22,10 +21,9 @@ describe('Environment Validation', () => {

it('throws and exits when OPENAI_API_KEY is missing', () => {
delete process.env.OPENAI_API_KEY;

// We expect process.exit(1) to be called
validateEnv();


// validateEnv calls process.exit(1) and throws in test (mocked exit)
expect(() => validateEnv()).toThrow();
expect(process.exit).toHaveBeenCalledWith(1);
});

Expand Down Expand Up @@ -54,14 +52,17 @@ describe('Environment Validation', () => {

it('transforms JOBS_ENABLED correctly', () => {
process.env.OPENAI_API_KEY = 'test-key';

process.env.JOBS_ENABLED = 'false';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(false);

process.env.JOBS_ENABLED = 'true';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(true);

process.env.JOBS_ENABLED = 'any-other-string';
clearEnvCache();
expect(validateEnv().JOBS_ENABLED).toBe(true);
});
});
176 changes: 176 additions & 0 deletions backend/src/config/__tests__/environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import {
resolveEnvironmentName,
getEnvironmentOverrides,
applyEnvironmentFileDefaults,
refreshSecretsManagerConfig,
developmentOverrides,
stagingOverrides,
productionOverrides,
} from '../environments';
import type { EnvironmentName } from '../environments/types';

describe('Environment Configuration', () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
vi.stubGlobal('process', {
...process,
env: process.env,
});
});

afterEach(() => {
process.env = originalEnv;
vi.unstubAllGlobals();
});

describe('resolveEnvironmentName', () => {
it('returns development for undefined NODE_ENV', () => {
delete process.env.NODE_ENV;
expect(resolveEnvironmentName(undefined)).toBe('development');
});

it('returns development for development NODE_ENV', () => {
process.env.NODE_ENV = 'development';
expect(resolveEnvironmentName('development')).toBe('development');
});

it('returns staging for staging NODE_ENV', () => {
process.env.NODE_ENV = 'staging';
expect(resolveEnvironmentName('staging')).toBe('staging');
});

it('returns production for production NODE_ENV', () => {
process.env.NODE_ENV = 'production';
expect(resolveEnvironmentName('production')).toBe('production');
});

it('defaults to development for unknown NODE_ENV', () => {
process.env.NODE_ENV = 'unknown';
expect(resolveEnvironmentName('unknown')).toBe('development');
});
});

describe('getEnvironmentOverrides', () => {
it('returns development overrides', () => {
const overrides = getEnvironmentOverrides('development');
expect(overrides.CORS_ALLOWED_ORIGINS).toBe('*');
expect(overrides.STELLAR_NETWORK).toBe('testnet');
expect(overrides.JOBS_ENABLED).toBe('true');
expect(overrides.QUEUE_ENABLED).toBe('true');
expect(overrides.AWS_SECRETS_MANAGER_ENABLED).toBe('false');
});

it('returns staging overrides', () => {
const overrides = getEnvironmentOverrides('staging');
expect(overrides.CORS_ALLOWED_ORIGINS).toBe('https://staging.agenticpay.app');
expect(overrides.STELLAR_NETWORK).toBe('testnet');
expect(overrides.RATE_LIMIT_FREE).toBe('100');
expect(overrides.AWS_SECRETS_MANAGER_ENABLED).toBe('true');
});

it('returns production overrides', () => {
const overrides = getEnvironmentOverrides('production');
expect(overrides.CORS_ALLOWED_ORIGINS).toBe('https://app.agenticpay.io');
expect(overrides.STELLAR_NETWORK).toBe('public');
expect(overrides.RATE_LIMIT_FREE).toBe('60');
expect(overrides.RATE_LIMIT_ENTERPRISE).toBe('2000');
expect(overrides.AWS_SECRETS_MANAGER_SECRET_ID).toBe('agenticpay-prod-app-secrets');
});
});

describe('applyEnvironmentFileDefaults', () => {
it('applies development defaults when NODE_ENV not set', () => {
delete process.env.NODE_ENV;
delete process.env.CORS_ALLOWED_ORIGINS;
delete process.env.STELLAR_NETWORK;

const envName = applyEnvironmentFileDefaults();

expect(envName).toBe('development');
expect(process.env.CORS_ALLOWED_ORIGINS).toBe('*');
expect(process.env.STELLAR_NETWORK).toBe('testnet');
});

it('does not override existing environment variables', () => {
process.env.CORS_ALLOWED_ORIGINS = 'https://custom.com';
process.env.NODE_ENV = 'development';

applyEnvironmentFileDefaults();

expect(process.env.CORS_ALLOWED_ORIGINS).toBe('https://custom.com');
});

it('applies staging defaults', () => {
process.env.NODE_ENV = 'staging';
delete process.env.CORS_ALLOWED_ORIGINS;
delete process.env.STELLAR_NETWORK;

applyEnvironmentFileDefaults();

expect(process.env.CORS_ALLOWED_ORIGINS).toBe('https://staging.agenticpay.app');
expect(process.env.STELLAR_NETWORK).toBe('testnet');
});

it('applies production defaults', () => {
process.env.NODE_ENV = 'production';
delete process.env.CORS_ALLOWED_ORIGINS;
delete process.env.STELLAR_NETWORK;

applyEnvironmentFileDefaults();

expect(process.env.CORS_ALLOWED_ORIGINS).toBe('https://app.agenticpay.io');
expect(process.env.STELLAR_NETWORK).toBe('public');
});
});

describe('Environment Overrides Objects', () => {
it('developmentOverrides has correct values', () => {
expect(developmentOverrides.CORS_ALLOWED_ORIGINS).toBe('*');
expect(developmentOverrides.STELLAR_NETWORK).toBe('testnet');
expect(developmentOverrides.JOBS_ENABLED).toBe('true');
expect(developmentOverrides.QUEUE_ENABLED).toBe('true');
expect(developmentOverrides.AWS_SECRETS_MANAGER_ENABLED).toBe('false');
});

it('stagingOverrides has correct values', () => {
expect(stagingOverrides.CORS_ALLOWED_ORIGINS).toBe('https://staging.agenticpay.app');
expect(stagingOverrides.STELLAR_NETWORK).toBe('testnet');
expect(stagingOverrides.RATE_LIMIT_FREE).toBe('100');
expect(stagingOverrides.AWS_SECRETS_MANAGER_ENABLED).toBe('true');
expect(stagingOverrides.AWS_SECRETS_MANAGER_SECRET_ID).toBe('agenticpay-staging-app-secrets');
});

it('productionOverrides has correct values', () => {
expect(productionOverrides.CORS_ALLOWED_ORIGINS).toBe('https://app.agenticpay.io');
expect(productionOverrides.STELLAR_NETWORK).toBe('public');
expect(productionOverrides.RATE_LIMIT_FREE).toBe('60');
expect(productionOverrides.RATE_LIMIT_PRO).toBe('300');
expect(productionOverrides.RATE_LIMIT_ENTERPRISE).toBe('2000');
expect(productionOverrides.AWS_SECRETS_MANAGER_ENABLED).toBe('true');
expect(productionOverrides.AWS_SECRETS_MANAGER_SECRET_ID).toBe('agenticpay-prod-app-secrets');
});
});

describe('refreshSecretsManagerConfig', () => {
it('returns null when secrets manager not enabled', async () => {
process.env.AWS_SECRETS_MANAGER_ENABLED = 'false';

const result = await refreshSecretsManagerConfig();

expect(result).toBeNull();
});

it('returns null when secret ID not set', async () => {
process.env.AWS_SECRETS_MANAGER_ENABLED = 'true';
delete process.env.AWS_SECRETS_MANAGER_SECRET_ID;

const result = await refreshSecretsManagerConfig();

expect(result).toBeNull();
});
});
});
Loading