diff --git a/docs/CONFIGURATION_SCHEMA_VALIDATION.md b/docs/CONFIGURATION_SCHEMA_VALIDATION.md new file mode 100644 index 00000000..654d6425 --- /dev/null +++ b/docs/CONFIGURATION_SCHEMA_VALIDATION.md @@ -0,0 +1,26 @@ +# 🛡️ Configuration Schema Validation + +This document details the deterministic schema validation engine implemented for NotifyChain application configuration (Issue #694). + +--- + +## 1. Schema Specifications + +| Variable | Type | Required | Allowed Values / Bounds | Description | +|---|---|---|---|---| +| `CONTRACT_ADDRESSES` | `JSON Array` | **Yes** | Array of `C...` StrKey addresses | Soroban contracts to monitor | +| `STELLAR_NETWORK` | `Enum` | No | `local`, `testnet`, `mainnet`, `standalone` | Target Stellar network | +| `STELLAR_RPC_URL` | `URL` | No | Valid `http://` or `https://` URL | Soroban RPC endpoint | +| `POLL_INTERVAL_MS` | `Integer` | No | `100 .. 3600000` | Ledger poll cycle interval | +| `LOG_LEVEL` | `Enum` | No | `debug`, `info`, `warn`, `error` | Winston log severity level | +| `PORT` | `Integer` | No | `1 .. 65535` | HTTP server port | + +--- + +## 2. Actionable Error Reporting + +Validation errors collect all schema violations simultaneously and pinpoint the exact failing field: + +``` +ConfigSchemaValidationError: Configuration schema validation failed: [Field: CONTRACT_ADDRESSES] Required environment variable is missing; [Field: STELLAR_NETWORK] Invalid network. Allowed values: local, testnet, mainnet, standalone. +``` diff --git a/listener/src/config/schema-validator.test.ts b/listener/src/config/schema-validator.test.ts new file mode 100644 index 00000000..426cc1fb --- /dev/null +++ b/listener/src/config/schema-validator.test.ts @@ -0,0 +1,72 @@ +import { + validateConfigSchema, + ConfigSchemaValidationError, +} from './schema-validator'; + +describe('Configuration Schema Validation (Issue #694)', () => { + const validEnv = { + CONTRACT_ADDRESSES: JSON.stringify([ + 'CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA64P7TV5A4W', + ]), + STELLAR_NETWORK: 'testnet', + STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', + POLL_INTERVAL_MS: '2000', + LOG_LEVEL: 'info', + PORT: '8080', + }; + + test('passes validation with valid configuration', () => { + const config = validateConfigSchema(validEnv); + + expect(config.contractAddresses).toHaveLength(1); + expect(config.stellarNetwork).toBe('testnet'); + expect(config.pollIntervalMs).toBe(2000); + expect(config.port).toBe(8080); + expect(config.logLevel).toBe('info'); + }); + + test('fails with descriptive error when required field is missing', () => { + expect(() => validateConfigSchema({})).toThrow(ConfigSchemaValidationError); + + try { + validateConfigSchema({}); + } catch (err: unknown) { + const e = err as ConfigSchemaValidationError; + expect(e.errors.some((err) => err.field === 'CONTRACT_ADDRESSES')).toBe(true); + } + }); + + test('fails with field identification when enum value is invalid', () => { + const invalidNetworkEnv = { + ...validEnv, + STELLAR_NETWORK: 'unknown-chain', + }; + + try { + validateConfigSchema(invalidNetworkEnv); + fail('Should have thrown validation error'); + } catch (err: unknown) { + const e = err as ConfigSchemaValidationError; + const netError = e.errors.find((err) => err.field === 'STELLAR_NETWORK'); + expect(netError).toBeDefined(); + expect(netError?.message).toContain('Allowed values'); + } + }); + + test('fails when numeric values are out of bounds', () => { + const invalidNumericEnv = { + ...validEnv, + POLL_INTERVAL_MS: '10', // Below minimum 100ms + PORT: '70000', // Above 65535 + }; + + try { + validateConfigSchema(invalidNumericEnv); + fail('Should have thrown validation error'); + } catch (err: unknown) { + const e = err as ConfigSchemaValidationError; + expect(e.errors.some((err) => err.field === 'POLL_INTERVAL_MS')).toBe(true); + expect(e.errors.some((err) => err.field === 'PORT')).toBe(true); + } + }); +}); diff --git a/listener/src/config/schema-validator.ts b/listener/src/config/schema-validator.ts new file mode 100644 index 00000000..80ca10c2 --- /dev/null +++ b/listener/src/config/schema-validator.ts @@ -0,0 +1,159 @@ +/** + * Configuration Schema Validation Engine (Issue #694) + * + * Provides deterministic, schema-based validation for application environment + * variables and runtime configurations, precisely identifying failing fields. + */ + +export interface ConfigValidationErrorDetail { + field: string; + value: unknown; + message: string; +} + +export class ConfigSchemaValidationError extends Error { + public readonly errors: ConfigValidationErrorDetail[]; + + constructor(errors: ConfigValidationErrorDetail[]) { + const summary = errors.map((e) => `[Field: ${e.field}] ${e.message}`).join('; '); + super(`Configuration schema validation failed: ${summary}`); + this.name = 'ConfigSchemaValidationError'; + this.errors = errors; + } +} + +export const ALLOWED_NETWORKS = ['local', 'testnet', 'mainnet', 'standalone'] as const; +export type StellarNetwork = (typeof ALLOWED_NETWORKS)[number]; + +export const ALLOWED_LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const; +export type LogLevel = (typeof ALLOWED_LOG_LEVELS)[number]; + +export interface ValidatedAppConfig { + contractAddresses: string[]; + stellarNetwork: StellarNetwork; + rpcUrl: string; + networkPassphrase?: string; + pollIntervalMs: number; + logLevel: LogLevel; + port: number; + enableAnalytics: boolean; +} + +/** + * Validates a raw environment map against the strict configuration schema. + */ +export function validateConfigSchema( + rawEnv: Record = process.env +): ValidatedAppConfig { + const errors: ConfigValidationErrorDetail[] = []; + + // 1. Validate CONTRACT_ADDRESSES (Required, JSON Array of C... addresses) + let contractAddresses: string[] = []; + const rawContracts = rawEnv.CONTRACT_ADDRESSES?.trim(); + + if (!rawContracts) { + errors.push({ + field: 'CONTRACT_ADDRESSES', + value: rawContracts, + message: 'Required environment variable is missing or empty.', + }); + } else { + try { + const parsed = JSON.parse(rawContracts); + if (!Array.isArray(parsed) || parsed.length === 0) { + errors.push({ + field: 'CONTRACT_ADDRESSES', + value: rawContracts, + message: 'Must be a non-empty JSON array of contract objects or strings.', + }); + } else { + for (let i = 0; i < parsed.length; i++) { + const item = parsed[i]; + const address = typeof item === 'object' && item !== null && 'address' in item ? item.address : item; + if (typeof address !== 'string' || address.length < 50) { + errors.push({ + field: `CONTRACT_ADDRESSES[${i}]`, + value: address, + message: 'Invalid Soroban contract address format (must start with C and be valid StrKey).', + }); + } else { + contractAddresses.push(address); + } + } + } + } catch { + errors.push({ + field: 'CONTRACT_ADDRESSES', + value: rawContracts, + message: 'Must be a valid JSON-encoded string array.', + }); + } + } + + // 2. Validate STELLAR_NETWORK (Enum) + const rawNetwork = (rawEnv.STELLAR_NETWORK?.trim().toLowerCase() || 'testnet') as StellarNetwork; + if (!ALLOWED_NETWORKS.includes(rawNetwork)) { + errors.push({ + field: 'STELLAR_NETWORK', + value: rawEnv.STELLAR_NETWORK, + message: `Invalid network. Allowed values: ${ALLOWED_NETWORKS.join(', ')}.`, + }); + } + + // 3. Validate RPC URL + const rawRpcUrl = rawEnv.STELLAR_RPC_URL?.trim() || rawEnv.SOROBAN_RPC_URL?.trim() || 'https://soroban-testnet.stellar.org'; + if (!rawRpcUrl.startsWith('http://') && !rawRpcUrl.startsWith('https://')) { + errors.push({ + field: 'STELLAR_RPC_URL', + value: rawRpcUrl, + message: 'Must be a valid HTTP or HTTPS endpoint URL.', + }); + } + + // 4. Validate POLL_INTERVAL_MS (Numeric Range) + const rawPoll = rawEnv.POLL_INTERVAL_MS?.trim() || '5000'; + const pollIntervalMs = parseInt(rawPoll, 10); + if (isNaN(pollIntervalMs) || pollIntervalMs < 100 || pollIntervalMs > 3600000) { + errors.push({ + field: 'POLL_INTERVAL_MS', + value: rawPoll, + message: 'Must be an integer between 100ms and 3,600,000ms (1 hour).', + }); + } + + // 5. Validate LOG_LEVEL (Enum) + const rawLogLevel = (rawEnv.LOG_LEVEL?.trim().toLowerCase() || 'info') as LogLevel; + if (!ALLOWED_LOG_LEVELS.includes(rawLogLevel)) { + errors.push({ + field: 'LOG_LEVEL', + value: rawEnv.LOG_LEVEL, + message: `Invalid log level. Allowed values: ${ALLOWED_LOG_LEVELS.join(', ')}.`, + }); + } + + // 6. Validate PORT (Numeric Range) + const rawPort = rawEnv.PORT?.trim() || '3000'; + const port = parseInt(rawPort, 10); + if (isNaN(port) || port < 1 || port > 65535) { + errors.push({ + field: 'PORT', + value: rawPort, + message: 'Must be a valid TCP port number between 1 and 65535.', + }); + } + + if (errors.length > 0) { + throw new ConfigSchemaValidationError(errors); + } + + return { + contractAddresses, + stellarNetwork: rawNetwork, + rpcUrl: rawRpcUrl, + networkPassphrase: rawEnv.STELLAR_NETWORK_PASSPHRASE?.trim(), + pollIntervalMs, + logLevel: rawLogLevel, + port, + enableAnalytics: rawEnv.ENABLE_ANALYTICS !== 'false', + }; +}