mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(server): add app-config core with fail-fast env validation
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveApp,
|
||||
deriveHttp,
|
||||
deriveSession,
|
||||
deriveDemo,
|
||||
deriveOidc,
|
||||
deriveSmtp,
|
||||
deriveMcp,
|
||||
derivePlugins,
|
||||
deriveIntegrations,
|
||||
deriveBackup,
|
||||
deriveNet,
|
||||
deriveAll,
|
||||
} from '../../../src/app-config/derive';
|
||||
|
||||
// These tests PIN the exact legacy coercions each derived field replaced.
|
||||
// If one fails after an edit, the edit changed runtime behavior — fix the
|
||||
// edit, not the test (parity is law).
|
||||
|
||||
describe('deriveApp', () => {
|
||||
it('NODE_ENV: loose helpers lowercase, isTest stays case-sensitive (db/database.ts)', () => {
|
||||
expect(deriveApp({ NODE_ENV: 'PRODUCTION' }).isProduction).toBe(true);
|
||||
expect(deriveApp({ NODE_ENV: 'Development' }).isDevelopment).toBe(true);
|
||||
expect(deriveApp({ NODE_ENV: 'test' }).isTest).toBe(true);
|
||||
expect(deriveApp({ NODE_ENV: 'TEST' }).isTest).toBe(false); // strict on purpose
|
||||
expect(deriveApp({}).isProduction).toBe(false);
|
||||
});
|
||||
|
||||
it('PORT: Number(x) || 3001 (index.ts)', () => {
|
||||
expect(deriveApp({ PORT: '8080' }).port).toBe(8080);
|
||||
expect(deriveApp({ PORT: 'abc' }).port).toBe(3001);
|
||||
expect(deriveApp({}).port).toBe(3001);
|
||||
});
|
||||
|
||||
it('APP_VERSION / APP_URL stay raw — per-site fallbacks live at the call sites', () => {
|
||||
expect(deriveApp({}).appVersion).toBeUndefined();
|
||||
expect(deriveApp({ APP_URL: 'https://x.example/' }).appUrl).toBe('https://x.example/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHttp', () => {
|
||||
it('ALLOWED_ORIGINS: CORS variant filters empties, websocket variant keeps them', () => {
|
||||
const env = { ALLOWED_ORIGINS: ' https://a.example ,, https://b.example ' };
|
||||
expect(deriveHttp(env).corsOrigins).toEqual(['https://a.example', 'https://b.example']);
|
||||
expect(deriveHttp(env).wsOrigins).toEqual(['https://a.example', '', 'https://b.example']);
|
||||
expect(deriveHttp({}).corsOrigins).toBeNull();
|
||||
expect(deriveHttp({}).wsOrigins).toBeNull();
|
||||
});
|
||||
|
||||
it('TRUST_PROXY: parseInt || 1 (globalMiddleware)', () => {
|
||||
expect(deriveHttp({ TRUST_PROXY: '2' }).trustProxy).toBe(2);
|
||||
expect(deriveHttp({ TRUST_PROXY: 'abc' }).trustProxy).toBe(1);
|
||||
expect(deriveHttp({}).trustProxy).toBe(1);
|
||||
});
|
||||
|
||||
it('boolean switches accept the whole boolean-like family (unified semantics)', () => {
|
||||
expect(deriveHttp({ FORCE_HTTPS: 'TRUE' }).forceHttps).toBe(true);
|
||||
expect(deriveHttp({ FORCE_HTTPS: 'on' }).forceHttps).toBe(true);
|
||||
expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: 'TRUE' }).hstsIncludeSubdomains).toBe(true);
|
||||
expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: '1' }).hstsIncludeSubdomains).toBe(true);
|
||||
expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: 'off' }).hstsIncludeSubdomains).toBe(false);
|
||||
});
|
||||
|
||||
it('COOKIE_SECURE is tri-state: explicit falsy disables, anything else auto-detects', () => {
|
||||
expect(deriveHttp({ COOKIE_SECURE: 'false' }).cookieSecureDisabled).toBe(true);
|
||||
expect(deriveHttp({ COOKIE_SECURE: 'off' }).cookieSecureDisabled).toBe(true);
|
||||
expect(deriveHttp({ COOKIE_SECURE: 'true' }).cookieSecureDisabled).toBe(false);
|
||||
expect(deriveHttp({}).cookieSecureDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('TREK_API_DOCS_ENABLED: trimmed, boolean-like (api-docs.kill-switch)', () => {
|
||||
expect(deriveHttp({ TREK_API_DOCS_ENABLED: ' TRUE ' }).apiDocsEnabled).toBe(true);
|
||||
expect(deriveHttp({ TREK_API_DOCS_ENABLED: '1' }).apiDocsEnabled).toBe(true);
|
||||
expect(deriveHttp({ TREK_API_DOCS_ENABLED: 'false' }).apiDocsEnabled).toBe(false);
|
||||
expect(deriveHttp({}).apiDocsEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSession', () => {
|
||||
it('parses durations and falls back on invalid input (src/config.ts semantics)', () => {
|
||||
const ok = deriveSession({ SESSION_DURATION: '12h', SESSION_DURATION_REMEMBER: '90d' });
|
||||
expect(ok.duration).toBe('12h');
|
||||
expect(ok.durationMs).toBe(12 * 3_600_000);
|
||||
expect(ok.durationSeconds).toBe(12 * 3600);
|
||||
expect(ok.durationRememberMs).toBe(90 * 86_400_000);
|
||||
|
||||
const bad = deriveSession({ SESSION_DURATION: 'bogus' });
|
||||
expect(bad.duration).toBe('24h');
|
||||
expect(bad.durationMs).toBe(24 * 3_600_000);
|
||||
expect(deriveSession({}).durationRemember).toBe('30d');
|
||||
});
|
||||
|
||||
it('IDEMPOTENCY_TTL_SECONDS: positive number or 30 days (scheduler.ts)', () => {
|
||||
expect(deriveSession({ IDEMPOTENCY_TTL_SECONDS: '60' }).idempotencyTtlSeconds).toBe(60);
|
||||
expect(deriveSession({ IDEMPOTENCY_TTL_SECONDS: '0' }).idempotencyTtlSeconds).toBe(30 * 24 * 60 * 60);
|
||||
expect(deriveSession({}).idempotencyTtlSeconds).toBe(30 * 24 * 60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveDemo', () => {
|
||||
it('DEMO_MODE coerces the whole boolean-like family to one bool', () => {
|
||||
expect(deriveDemo({ DEMO_MODE: 'TRUE' }).enabled).toBe(true);
|
||||
expect(deriveDemo({ DEMO_MODE: 'yes' }).enabled).toBe(true);
|
||||
expect(deriveDemo({ DEMO_MODE: 'off' }).enabled).toBe(false);
|
||||
expect(deriveDemo({ DEMO_MODE: 'maybe' }).enabled).toBe(false); // out-of-family → default (validation rejects it at boot)
|
||||
expect(deriveDemo({}).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMO_ADMIN_EMAIL stays raw — demo-seed and demo-reset apply different defaults', () => {
|
||||
expect(deriveDemo({}).adminEmailRaw).toBeUndefined();
|
||||
expect(deriveDemo({}).adminUser).toBe('admin');
|
||||
expect(deriveDemo({}).adminPass).toBe('admin12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveOidc', () => {
|
||||
it('scope and adminClaim carry their single-site defaults', () => {
|
||||
expect(deriveOidc({}).scope).toBe('openid email profile');
|
||||
expect(deriveOidc({}).adminClaim).toBe('groups');
|
||||
expect(deriveOidc({ OIDC_ADMIN_CLAIM: 'roles' }).adminClaim).toBe('roles');
|
||||
});
|
||||
|
||||
it('OIDC_ONLY coerces the boolean-like family', () => {
|
||||
expect(deriveOidc({ OIDC_ONLY: 'True' }).only).toBe(true);
|
||||
expect(deriveOidc({ OIDC_ONLY: '1' }).only).toBe(true);
|
||||
expect(deriveOidc({ OIDC_ONLY: 'no' }).only).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSmtp', () => {
|
||||
it('SMTP_SKIP_TLS_VERIFY coerces the boolean-like family', () => {
|
||||
expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'true' }).skipTlsVerify).toBe(true);
|
||||
expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'TRUE' }).skipTlsVerify).toBe(true);
|
||||
expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'off' }).skipTlsVerify).toBe(false);
|
||||
expect(deriveSmtp({}).skipTlsVerify).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveMcp', () => {
|
||||
it('pins the mcp/index.ts module-const parsing', () => {
|
||||
expect(deriveMcp({}).sessionTtlMs).toBe(3_600_000);
|
||||
expect(deriveMcp({ MCP_MAX_SESSION_PER_USER: '5' }).maxSessionsPerUser).toBe(5);
|
||||
expect(deriveMcp({ MCP_MAX_SESSION_PER_USER: '0' }).maxSessionsPerUser).toBe(20);
|
||||
expect(deriveMcp({ MCP_SSE_KEEPALIVE: '0' }).sseKeepaliveMs).toBe(0);
|
||||
expect(deriveMcp({ MCP_RATE_LIMIT: '100' }).rateLimitMax).toBe(100);
|
||||
expect(deriveMcp({}).rateLimitMax).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePlugins', () => {
|
||||
it('kill-switch is default-on; only an explicit falsy value disables', () => {
|
||||
expect(derivePlugins({}).enabled).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGINS_ENABLED: 'anything' }).enabled).toBe(true); // out-of-family → default-on
|
||||
for (const v of ['false', '0', 'off', 'no', ' OFF ']) {
|
||||
expect(derivePlugins({ TREK_PLUGINS_ENABLED: v }).enabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('devLink and permissions jail coerce the boolean-like family; jail is default-on', () => {
|
||||
expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: '1' }).devLink).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: 'true' }).devLink).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: 'off' }).devLink).toBe(false);
|
||||
expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'OFF' }).permissionsOff).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'false' }).permissionsOff).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'on' }).permissionsOff).toBe(false);
|
||||
expect(derivePlugins({}).permissionsOff).toBe(false);
|
||||
});
|
||||
|
||||
it('allowPrivateEgress is a bool (supervisor normalizes it to the literal "on" for the child)', () => {
|
||||
expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'on' }).allowPrivateEgress).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'true' }).allowPrivateEgress).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'off' }).allowPrivateEgress).toBe(false);
|
||||
expect(derivePlugins({}).allowPrivateEgress).toBe(false);
|
||||
});
|
||||
|
||||
it('rate limits use Number(x) || default (rate-limit.ts / supervisor)', () => {
|
||||
expect(derivePlugins({}).rpcBurst).toBe(60);
|
||||
expect(derivePlugins({ TREK_PLUGIN_RPC_PER_SEC: '5' }).rpcPerSec).toBe(5);
|
||||
expect(derivePlugins({ TREK_PLUGIN_MAX_RSS_MB: 'abc' }).maxRssMb).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveIntegrations', () => {
|
||||
it('pins unsplash trim, transit base strip + default, overpass timeout', () => {
|
||||
expect(deriveIntegrations({ UNSPLASH_ACCESS_KEY: ' key ' }).unsplashAccessKey).toBe('key');
|
||||
expect(deriveIntegrations({}).transitApiBase).toBe('https://api.transitous.org');
|
||||
expect(deriveIntegrations({ TRANSIT_API_URL: 'https://t.example//' }).transitApiBase).toBe('https://t.example');
|
||||
expect(deriveIntegrations({}).overpassTimeoutMs).toBe(12000);
|
||||
expect(deriveIntegrations({ OVERPASS_TIMEOUT_MS: '-1' }).overpassTimeoutMs).toBe(12000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveBackup', () => {
|
||||
it('pins the backupService limit parsing (positive number or default)', () => {
|
||||
expect(deriveBackup({}).uploadLimitMb).toBe(500);
|
||||
expect(deriveBackup({ BACKUP_UPLOAD_LIMIT_MB: ' 750 ' }).uploadLimitMb).toBe(750);
|
||||
expect(deriveBackup({ BACKUP_UPLOAD_LIMIT_MB: '-1' }).uploadLimitMb).toBe(500);
|
||||
expect(deriveBackup({}).maxDecompressedMb).toBe(5 * 1024);
|
||||
expect(deriveBackup({ ENCRYPTION_KEY: 'x' }).encryptionKeyFromEnv).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveNet', () => {
|
||||
it('ALLOW_INTERNAL_NETWORK coerces the boolean-like family (ssrfGuard)', () => {
|
||||
expect(deriveNet({ ALLOW_INTERNAL_NETWORK: 'True' }).allowInternalNetwork).toBe(true);
|
||||
expect(deriveNet({ ALLOW_INTERNAL_NETWORK: '1' }).allowInternalNetwork).toBe(true);
|
||||
expect(deriveNet({}).allowInternalNetwork).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAll', () => {
|
||||
it('assembles every namespace', () => {
|
||||
const env = deriveAll({ PORT: '4000', DEMO_MODE: 'true' });
|
||||
expect(env.app.port).toBe(4000);
|
||||
expect(env.demo.enabled).toBe(true);
|
||||
for (const ns of [
|
||||
'app', 'http', 'session', 'demo', 'adminBootstrap', 'oidc', 'smtp', 'mcp',
|
||||
'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net',
|
||||
] as const) {
|
||||
expect(env[ns]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
csvList,
|
||||
csvListFiltered,
|
||||
numberOr,
|
||||
parseBool,
|
||||
parseDurationMs,
|
||||
positiveIntOr,
|
||||
positiveNumberOr,
|
||||
resolveKeepaliveMs,
|
||||
resolveSessionTtlMs,
|
||||
stripTrailingSlashes,
|
||||
} from '../../../src/app-config/parsers';
|
||||
|
||||
describe('parseBool', () => {
|
||||
it('accepts the whole truthy family, any casing, padded', () => {
|
||||
for (const v of ['true', 'TRUE', 'True', '1', 'on', 'ON', 'yes', 'Yes', ' true ']) {
|
||||
expect(parseBool(v)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the whole falsy family', () => {
|
||||
for (const v of ['false', 'FALSE', '0', 'off', 'OFF', 'no', 'No', ' off ']) {
|
||||
expect(parseBool(v)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns undefined for unset, blank and out-of-family values (default applies)', () => {
|
||||
expect(parseBool(undefined)).toBeUndefined();
|
||||
expect(parseBool('')).toBeUndefined();
|
||||
expect(parseBool(' ')).toBeUndefined();
|
||||
expect(parseBool('maybe')).toBeUndefined();
|
||||
expect(parseBool('enabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('numberOr', () => {
|
||||
it('parses plain numbers', () => {
|
||||
expect(numberOr('8080', 3001)).toBe(8080);
|
||||
});
|
||||
|
||||
// Pins the legacy `Number(x) || d` quirk: "0" is treated as unset.
|
||||
it('falls back on NaN, empty and "0"', () => {
|
||||
expect(numberOr('abc', 3001)).toBe(3001);
|
||||
expect(numberOr('', 3001)).toBe(3001);
|
||||
expect(numberOr(undefined, 3001)).toBe(3001);
|
||||
expect(numberOr('0', 3001)).toBe(3001);
|
||||
});
|
||||
|
||||
it('keeps negative values (legacy quirk: -5 || d === -5)', () => {
|
||||
expect(numberOr('-5', 3001)).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positiveNumberOr', () => {
|
||||
it('accepts positive numbers including fractions', () => {
|
||||
expect(positiveNumberOr('12.5', 500)).toBe(12.5);
|
||||
});
|
||||
|
||||
it('falls back on zero, negatives, NaN and unset', () => {
|
||||
expect(positiveNumberOr('0', 500)).toBe(500);
|
||||
expect(positiveNumberOr('-3', 500)).toBe(500);
|
||||
expect(positiveNumberOr('abc', 500)).toBe(500);
|
||||
expect(positiveNumberOr(undefined, 500)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positiveIntOr', () => {
|
||||
it('parses leading integers the way parseInt does (pins "5x" → 5)', () => {
|
||||
expect(positiveIntOr('5', 20)).toBe(5);
|
||||
expect(positiveIntOr('5x', 20)).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back on zero, negatives and garbage', () => {
|
||||
expect(positiveIntOr('0', 20)).toBe(20);
|
||||
expect(positiveIntOr('-1', 20)).toBe(20);
|
||||
expect(positiveIntOr('abc', 20)).toBe(20);
|
||||
expect(positiveIntOr(undefined, 20)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvList / csvListFiltered', () => {
|
||||
it('returns null when unset or empty', () => {
|
||||
expect(csvList(undefined)).toBeNull();
|
||||
expect(csvList('')).toBeNull();
|
||||
expect(csvListFiltered(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('trims entries', () => {
|
||||
expect(csvList(' a.example , b.example ')).toEqual(['a.example', 'b.example']);
|
||||
});
|
||||
|
||||
// The two ALLOWED_ORIGINS call sites differ: websocket keeps empty entries,
|
||||
// the CORS middleware drops them. Both behaviors are pinned.
|
||||
it('csvList keeps empty entries, csvListFiltered drops them', () => {
|
||||
expect(csvList('a,,b')).toEqual(['a', '', 'b']);
|
||||
expect(csvListFiltered('a,,b')).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripTrailingSlashes', () => {
|
||||
it('strips all trailing slashes', () => {
|
||||
expect(stripTrailingSlashes('https://x.example///')).toBe('https://x.example');
|
||||
expect(stripTrailingSlashes('https://x.example')).toBe('https://x.example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDurationMs', () => {
|
||||
it('parses the ms-style units used by SESSION_DURATION', () => {
|
||||
expect(parseDurationMs('1h')).toBe(3_600_000);
|
||||
expect(parseDurationMs('7d')).toBe(7 * 86_400_000);
|
||||
expect(parseDurationMs('500')).toBe(500); // unitless = ms
|
||||
expect(parseDurationMs(' 12H ')).toBe(12 * 3_600_000); // trims + case-insensitive
|
||||
expect(parseDurationMs('1.5h')).toBe(1.5 * 3_600_000);
|
||||
});
|
||||
|
||||
it('rejects zero, negatives and garbage', () => {
|
||||
expect(parseDurationMs('0h')).toBeNull();
|
||||
expect(parseDurationMs('bogus')).toBeNull();
|
||||
expect(parseDurationMs('-1d')).toBeNull();
|
||||
expect(parseDurationMs('1 fortnight')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSessionTtlMs', () => {
|
||||
it('defaults to 1h and clamps to 24h', () => {
|
||||
expect(resolveSessionTtlMs(undefined)).toBe(3_600_000);
|
||||
expect(resolveSessionTtlMs('120')).toBe(120_000);
|
||||
expect(resolveSessionTtlMs(String(48 * 60 * 60))).toBe(24 * 60 * 60 * 1000);
|
||||
expect(resolveSessionTtlMs('0')).toBe(3_600_000);
|
||||
expect(resolveSessionTtlMs('abc')).toBe(3_600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveKeepaliveMs', () => {
|
||||
it('defaults to 25s and allows 0 to disable', () => {
|
||||
expect(resolveKeepaliveMs(undefined)).toBe(25_000);
|
||||
expect(resolveKeepaliveMs('10')).toBe(10_000);
|
||||
expect(resolveKeepaliveMs('0')).toBe(0);
|
||||
expect(resolveKeepaliveMs('abc')).toBe(25_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import { validateEnvAtBoot, readEnv } from '../../../src/app-config/env';
|
||||
|
||||
describe('validateEnvAtBoot', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('passes with a completely empty environment (all defaults)', () => {
|
||||
expect(() => validateEnvAtBoot({})).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes with a representative valid environment', () => {
|
||||
expect(() =>
|
||||
validateEnvAtBoot({
|
||||
PORT: '3001',
|
||||
NODE_ENV: 'production',
|
||||
DEMO_MODE: 'false',
|
||||
SESSION_DURATION: '12h',
|
||||
SESSION_DURATION_REMEMBER: '90d',
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
APP_URL: 'https://trek.example.com',
|
||||
ALLOWED_ORIGINS: 'https://trek.example.com',
|
||||
LOG_LEVEL: 'debug',
|
||||
TRUST_PROXY: '2',
|
||||
OIDC_ISSUER: 'https://auth.example.com/realms/trek',
|
||||
SMTP_PORT: '587',
|
||||
MCP_RATE_LIMIT: '100',
|
||||
BACKUP_UPLOAD_LIMIT_MB: '750',
|
||||
TREK_PLUGINS_ENABLED: 'off',
|
||||
TREK_PLUGIN_PERMISSIONS: 'on',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('treats blank values as unset (defaults apply, no error)', () => {
|
||||
expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('ignores unknown environment variables', () => {
|
||||
expect(() => validateEnvAtBoot({ SOME_OTHER_TOOLS_VAR: '!!not-a-trek-var!!' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not validate NODE_ENV (non-standard values must keep booting)', () => {
|
||||
expect(() => validateEnvAtBoot({ NODE_ENV: 'staging' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws on present-but-malformed values with an aggregated report', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() =>
|
||||
validateEnvAtBoot({
|
||||
PORT: 'not-a-port',
|
||||
SESSION_DURATION: 'bogus',
|
||||
DEMO_MODE: 'maybe',
|
||||
APP_URL: 'not a url',
|
||||
}),
|
||||
).toThrow(/Invalid environment configuration \(4 problems\)/);
|
||||
const report = error.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
expect(report).toContain('PORT="not-a-port"');
|
||||
expect(report).toContain('SESSION_DURATION="bogus"');
|
||||
expect(report).toContain('DEMO_MODE="maybe"');
|
||||
expect(report).toContain('APP_URL="not a url"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['PORT', '70000'],
|
||||
['TRUST_PROXY', '1.5'],
|
||||
['DEFAULT_LANGUAGE', 'klingon'],
|
||||
['LOG_LEVEL', 'verbose'],
|
||||
['SESSION_DURATION_REMEMBER', '-1d'],
|
||||
['OIDC_ISSUER', 'auth.example.com'],
|
||||
['SMTP_PORT', 'smtp'],
|
||||
['MCP_SSE_KEEPALIVE', '-1'],
|
||||
['IDEMPOTENCY_TTL_SECONDS', '0'],
|
||||
['BACKUP_UPLOAD_LIMIT_MB', '0'],
|
||||
['HSTS_INCLUDE_SUBDOMAINS', 'enabled'],
|
||||
['TREK_PLUGINS_ENABLED', 'maybe'],
|
||||
['TREK_PLUGINS_DEV_LINK', 'always'],
|
||||
['TREK_PLUGIN_PERMISSIONS', 'disabled'],
|
||||
['TREK_PLUGIN_MAX_RSS_MB', '-100'],
|
||||
])('rejects malformed %s=%s', (key, value) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() => validateEnvAtBoot({ [key]: value })).toThrow(/Invalid environment configuration/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['DEMO_MODE', 'TRUE'],
|
||||
['TREK_API_DOCS_ENABLED', ' TRUE '],
|
||||
['TREK_PLUGINS_ENABLED', ' OFF '],
|
||||
['DEFAULT_LANGUAGE', 'EN'],
|
||||
['LOG_LEVEL', 'ERROR'],
|
||||
['SESSION_DURATION', ' 12H '],
|
||||
])('accepts differently-cased/padded valid %s=%s', (key, value) => {
|
||||
expect(() => validateEnvAtBoot({ [key]: value })).not.toThrow();
|
||||
});
|
||||
|
||||
// Booleans accept the whole boolean-like family (true/false, 1/0, on/off,
|
||||
// yes/no) and derive to real booleans via parseBool — DEMO_MODE=yes enables
|
||||
// demo mode, TREK_PLUGINS_DEV_LINK=true activates dev-link (see derive.test).
|
||||
it.each([
|
||||
['DEMO_MODE', 'yes'],
|
||||
['HSTS_INCLUDE_SUBDOMAINS', '1'],
|
||||
['TREK_PLUGINS_DEV_LINK', 'true'],
|
||||
['TREK_PLUGIN_PERMISSIONS', 'off'],
|
||||
['TREK_PLUGIN_ALLOW_PRIVATE_EGRESS', 'on'],
|
||||
['FORCE_HTTPS', 'On'],
|
||||
])('accepts boolean-like %s=%s', (key, value) => {
|
||||
expect(() => validateEnvAtBoot({ [key]: value })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEnv', () => {
|
||||
it('reads process.env live — a runtime mutation is visible on the next call', () => {
|
||||
const before = process.env.DEMO_MODE;
|
||||
try {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
expect(readEnv().demo.enabled).toBe(true);
|
||||
process.env.DEMO_MODE = 'false';
|
||||
expect(readEnv().demo.enabled).toBe(false);
|
||||
delete process.env.DEMO_MODE;
|
||||
expect(readEnv().demo.enabled).toBe(false);
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = before;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user