From ed55eb0962a73c14dc4aa77c96b697eb0a6f8a25 Mon Sep 17 00:00:00 2001 From: leo1987820 <290468635+leo1987820@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:24:43 +0800 Subject: [PATCH] feat: validate environment configuration --- .env.example | 41 ++++++++++++++--------- README.md | 28 ++++++++-------- package-lock.json | 19 +++++++++++ package.json | 1 + src/config.js | 62 ++++++++++++++++++++++++++++------- src/services/cache.js | 3 +- test/config.test.js | 75 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 test/config.test.js diff --git a/.env.example b/.env.example index 783c72f..da00ec5 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,41 @@ -# Server +# Runtime environment +# NODE_ENV: string enum (development, test, production). Default: development. +NODE_ENV=development + +# PORT: number. Default: 3000. PORT=3000 -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= +# REDIS_URL: URL. Required in production. Development/test default: redis://localhost:6379. +REDIS_URL=redis://localhost:6379 + +# DATABASE_URL: URL. Required in production. Development default: postgres://localhost/smartdrop. Test default: postgres://localhost/smartdrop_test. +DATABASE_URL=postgres://localhost/smartdrop -# Stellar Horizon +# STELLAR_HORIZON_URL: URL. Default: https://horizon.stellar.org. STELLAR_HORIZON_URL=https://horizon.stellar.org -# Stellar USDC Issuer +# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer. USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA -# CoinGecko (optional, free tier available) +# COINGECKO_API_KEY: string. Optional. Default: empty. COINGECKO_API_KEY= -# CoinMarketCap (requires API key) +# COINMARKETCAP_API_KEY: string. Optional. Default: empty. COINMARKETCAP_API_KEY= -# Price Oracle Settings -PRICE_CACHE_TTL=60 -PRICE_REFRESH_INTERVAL=30 -PRICE_STALE_THRESHOLD=5 -PRICE_ANOMALY_THRESHOLD=10 +# PRICE_CACHE_TTL_SECONDS: number. Default: 60. +PRICE_CACHE_TTL_SECONDS=60 + +# PRICE_REFRESH_INTERVAL_SECONDS: number. Default: 30. +PRICE_REFRESH_INTERVAL_SECONDS=30 + +# PRICE_STALE_THRESHOLD_MINUTES: number. Default: 5. +PRICE_STALE_THRESHOLD_MINUTES=5 + +# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20. +PRICE_ANOMALY_THRESHOLD_PCT=20 -# Logging +# LOG_LEVEL: string enum (debug, info, warn, error). Default: info. LOG_LEVEL=info # CORS diff --git a/README.md b/README.md index 986083f..357bdc5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Multi-source price oracle that fetches and caches USD prices for Stellar assets. - Redis caching with configurable TTL (default: 60s) - Background job refreshes prices every 30 seconds - Stale price detection (>5 minutes) -- Price anomaly logging (>10% changes) +- Price anomaly logging (>20% changes) - Fallback chain: DEX → CoinGecko → CoinMarketCap → cached ## Setup @@ -80,19 +80,19 @@ cp .env.example .env | Variable | Description | Default | Required | |----------|-------------|---------|----------| +| `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No | | `PORT` | Server port | 3000 | No | -| `REDIS_HOST` | Redis server host | localhost | No | -| `REDIS_PORT` | Redis server port | 6379 | No | -| `REDIS_PASSWORD` | Redis password | undefined | No | +| `REDIS_URL` | Redis connection URL | redis://localhost:6379 in development/test | Yes in production | +| `DATABASE_URL` | Database connection URL reserved for persistence-backed features | postgres://localhost/smartdrop in development, postgres://localhost/smartdrop_test in test | Yes in production | | `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No | | `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No | -| `COINGECKO_API_KEY` | CoinGecko API key | undefined | No | -| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | undefined | No | -| `PRICE_CACHE_TTL` | Cache TTL in seconds | 60 | No | -| `PRICE_REFRESH_INTERVAL` | Refresh interval in seconds | 30 | No | -| `PRICE_STALE_THRESHOLD` | Stale threshold in minutes | 5 | No | -| `PRICE_ANOMALY_THRESHOLD` | Anomaly detection threshold % | 10 | No | -| `LOG_LEVEL` | Logging level | info | No | +| `COINGECKO_API_KEY` | CoinGecko API key | empty | No | +| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | empty | No | +| `PRICE_CACHE_TTL_SECONDS` | Cache TTL in seconds | 60 | No | +| `PRICE_REFRESH_INTERVAL_SECONDS` | Refresh interval in seconds | 30 | No | +| `PRICE_STALE_THRESHOLD_MINUTES` | Stale threshold in minutes | 5 | No | +| `PRICE_ANOMALY_THRESHOLD_PCT` | Anomaly detection threshold % | 20 | No | +| `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, or `error` | info | No | ### Running @@ -245,8 +245,8 @@ module.exports = { fetchPrice }; If you see "Redis connection error" in logs: - Verify Redis is running: `redis-cli ping` -- Check Redis host and port in `.env` -- If using a password, ensure `REDIS_PASSWORD` is set correctly +- Check `REDIS_URL` in `.env` +- If Redis requires a password, include it in the connection URL ### Price Not Available @@ -267,7 +267,7 @@ External APIs may rate limit requests: The service logs important events: - Price fetches from each source -- Price anomalies (>10% changes) +- Price anomalies (>20% changes) - Stale price warnings - Cache refresh cycles - API errors diff --git a/package-lock.json b/package-lock.json index d0f9074..b72bf3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.7.0", "cors": "^2.8.5", + "envalid": "^8.2.0", "express": "^4.21.0", "helmet": "^8.2.0", "ioredis": "^5.4.1", @@ -2261,6 +2262,18 @@ "node": ">= 0.8" } }, + "node_modules/envalid": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.2.0.tgz", + "integrity": "sha512-CkPvea95dwMYE1wnKX5mQXkOpiMs9O+ncv8NqZy+gW7FuzpUp06KTdUHb18xFG8CqQHmfmRqGLF5DuGaBWNrSw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5350,6 +5363,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/package.json b/package.json index 53eeef8..fd7cbf8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "axios": "^1.7.0", "cors": "^2.8.5", + "envalid": "^8.2.0", "express": "^4.21.0", "helmet": "^8.2.0", "ioredis": "^5.4.1", diff --git a/src/config.js b/src/config.js index b99a1ac..c445953 100644 --- a/src/config.js +++ b/src/config.js @@ -1,29 +1,67 @@ require('dotenv').config(); +const { cleanEnv, makeValidator, num, port, str, url } = require('envalid'); + +const stellarAddress = makeValidator((input) => { + if (!/^G[A-Z0-9]{55}$/.test(input)) { + throw new Error('must be a valid Stellar public key'); + } + return input; +}); + +const databaseDevDefault = + process.env.NODE_ENV === 'test' + ? 'postgres://localhost/smartdrop_test' + : 'postgres://localhost/smartdrop'; + +const env = cleanEnv(process.env, { + NODE_ENV: str({ + default: 'development', + choices: ['development', 'test', 'production'], + }), + PORT: port({ default: 3000 }), + REDIS_URL: url({ devDefault: 'redis://localhost:6379' }), + DATABASE_URL: url({ devDefault: databaseDevDefault }), + STELLAR_HORIZON_URL: url({ default: 'https://horizon.stellar.org' }), + USDC_ISSUER: stellarAddress({ + default: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + }), + COINGECKO_API_KEY: str({ default: '' }), + COINMARKETCAP_API_KEY: str({ default: '' }), + PRICE_CACHE_TTL_SECONDS: num({ default: 60 }), + PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }), + PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }), + PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }), + LOG_LEVEL: str({ + default: 'info', + choices: ['debug', 'info', 'warn', 'error'], + }), +}); + module.exports = { - port: process.env.PORT || 3000, + nodeEnv: env.NODE_ENV, + port: env.PORT, + databaseUrl: env.DATABASE_URL, redis: { - host: process.env.REDIS_HOST || 'localhost', - port: process.env.REDIS_PORT || 6379, - password: process.env.REDIS_PASSWORD || undefined, + url: env.REDIS_URL, }, stellar: { - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', - usdcIssuer: process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + horizonUrl: env.STELLAR_HORIZON_URL, + usdcIssuer: env.USDC_ISSUER, }, coingecko: { - apiKey: process.env.COINGECKO_API_KEY || '', + apiKey: env.COINGECKO_API_KEY, baseUrl: 'https://api.coingecko.com/api/v3', }, coinmarketcap: { - apiKey: process.env.COINMARKETCAP_API_KEY || '', + apiKey: env.COINMARKETCAP_API_KEY, baseUrl: 'https://pro-api.coinmarketcap.com/v1', }, price: { - cacheTtl: parseInt(process.env.PRICE_CACHE_TTL, 10) || 60, - refreshInterval: parseInt(process.env.PRICE_REFRESH_INTERVAL, 10) || 30, - staleThresholdMinutes: parseInt(process.env.PRICE_STALE_THRESHOLD, 10) || 5, - anomalyThresholdPercent: parseFloat(process.env.PRICE_ANOMALY_THRESHOLD, 10) || 10, + cacheTtl: env.PRICE_CACHE_TTL_SECONDS, + refreshInterval: env.PRICE_REFRESH_INTERVAL_SECONDS, + staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES, + anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT, }, corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001') .split(',') diff --git a/src/services/cache.js b/src/services/cache.js index e3ee57d..51b3fde 100644 --- a/src/services/cache.js +++ b/src/services/cache.js @@ -6,8 +6,7 @@ let client = null; function getClient() { if (!client) { - client = new Redis({ - ...config.redis, + client = new Redis(config.redis.url, { lazyConnect: true, enableOfflineQueue: false, }); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..ee0c74a --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..'); + +function cleanProcessEnv(overrides) { + return { + PATH: process.env.PATH, + Path: process.env.Path, + SystemRoot: process.env.SystemRoot, + COMSPEC: process.env.COMSPEC, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + ...overrides, + }; +} + +function runConfig(script, env) { + return spawnSync(process.execPath, ['-e', script], { + cwd: repoRoot, + env: cleanProcessEnv(env), + encoding: 'utf8', + }); +} + +describe('configuration validation', () => { + test('exits before startup and reports every invalid production variable', () => { + const result = runConfig("require('./src/config')", { + NODE_ENV: 'production', + REDIS_URL: 'not-a-url', + LOG_LEVEL: 'verbose', + PRICE_CACHE_TTL_SECONDS: 'soon', + }); + + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.status).toBe(1); + expect(output).toContain('DATABASE_URL'); + expect(output).toContain('REDIS_URL'); + expect(output).toContain('LOG_LEVEL'); + expect(output).toContain('PRICE_CACHE_TTL_SECONDS'); + }); + + test('loads safe in-process defaults under NODE_ENV=test', () => { + const result = runConfig( + [ + "const config = require('./src/config');", + 'console.log(JSON.stringify({', + ' port: config.port,', + ' databaseUrl: config.databaseUrl,', + ' redisUrl: config.redis.url,', + ' price: config.price,', + '}));', + ].join(' '), + { NODE_ENV: 'test' } + ); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed).toEqual({ + port: 3000, + databaseUrl: 'postgres://localhost/smartdrop_test', + redisUrl: 'redis://localhost:6379', + price: { + cacheTtl: 60, + refreshInterval: 30, + staleThresholdMinutes: 5, + anomalyThresholdPercent: 20, + }, + }); + }); +});