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
78 changes: 78 additions & 0 deletions src/config.required.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
getMissingRequiredEnvVars,
MissingRequiredEnvError,
REQUIRED_ENV_VARS,
validateRequiredEnvVars,
} from './config.required';

const COMPLETE_REQUIRED_ENV: Record<string, string | undefined> =
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');
}
});
});
51 changes: 51 additions & 0 deletions src/config.required.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>
): RequiredEnvVar[] {
return REQUIRED_ENV_VARS.filter(name => {
const value = env[name];
return value === undefined || value.trim().length === 0;
});
}

export function validateRequiredEnvVars(
env: Record<string, string | undefined>
): void {
const missingVariables = getMissingRequiredEnvVars(env);

if (missingVariables.length > 0) {
throw new MissingRequiredEnvError(missingVariables);
}
}
61 changes: 43 additions & 18 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'),
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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'],
Expand Down
22 changes: 17 additions & 5 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
});
Expand All @@ -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,
Expand All @@ -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();
}
Expand Down
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 0 additions & 7 deletions src/utils/startup.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ jest.mock('./logger.utils', () => ({

jest.mock('../config', () => ({
envConfig: {
GMAIL_USER: '',
GMAIL_APP_PASSWORD: '',
PAYSTACK_PUBLIC_KEY: undefined,
},
}));
Expand All @@ -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' }),
]),
}),
Expand All @@ -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();
Expand Down
9 changes: 0 additions & 9 deletions src/utils/startup.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading