From d020d8c8cb04c10c5d5d46cd1a596bac150ab0d3 Mon Sep 17 00:00:00 2001 From: urielit1313-creator Date: Tue, 13 Oct 2026 09:13:30 -0600 Subject: [PATCH] fix(config): validate numeric env vars at boot and fail fast (#610) --- src/config/configuration.ts | 38 +++++-- src/config/numeric-config.spec.ts | 135 +++++++++++++++++++++++++ src/config/validate-required-config.ts | 12 +++ 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/config/numeric-config.spec.ts diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 4c3c0a50..fd27fef8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -67,9 +67,31 @@ export interface AppConfig { }; } +/** + * Parses a positive integer from an environment variable string, or falls back + * to `defaultValue` when unset or empty. Fails fast with a descriptive Error + * if the string is non-numeric, zero, negative, or not an integer (#610). + */ +export function parsePositiveIntegerEnv( + value: string | undefined, + defaultValue: number, + varName: string, +): number { + if (value === undefined || value.trim() === '') { + return defaultValue; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `Invalid environment variable ${varName}: expected a positive integer, got "${value}"`, + ); + } + return parsed; +} + export default (): AppConfig => ({ env: process.env.NODE_ENV ?? 'development', - port: parseInt(process.env.PORT ?? '4000', 10), + port: parsePositiveIntegerEnv(process.env.PORT, 4000, 'PORT'), appUrl: process.env.APP_URL ?? 'http://localhost:4000', frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000', logLevel: process.env.LOG_LEVEL ?? 'debug', @@ -96,9 +118,10 @@ export default (): AppConfig => ({ }, analytics: { - platformSummaryTtlMs: parseInt( - process.env.ANALYTICS_PLATFORM_SUMMARY_TTL_MS ?? '60000', - 10, + platformSummaryTtlMs: parsePositiveIntegerEnv( + process.env.ANALYTICS_PLATFORM_SUMMARY_TTL_MS, + 60000, + 'ANALYTICS_PLATFORM_SUMMARY_TTL_MS', ), }, stellar: { @@ -118,9 +141,10 @@ export default (): AppConfig => ({ USDC: process.env.USDC_TOKEN_CONTRACT_ID ?? '', XLM: process.env.XLM_TOKEN_CONTRACT_ID ?? '', }, - escrowDeadlineSeconds: parseInt( - process.env.ESCROW_DEADLINE_SECONDS ?? '7776000', - 10, + escrowDeadlineSeconds: parsePositiveIntegerEnv( + process.env.ESCROW_DEADLINE_SECONDS, + 7776000, + 'ESCROW_DEADLINE_SECONDS', ), }, }); diff --git a/src/config/numeric-config.spec.ts b/src/config/numeric-config.spec.ts new file mode 100644 index 00000000..8b7fac3a --- /dev/null +++ b/src/config/numeric-config.spec.ts @@ -0,0 +1,135 @@ +import configuration, { parsePositiveIntegerEnv } from './configuration'; +import { + collectConfigIssues, + assertRequiredConfig, +} from './validate-required-config'; + +describe('Numeric Configuration Validation (#610)', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('parsePositiveIntegerEnv', () => { + it('returns parsed integer for valid positive integer strings', () => { + expect(parsePositiveIntegerEnv('3000', 8080, 'PORT')).toBe(3000); + expect( + parsePositiveIntegerEnv('7776000', 3600, 'ESCROW_DEADLINE_SECONDS'), + ).toBe(7776000); + expect( + parsePositiveIntegerEnv( + '60000', + 1000, + 'ANALYTICS_PLATFORM_SUMMARY_TTL_MS', + ), + ).toBe(60000); + }); + + it('returns default value when input is undefined or empty/whitespace', () => { + expect(parsePositiveIntegerEnv(undefined, 3000, 'PORT')).toBe(3000); + expect(parsePositiveIntegerEnv('', 3000, 'PORT')).toBe(3000); + expect(parsePositiveIntegerEnv(' ', 3000, 'PORT')).toBe(3000); + }); + + it('throws descriptive error on alphanumeric typos (like 90d)', () => { + expect(() => + parsePositiveIntegerEnv('90d', 7776000, 'ESCROW_DEADLINE_SECONDS'), + ).toThrow( + 'Invalid environment variable ESCROW_DEADLINE_SECONDS: expected a positive integer, got "90d"', + ); + + expect(() => parsePositiveIntegerEnv('abc', 3000, 'PORT')).toThrow( + 'Invalid environment variable PORT: expected a positive integer, got "abc"', + ); + }); + + it('throws on zero, negative integers, and decimals', () => { + expect(() => parsePositiveIntegerEnv('0', 3000, 'PORT')).toThrow( + 'Invalid environment variable PORT: expected a positive integer, got "0"', + ); + + expect(() => parsePositiveIntegerEnv('-1', 3000, 'PORT')).toThrow( + 'Invalid environment variable PORT: expected a positive integer, got "-1"', + ); + + expect(() => parsePositiveIntegerEnv('3000.5', 3000, 'PORT')).toThrow( + 'Invalid environment variable PORT: expected a positive integer, got "3000.5"', + ); + }); + }); + + describe('configuration() boot fail-fast', () => { + it('throws immediately when ESCROW_DEADLINE_SECONDS is misconfigured', () => { + process.env.ESCROW_DEADLINE_SECONDS = '90d'; + expect(() => configuration()).toThrow( + 'Invalid environment variable ESCROW_DEADLINE_SECONDS: expected a positive integer, got "90d"', + ); + }); + + it('throws immediately when PORT is misconfigured', () => { + process.env.PORT = 'invalid-port'; + expect(() => configuration()).toThrow( + 'Invalid environment variable PORT: expected a positive integer, got "invalid-port"', + ); + }); + + it('throws immediately when ANALYTICS_PLATFORM_SUMMARY_TTL_MS is misconfigured', () => { + process.env.ANALYTICS_PLATFORM_SUMMARY_TTL_MS = '-50'; + expect(() => configuration()).toThrow( + 'Invalid environment variable ANALYTICS_PLATFORM_SUMMARY_TTL_MS: expected a positive integer, got "-50"', + ); + }); + }); + + describe('collectConfigIssues & assertRequiredConfig in production', () => { + const validProdConfig = { + env: 'production', + jwt: { secret: 'super-long-secure-random-jwt-secret-key-12345' }, + github: { + clientId: 'real_client_id', + clientSecret: 'real_client_secret', + webhookSecret: 'real_webhook_secret', + }, + stellar: { + escrowContractId: 'CA_ESCROW_REAL_CONTRACT', + treasurySecret: 'S_REAL_TREASURY_SECRET', + escrowDeadlineSeconds: 7776000, + }, + database: { + url: 'postgresql://prod_user:secret_pass@prod-cluster:5432/mergefi', + }, + }; + + it('passes when escrowDeadlineSeconds is a valid positive integer', () => { + const issues = collectConfigIssues(validProdConfig); + expect(issues).toEqual([]); + expect(() => assertRequiredConfig(validProdConfig)).not.toThrow(); + }); + + it('catches non-positive or NaN escrowDeadlineSeconds in production', () => { + const invalidConfig = { + ...validProdConfig, + stellar: { + ...validProdConfig.stellar, + escrowDeadlineSeconds: NaN, + }, + }; + + const issues = collectConfigIssues(invalidConfig); + expect(issues).toContainEqual({ + key: 'ESCROW_DEADLINE_SECONDS', + message: + 'ESCROW_DEADLINE_SECONDS must be a positive integer — escrow deadline cannot be computed', + }); + + expect(() => assertRequiredConfig(invalidConfig)).toThrow( + 'ESCROW_DEADLINE_SECONDS must be a positive integer', + ); + }); + }); +}); diff --git a/src/config/validate-required-config.ts b/src/config/validate-required-config.ts index 8008c69c..9f5a33cf 100644 --- a/src/config/validate-required-config.ts +++ b/src/config/validate-required-config.ts @@ -109,6 +109,18 @@ export function collectConfigIssues(config: RequiredConfig): ConfigIssue[] { 'escrow release/refund transactions cannot be signed', ); + if ( + typeof config.stellar.escrowDeadlineSeconds !== 'number' || + !Number.isInteger(config.stellar.escrowDeadlineSeconds) || + config.stellar.escrowDeadlineSeconds <= 0 + ) { + issues.push({ + key: 'ESCROW_DEADLINE_SECONDS', + message: + 'ESCROW_DEADLINE_SECONDS must be a positive integer — escrow deadline cannot be computed', + }); + } + return issues; }