diff --git a/src/config.required.test.ts b/src/config.required.test.ts new file mode 100644 index 0000000..5b0f432 --- /dev/null +++ b/src/config.required.test.ts @@ -0,0 +1,78 @@ +import { + getMissingRequiredEnvVars, + MissingRequiredEnvError, + REQUIRED_ENV_VARS, + validateRequiredEnvVars, +} from './config.required'; + +const COMPLETE_REQUIRED_ENV: Record = + Object.fromEntries( + REQUIRED_ENV_VARS.map(name => [name, `${name.toLowerCase()}-value`]) + ); + +describe('Required environment startup validation', () => { + it('lists required variables in one central location', () => { + expect(REQUIRED_ENV_VARS).toEqual([ + 'DATABASE_URL', + 'GMAIL_USER', + 'GMAIL_APP_PASSWORD', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'BACKEND_URL', + 'FRONTEND_URL', + 'CLOUDINARY_CLOUD_NAME', + 'CLOUDINARY_API_KEY', + 'CLOUDINARY_API_SECRET', + 'PAYSTACK_SECRET_KEY', + ]); + }); + + it('does not include optional variables in the required startup check', () => { + expect(REQUIRED_ENV_VARS).not.toContain('PAYSTACK_PUBLIC_KEY'); + expect(REQUIRED_ENV_VARS).not.toContain('PORT'); + expect(REQUIRED_ENV_VARS).not.toContain('APP_SECRET'); + }); + + it('detects absent and empty required variables', () => { + const env = { + ...COMPLETE_REQUIRED_ENV, + DATABASE_URL: '', + GOOGLE_CLIENT_ID: ' ', + CLOUDINARY_API_SECRET: undefined, + }; + + expect(getMissingRequiredEnvVars(env)).toEqual([ + 'DATABASE_URL', + 'GOOGLE_CLIENT_ID', + 'CLOUDINARY_API_SECRET', + ]); + }); + + it('passes when every required variable is present and non-empty', () => { + expect(() => + validateRequiredEnvVars(COMPLETE_REQUIRED_ENV) + ).not.toThrow(); + }); + + it('throws a descriptive error naming the missing variable', () => { + expect(() => + validateRequiredEnvVars({ + ...COMPLETE_REQUIRED_ENV, + DATABASE_URL: undefined, + }) + ).toThrow(MissingRequiredEnvError); + + try { + validateRequiredEnvVars({ + ...COMPLETE_REQUIRED_ENV, + DATABASE_URL: undefined, + }); + } catch (error) { + expect(error).toBeInstanceOf(MissingRequiredEnvError); + expect((error as MissingRequiredEnvError).missingVariables).toEqual([ + 'DATABASE_URL', + ]); + expect((error as Error).message).toContain('DATABASE_URL'); + } + }); +}); diff --git a/src/config.required.ts b/src/config.required.ts new file mode 100644 index 0000000..cb3effb --- /dev/null +++ b/src/config.required.ts @@ -0,0 +1,51 @@ +export const REQUIRED_ENV_VARS = [ + 'DATABASE_URL', + 'GMAIL_USER', + 'GMAIL_APP_PASSWORD', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'BACKEND_URL', + 'FRONTEND_URL', + 'CLOUDINARY_CLOUD_NAME', + 'CLOUDINARY_API_KEY', + 'CLOUDINARY_API_SECRET', + 'PAYSTACK_SECRET_KEY', +] as const; + +export type RequiredEnvVar = (typeof REQUIRED_ENV_VARS)[number]; + +export class MissingRequiredEnvError extends Error { + readonly missingVariables: RequiredEnvVar[]; + + constructor(missingVariables: RequiredEnvVar[]) { + const variableLabel = + missingVariables.length === 1 + ? 'environment variable' + : 'environment variables'; + + super( + `Missing required ${variableLabel}: ${missingVariables.join(', ')}` + ); + this.name = 'MissingRequiredEnvError'; + this.missingVariables = missingVariables; + } +} + +export function getMissingRequiredEnvVars( + env: Record +): RequiredEnvVar[] { + return REQUIRED_ENV_VARS.filter(name => { + const value = env[name]; + return value === undefined || value.trim().length === 0; + }); +} + +export function validateRequiredEnvVars( + env: Record +): void { + const missingVariables = getMissingRequiredEnvVars(env); + + if (missingVariables.length > 0) { + throw new MissingRequiredEnvError(missingVariables); + } +} diff --git a/src/config.schema.ts b/src/config.schema.ts index d277434..4f5bf4e 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -20,7 +20,7 @@ import { z } from 'zod'; * Zod's default z.coerce.boolean() returns true for any non-empty string, * including "false" and "0", which is usually not what we want for .env files. */ -const booleanCoerce = z.preprocess((val) => { +const booleanCoerce = z.preprocess(val => { if (typeof val === 'string') { const lower = val.toLowerCase(); if (lower === 'true' || lower === '1') return true; @@ -29,12 +29,20 @@ const booleanCoerce = z.preprocess((val) => { return val; }, z.coerce.boolean()); +const optionalNonEmptyString = z.preprocess(val => { + if (typeof val === 'string' && val.trim().length === 0) { + return undefined; + } + + return val; +}, z.string().min(1).optional()); + export const envSchema = z .object({ PORT: z.coerce.number().default(3000), - MODE: z.enum(['development', 'production', 'test']).default( - 'development' - ), + MODE: z + .enum(['development', 'production', 'test']) + .default('development'), DATABASE_URL: z .string() .min(1, 'DATABASE_URL is required in the environment variables'), @@ -70,10 +78,7 @@ export const envSchema = z PAYSTACK_SECRET_KEY: z .string() .min(1, 'PAYSTACK_SECRET_KEY is required for payment processing'), - PAYSTACK_PUBLIC_KEY: z - .string() - .min(1, 'PAYSTACK_PUBLIC_KEY is required for payment processing') - .optional(), + PAYSTACK_PUBLIC_KEY: optionalNonEmptyString, ENABLE_RESPONSE_TIMING: booleanCoerce.default(true), API_VERSION: z.string().default('1.0.0'), ENABLE_API_VERSION_HEADER: booleanCoerce.default(true), @@ -87,11 +92,26 @@ export const envSchema = z .default('accesslayer_default_development_secret_key_32_bytes_long'), INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1), - BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000), + BACKGROUND_JOB_LOCK_TTL_MS: z.coerce + .number() + .int() + .positive() + .default(300000), SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), - CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), - INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce.number().int().positive().default(300000), - INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce.number().positive().default(300000), + CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce + .number() + .int() + .positive() + .default(500), + INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce + .number() + .int() + .positive() + .default(300000), + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce + .number() + .positive() + .default(300000), // Indexer feature flags ENABLE_INDEXER_DEDUPE: booleanCoerce.default(true), @@ -124,15 +144,20 @@ export const envSchema = z .min(1) .default('creator_ownership_snapshots'), OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: z.coerce.boolean().default(true), - OWNERSHIP_SNAPSHOT_RETENTION_DAYS: z.coerce.number().int().positive().default(30), + OWNERSHIP_SNAPSHOT_RETENTION_DAYS: z.coerce + .number() + .int() + .positive() + .default(30), OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: z.coerce.boolean().default(false), - OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: z.coerce.number().int().positive().default(60), + OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: z.coerce + .number() + .int() + .positive() + .default(60), }) .superRefine((data, ctx) => { - if ( - data.MODE === 'production' && - data.STELLAR_NETWORK === 'testnet' - ) { + if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['STELLAR_NETWORK'], diff --git a/src/config.test.ts b/src/config.test.ts index 54c478e..acaf33d 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -9,7 +9,7 @@ import { envSchema } from './config.schema'; * These tests validate the documented source precedence rules and Stellar-specific logic. */ -const booleanCoerce = z.preprocess((val) => { +const booleanCoerce = z.preprocess(val => { if (typeof val === 'string') { const lower = val.toLowerCase(); if (lower === 'true' || lower === '1') return true; @@ -36,12 +36,13 @@ const BASE_ENV = { }; describe('Config Validation and Source Precedence', () => { - describe('Stellar & General Schema Validation', () => { it('Defaults are applied correctly', () => { const defaults = envSchema.parse(BASE_ENV); expect(defaults.STELLAR_NETWORK).toBe('testnet'); - expect(defaults.STELLAR_HORIZON_URL).toBe('https://horizon-testnet.stellar.org'); + expect(defaults.STELLAR_HORIZON_URL).toBe( + 'https://horizon-testnet.stellar.org' + ); expect(defaults.API_VERSION).toBe('1.0.0'); expect(defaults.ENABLE_INDEXER_DEDUPE).toBe(true); }); @@ -55,6 +56,15 @@ describe('Config Validation and Source Precedence', () => { expect(valid.success).toBe(true); }); + it('Optional PAYSTACK_PUBLIC_KEY accepts empty values as unset', () => { + const optionalKey = envSchema.parse({ + ...BASE_ENV, + PAYSTACK_PUBLIC_KEY: '', + }); + + expect(optionalKey.PAYSTACK_PUBLIC_KEY).toBeUndefined(); + }); + it('Invalid STELLAR_NETWORK value is rejected', () => { const badNetwork = envSchema.safeParse({ ...BASE_ENV, @@ -71,8 +81,10 @@ describe('Config Validation and Source Precedence', () => { }); expect(mismatch.success).toBe(false); if (!mismatch.success) { - const issue = mismatch.error.issues.find((i: z.ZodIssue) => - i.path.includes('STELLAR_NETWORK') && i.message.includes('mainnet') + const issue = mismatch.error.issues.find( + (i: z.ZodIssue) => + i.path.includes('STELLAR_NETWORK') && + i.message.includes('mainnet') ); expect(issue).toBeDefined(); } diff --git a/src/config.ts b/src/config.ts index 543f3e1..86315ad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,19 @@ import dotenv from 'dotenv'; import { envSchema } from './config.schema'; +import { validateRequiredEnvVars } from './config.required'; export { envSchema }; +export { + getMissingRequiredEnvVars, + MissingRequiredEnvError, + REQUIRED_ENV_VARS, + validateRequiredEnvVars, +} from './config.required'; // Load environment variables from .env file // Note: Does not override existing environment variables dotenv.config(); +validateRequiredEnvVars(process.env); /** * Validated and typed environment configuration. diff --git a/src/utils/startup.utils.test.ts b/src/utils/startup.utils.test.ts index b092300..a6cd6a5 100644 --- a/src/utils/startup.utils.test.ts +++ b/src/utils/startup.utils.test.ts @@ -10,8 +10,6 @@ jest.mock('./logger.utils', () => ({ jest.mock('../config', () => ({ envConfig: { - GMAIL_USER: '', - GMAIL_APP_PASSWORD: '', PAYSTACK_PUBLIC_KEY: undefined, }, })); @@ -27,9 +25,6 @@ describe('Startup Utilities', () => { expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ disabledDependencies: expect.arrayContaining([ - expect.objectContaining({ - dependency: 'Email Transport (Gmail)', - }), expect.objectContaining({ dependency: 'Paystack Public Key' }), ]), }), @@ -38,8 +33,6 @@ describe('Startup Utilities', () => { }); it('should not emit a warning when all optional dependencies are present', () => { - envConfig.GMAIL_USER = 'user@gmail.com'; - envConfig.GMAIL_APP_PASSWORD = 'secure-app-password'; envConfig.PAYSTACK_PUBLIC_KEY = 'pk_test_123456789'; checkOptionalDependencies(); diff --git a/src/utils/startup.utils.ts b/src/utils/startup.utils.ts index 4e8d58d..f9e3295 100644 --- a/src/utils/startup.utils.ts +++ b/src/utils/startup.utils.ts @@ -4,15 +4,6 @@ import { logger } from './logger.utils'; export function checkOptionalDependencies(): void { const disabledFeatures: Array<{ dependency: string; impact: string }> = []; - // Gmail credentials are technically optional (no min length enforced) - if (!envConfig.GMAIL_USER || !envConfig.GMAIL_APP_PASSWORD) { - disabledFeatures.push({ - dependency: 'Email Transport (Gmail)', - impact: - 'Transactional emails will not be sent. Email-based flows (e.g., test emails) will fail.', - }); - } - // Paystack Public Key is explicitly optional in the schema if (!envConfig.PAYSTACK_PUBLIC_KEY) { disabledFeatures.push({