517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|
|
});
|