import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { z } from 'zod'; import { checkRequiredEnvVars, COMMON_SECRET_PATTERNS, createStrictSchema, formatValidationResult, isSecret, isSecretEnvVar, mergeSchemas, redactSecrets, secret, secretSchema, secretStringSchema, SecretValue, validateCompleteness, validateConfig, wrapSecretEnvVars, type ValidationResult, } from '../src'; describe('Config Utils', () => { describe('SecretValue', () => { it('should create a secret value', () => { const secret = new SecretValue('my-secret'); expect(secret).toBeInstanceOf(SecretValue); expect(secret.toString()).toBe('***'); }); it('should use custom mask', () => { const secret = new SecretValue('my-secret', 'HIDDEN'); expect(secret.toString()).toBe('HIDDEN'); }); it('should reveal value with reason', () => { const secret = new SecretValue('my-secret'); expect(secret.reveal('testing')).toBe('my-secret'); }); it('should throw when revealing without reason', () => { const secret = new SecretValue('my-secret'); expect(() => secret.reveal('')).toThrow('Reason required for revealing secret value'); }); it('should mask value in JSON', () => { const secret = new SecretValue('my-secret'); expect(JSON.stringify(secret)).toBe('"***"'); expect(secret.toJSON()).toBe('***'); }); it('should compare values without revealing', () => { const secret = new SecretValue('my-secret'); expect(secret.equals('my-secret')).toBe(true); expect(secret.equals('other-secret')).toBe(false); }); it('should map secret values', () => { const secret = new SecretValue('hello'); const mapped = secret.map(val => val.toUpperCase(), 'testing transformation'); expect(mapped.reveal('checking result')).toBe('HELLO'); expect(mapped.toString()).toBe('***'); }); it('should work with non-string types', () => { const numberSecret = new SecretValue(12345, 'XXX'); expect(numberSecret.reveal('test')).toBe(12345); expect(numberSecret.toString()).toBe('XXX'); const objectSecret = new SecretValue({ key: 'value' }, '[OBJECT]'); expect(objectSecret.reveal('test')).toEqual({ key: 'value' }); expect(objectSecret.toString()).toBe('[OBJECT]'); }); }); describe('secret helper function', () => { it('should create secret values', () => { const s = secret('my-secret'); expect(s).toBeInstanceOf(SecretValue); expect(s.reveal('test')).toBe('my-secret'); }); it('should accept custom mask', () => { const s = secret('my-secret', 'REDACTED'); expect(s.toString()).toBe('REDACTED'); }); }); describe('isSecret', () => { it('should identify secret values', () => { expect(isSecret(new SecretValue('test'))).toBe(true); expect(isSecret(secret('test'))).toBe(true); expect(isSecret('test')).toBe(false); expect(isSecret(null)).toBe(false); expect(isSecret(undefined)).toBe(false); expect(isSecret({})).toBe(false); }); }); describe('secretSchema', () => { it('should validate SecretValue instances', () => { const schema = secretSchema(z.string()); const secretVal = new SecretValue('test'); expect(() => schema.parse(secretVal)).not.toThrow(); expect(() => schema.parse('test')).toThrow(); expect(() => schema.parse(null)).toThrow(); }); }); describe('secretStringSchema', () => { it('should transform string to SecretValue', () => { const result = secretStringSchema.parse('my-secret'); expect(result).toBeInstanceOf(SecretValue); expect(result.reveal('test')).toBe('my-secret'); }); it('should reject non-strings', () => { expect(() => secretStringSchema.parse(123)).toThrow(); expect(() => secretStringSchema.parse(null)).toThrow(); }); }); describe('redactSecrets', () => { it('should redact specified paths', () => { const obj = { username: 'admin', password: 'secret123', nested: { apiKey: 'key123', public: 'visible', }, }; const redacted = redactSecrets(obj, ['password', 'nested.apiKey']); expect(redacted).toEqual({ username: 'admin', password: '***REDACTED***', nested: { apiKey: '***REDACTED***', public: 'visible', }, }); }); it('should redact SecretValue instances', () => { const obj = { normal: 'value', secret: new SecretValue('hidden', 'MASKED'), nested: { anotherSecret: secret('also-hidden'), }, }; const redacted = redactSecrets(obj); expect(redacted).toEqual({ normal: 'value', secret: 'MASKED', nested: { anotherSecret: '***', }, }); }); it('should handle arrays', () => { const obj = { items: [ { name: 'item1', secret: new SecretValue('s1') }, { name: 'item2', secret: new SecretValue('s2') }, ], }; const redacted = redactSecrets(obj); expect(redacted.items).toEqual([ { name: 'item1', secret: '***' }, { name: 'item2', secret: '***' }, ]); }); it('should handle null and undefined', () => { const obj = { nullValue: null, undefinedValue: undefined, secret: new SecretValue('test'), }; const redacted = redactSecrets(obj); expect(redacted).toEqual({ nullValue: null, undefinedValue: undefined, secret: '***', }); }); it('should handle non-existent paths gracefully', () => { const obj = { a: 'value' }; const redacted = redactSecrets(obj, ['b.c.d']); expect(redacted).toEqual({ a: 'value' }); }); it('should not modify original object', () => { const obj = { password: 'secret' }; const original = { ...obj }; redactSecrets(obj, ['password']); expect(obj).toEqual(original); }); }); describe('isSecretEnvVar', () => { it('should identify common secret patterns', () => { // Positive cases expect(isSecretEnvVar('PASSWORD')).toBe(true); expect(isSecretEnvVar('DB_PASSWORD')).toBe(true); expect(isSecretEnvVar('API_KEY')).toBe(true); expect(isSecretEnvVar('API-KEY')).toBe(true); expect(isSecretEnvVar('SECRET_TOKEN')).toBe(true); expect(isSecretEnvVar('AUTH_TOKEN')).toBe(true); expect(isSecretEnvVar('PRIVATE_KEY')).toBe(true); expect(isSecretEnvVar('CREDENTIAL')).toBe(true); expect(isSecretEnvVar('password')).toBe(true); // Case insensitive // Negative cases expect(isSecretEnvVar('USERNAME')).toBe(false); expect(isSecretEnvVar('PORT')).toBe(false); expect(isSecretEnvVar('DEBUG')).toBe(false); expect(isSecretEnvVar('NODE_ENV')).toBe(false); }); }); describe('wrapSecretEnvVars', () => { it('should wrap secret environment variables', () => { const env = { USERNAME: 'admin', PASSWORD: 'secret123', API_KEY: 'key123', PORT: '3000', }; const wrapped = wrapSecretEnvVars(env); expect(wrapped.USERNAME).toBe('admin'); expect(wrapped.PORT).toBe('3000'); expect(isSecret(wrapped.PASSWORD)).toBe(true); expect(isSecret(wrapped.API_KEY)).toBe(true); const passwordSecret = wrapped.PASSWORD as SecretValue; expect(passwordSecret.reveal('test')).toBe('secret123'); expect(passwordSecret.toString()).toBe('***PASSWORD***'); }); it('should handle undefined values', () => { const env = { PASSWORD: undefined, USERNAME: 'admin', }; const wrapped = wrapSecretEnvVars(env); expect(wrapped.PASSWORD).toBeUndefined(); expect(wrapped.USERNAME).toBe('admin'); }); }); describe('validateConfig', () => { const schema = z.object({ name: z.string(), port: z.number(), optional: z.string().optional(), }); it('should validate valid config', () => { const result = validateConfig({ name: 'app', port: 3000 }, schema); expect(result.valid).toBe(true); expect(result.errors).toBeUndefined(); }); it('should return errors for invalid config', () => { const result = validateConfig({ name: 'app', port: 'invalid' }, schema); expect(result.valid).toBe(false); expect(result.errors).toBeDefined(); expect(result.errors![0].path).toBe('port'); expect(result.errors![0].message).toContain('Expected number'); }); it('should handle missing required fields', () => { const result = validateConfig({ port: 3000 }, schema); expect(result.valid).toBe(false); expect(result.errors).toBeDefined(); expect(result.errors![0].path).toBe('name'); }); it('should rethrow non-Zod errors', () => { const badSchema = { parse: () => { throw new Error('Not a Zod error'); }, } as any; expect(() => validateConfig({}, badSchema)).toThrow('Not a Zod error'); }); }); describe('checkRequiredEnvVars', () => { const originalEnv = { ...process.env }; beforeEach(() => { // Clear environment for (const key in process.env) { delete process.env[key]; } }); afterEach(() => { // Restore environment for (const key in process.env) { delete process.env[key]; } Object.assign(process.env, originalEnv); }); it('should pass when all required vars are set', () => { process.env.API_KEY = 'key123'; process.env.DATABASE_URL = 'postgres://...'; const result = checkRequiredEnvVars(['API_KEY', 'DATABASE_URL']); expect(result.valid).toBe(true); expect(result.errors).toBeUndefined(); }); it('should fail when required vars are missing', () => { process.env.API_KEY = 'key123'; const result = checkRequiredEnvVars(['API_KEY', 'DATABASE_URL', 'MISSING_VAR']); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(2); expect(result.errors![0].path).toBe('env.DATABASE_URL'); expect(result.errors![1].path).toBe('env.MISSING_VAR'); }); it('should handle empty required list', () => { const result = checkRequiredEnvVars([]); expect(result.valid).toBe(true); expect(result.errors).toBeUndefined(); }); }); describe('validateCompleteness', () => { it('should validate complete config', () => { const config = { database: { host: 'localhost', port: 5432, credentials: { username: 'admin', password: 'secret', }, }, }; const result = validateCompleteness(config, [ 'database.host', 'database.port', 'database.credentials.username', ]); expect(result.valid).toBe(true); expect(result.errors).toBeUndefined(); }); it('should detect missing values', () => { const config = { database: { host: 'localhost', credentials: {}, }, }; const result = validateCompleteness(config, [ 'database.host', 'database.port', 'database.credentials.username', ]); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(2); expect(result.errors![0].path).toBe('database.port'); expect(result.errors![1].path).toBe('database.credentials.username'); }); it('should handle null and undefined as missing', () => { const config = { a: null, b: undefined, c: 'value', }; const result = validateCompleteness(config, ['a', 'b', 'c']); expect(result.valid).toBe(false); expect(result.errors).toHaveLength(2); }); it('should handle non-existent paths', () => { const config = { a: 'value' }; const result = validateCompleteness(config, ['b.c.d']); expect(result.valid).toBe(false); expect(result.errors![0].path).toBe('b.c.d'); }); }); describe('formatValidationResult', () => { it('should format valid result', () => { const result: ValidationResult = { valid: true }; const formatted = formatValidationResult(result); expect(formatted).toBe('✅ Configuration is valid'); }); it('should format errors', () => { const result: ValidationResult = { valid: false, errors: [ { path: 'port', message: 'Expected number' }, { path: 'database.host', message: 'Invalid value', expected: 'string', received: 'number', }, ], }; const formatted = formatValidationResult(result); expect(formatted).toContain('❌ Configuration validation failed'); expect(formatted).toContain('Errors:'); expect(formatted).toContain('- port: Expected number'); expect(formatted).toContain('- database.host: Invalid value'); expect(formatted).toContain('Expected: string, Received: number'); }); it('should format warnings', () => { const result: ValidationResult = { valid: true, warnings: [{ path: 'deprecated.feature', message: 'This feature is deprecated' }], }; const formatted = formatValidationResult(result); expect(formatted).toContain('✅ Configuration is valid'); expect(formatted).toContain('Warnings:'); expect(formatted).toContain('- deprecated.feature: This feature is deprecated'); }); }); describe('createStrictSchema', () => { it('should create strict schema', () => { const schema = createStrictSchema({ name: z.string(), age: z.number(), }); expect(() => schema.parse({ name: 'John', age: 30 })).not.toThrow(); expect(() => schema.parse({ name: 'John', age: 30, extra: 'field' })).toThrow(); }); }); describe('mergeSchemas', () => { it('should merge two schemas', () => { const schema1 = z.object({ a: z.string() }); const schema2 = z.object({ b: z.number() }); const merged = mergeSchemas(schema1, schema2); const result = merged.parse({ a: 'test', b: 123 }); expect(result).toEqual({ a: 'test', b: 123 }); }); it('should merge multiple schemas', () => { const schema1 = z.object({ a: z.string() }); const schema2 = z.object({ b: z.number() }); const schema3 = z.object({ c: z.boolean() }); const merged = mergeSchemas(schema1, schema2, schema3); const result = merged.parse({ a: 'test', b: 123, c: true }); expect(result).toEqual({ a: 'test', b: 123, c: true }); }); it('should throw with less than two schemas', () => { expect(() => mergeSchemas(z.object({}))).toThrow('At least two schemas required'); expect(() => mergeSchemas()).toThrow('At least two schemas required'); }); it('should handle overlapping fields', () => { const schema1 = z.object({ a: z.string(), shared: z.string() }); const schema2 = z.object({ b: z.number(), shared: z.string() }); const merged = mergeSchemas(schema1, schema2); // Both schemas require 'shared' to be a string expect(() => merged.parse({ a: 'test', b: 123, shared: 'value' })).not.toThrow(); expect(() => merged.parse({ a: 'test', b: 123, shared: 123 })).toThrow(); }); }); describe('COMMON_SECRET_PATTERNS', () => { it('should be an array of RegExp', () => { expect(Array.isArray(COMMON_SECRET_PATTERNS)).toBe(true); expect(COMMON_SECRET_PATTERNS.length).toBeGreaterThan(0); for (const pattern of COMMON_SECRET_PATTERNS) { expect(pattern).toBeInstanceOf(RegExp); } }); }); });