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
4 changes: 4 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ jobs:
env: {}
- package: packages/csrf
env: {}
- package: packages/12factor-env
env: {}
- package: packages/postmaster
env: {}

env:
PGHOST: localhost
Expand Down
220 changes: 220 additions & 0 deletions packages/12factor-env/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { writeFileSync, unlinkSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

import { env, str, port, bool, host } from '../src';

describe('env', () => {
const ORIGINAL_ENV = { ...process.env };
const testDir = join(tmpdir(), 'env-test-' + process.pid);

beforeAll(() => {
mkdirSync(testDir, { recursive: true });
});

afterAll(() => {
try {
rmSync(testDir, { recursive: true });
} catch {
// ignore cleanup errors
}
});

afterEach(() => {
// Remove any env var added during the test
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) {
delete process.env[key];
}
}
// Restore original values
Object.assign(process.env, ORIGINAL_ENV);
});

describe('_FILE secret detection', () => {
it('should discover secrets via _FILE suffix pattern in original env', () => {
const secretPath = join(testDir, 'api-secret');
writeFileSync(secretPath, 'super-secret-value');

try {
// API_KEY_FILE is in env but API_KEY is not in vars
// This tests the fix: secretEnv should use inputEnv, not varEnv
const result = env(
{
API_KEY_FILE: secretPath,
PORT: '3000'
},
{ API_KEY: str() },
{ PORT: port() }
);

expect(result.API_KEY).toBe('super-secret-value');
expect(result.PORT).toBe(3000);
} finally {
unlinkSync(secretPath);
}
});
});

describe('access to vars', () => {
it('should allow accessing vars without ReferenceError', () => {
const result = env(
{
MAILGUN_KEY: 'test-key',
MAILGUN_DOMAIN: 'mg.example.com'
},
{ MAILGUN_KEY: str() },
{ MAILGUN_DOMAIN: str() }
);

expect(result.MAILGUN_KEY).toBe('test-key');
expect(result.MAILGUN_DOMAIN).toBe('mg.example.com');
});
});

describe('precedence', () => {
it('should prefer file secret over plain env when both exist', () => {
const secretPath = join(testDir, 'api-key-secret');
writeFileSync(secretPath, 'from-file');

try {
const result = env(
{
API_KEY: 'from-env',
API_KEY_FILE: secretPath
},
{ API_KEY: str() },
{}
);

expect(result.API_KEY).toBe('from-file');
} finally {
unlinkSync(secretPath);
}
});

it('should use plain env when no file secret exists', () => {
const result = env(
{ API_KEY: 'from-env' },
{ API_KEY: str() },
{}
);

expect(result.API_KEY).toBe('from-env');
});
});

describe('Kubernetes secretKeyRef style', () => {
it('should allow secret set directly in env (no file)', () => {
const result = env(
{
DATABASE_PASSWORD: 'k8s-secret-value',
DATABASE_HOST: 'localhost'
},
{ DATABASE_PASSWORD: str() },
{ DATABASE_HOST: str() }
);

expect(result.DATABASE_PASSWORD).toBe('k8s-secret-value');
expect(result.DATABASE_HOST).toBe('localhost');
});
});

describe('validation', () => {
it('should throw for missing required secret', () => {
expect(() => {
env(
{ PORT: '3000' },
{ API_KEY: str() },
{ PORT: port() }
);
}).toThrow(/API_KEY/);
});

it('should use default values for optional vars', () => {
const result = env(
{ API_KEY: 'test-key' },
{ API_KEY: str() },
{
PORT: port({ default: 8080 }),
DEBUG: bool({ default: false })
}
);

expect(result.API_KEY).toBe('test-key');
expect(result.PORT).toBe(8080);
expect(result.DEBUG).toBe(false);
});
});

describe('ENV_SECRETS_PATH resolution', () => {
it('A5: should read secrets from ENV_SECRETS_PATH directory', () => {
// Write file named 'API_KEY' (not API_KEY_FILE) to temp dir
const secretPath = join(testDir, 'API_KEY');
writeFileSync(secretPath, 'secret-from-path');

// Set ENV_SECRETS_PATH - now works without module reload thanks to lazy getSecretsPath()
process.env.ENV_SECRETS_PATH = testDir;

try {
const result = env(
{}, // No API_KEY_FILE, no API_KEY in env
{ API_KEY: str() },
{}
);
expect(result.API_KEY).toBe('secret-from-path');
} finally {
unlinkSync(secretPath);
}
});
});

describe('fallback behavior', () => {
it('B1: _FILE present but file missing + env secret present → fallback', () => {
const result = env(
{
API_KEY: 'fallback-value',
API_KEY_FILE: '/nonexistent/path'
},
{ API_KEY: str() },
{}
);
expect(result.API_KEY).toBe('fallback-value');
});

it('B2: _FILE present but file missing + env secret missing → throw', () => {
expect(() => {
env(
{ API_KEY_FILE: '/nonexistent/path' },
{ API_KEY: str() },
{}
);
}).toThrow(/API_KEY/);
});
});

describe('validation errors', () => {
it('B3: Invalid optional var format (host validator) → throw', () => {
expect(() => {
env(
{
API_KEY: 'test-key',
MAILGUN_DOMAIN: 'not a valid host!!!'
},
{ API_KEY: str() },
{ MAILGUN_DOMAIN: host() }
);
}).toThrow(/MAILGUN_DOMAIN/);
});

it('B4: Invalid required secret format (port validator) → throw', () => {
expect(() => {
env(
{ DB_PORT: 'not-a-number' },
{ DB_PORT: port() },
{}
);
}).toThrow(/DB_PORT/);
});
});
});
18 changes: 18 additions & 0 deletions packages/12factor-env/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: ['/node_modules/*'],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
12 changes: 7 additions & 5 deletions packages/12factor-env/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ const cleanEnv = <S extends Record<string, ValidatorSpec<unknown>>>(
};

/**
* Default path for secret files (Docker/Kubernetes secrets)
* Get the secrets path lazily to allow ENV_SECRETS_PATH changes at runtime
*/
const ENV_SECRETS_PATH = process.env.ENV_SECRETS_PATH ?? '/run/secrets/';
const getSecretsPath = (): string =>
process.env.ENV_SECRETS_PATH ?? '/run/secrets/';

/**
* Resolve the full path to a secret file
*/
const secretPath = (name: string): string =>
name.startsWith('/') ? name : resolve(join(ENV_SECRETS_PATH, name));
name.startsWith('/') ? name : resolve(join(getSecretsPath(), name));

/**
* Read a secret from a file
Expand Down Expand Up @@ -140,13 +141,13 @@ const env = <S extends Specs, V extends Specs>(
const varEnv = cleanEnv(inputEnv, vars);

// Read secrets from files
const _secrets = secretEnv(varEnv as unknown as Record<string, string | undefined>, secrets);
const _secrets = secretEnv(inputEnv, secrets);

// Second pass: validate secrets with file values merged in
// Include inputEnv first so env vars (e.g., Kubernetes secretKeyRef) are available,
// then varEnv overrides, then file-based secrets have highest priority
const mergedEnv = { ...inputEnv, ...varEnv, ..._secrets } as unknown as Record<string, string | undefined>;
return cleanEnv(mergedEnv, secrets) as unknown as CleanedEnv<S & V>;
return cleanEnv(mergedEnv, { ...secrets, ...vars }) as unknown as CleanedEnv<S & V>;
};

export {
Expand All @@ -155,6 +156,7 @@ export {
secret,
getSecret,
secretPath,
getSecretsPath,
// Re-export from envalid
cleanEnv,
makeValidator,
Expand Down
Loading