Files
TREK/server/tests/unit/app-config/validate.test.ts
T

214 lines
8.1 KiB
TypeScript

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'],
['DEFAULT_LANGUAGE', 'zh-TW'],
['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('validateEnvAtBoot — centrally administered preconditions', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('MANAGED-BOOT-001: refuses TREK_MANAGED without ENCRYPTION_KEY', () => {
// The combination boots fine and is wrong: the at-rest key falls back to a
// file in the data volume, and backupService puts that file in every archive
// an instance admin can download.
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ TREK_MANAGED: '1' })).toThrow(/1 problem/);
});
it('MANAGED-BOOT-002: names the variable, so the fix does not need the source', () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ TREK_MANAGED: 'true' })).toThrow();
expect(err.mock.calls[0][0]).toMatch(/ENCRYPTION_KEY/);
});
it('MANAGED-BOOT-003: passes once the key is supplied', () => {
expect(() =>
validateEnvAtBoot({ TREK_MANAGED: '1', ENCRYPTION_KEY: 'a'.repeat(64) }),
).not.toThrow();
});
it('MANAGED-BOOT-004: inert without the switch — a self-hoster keeps the file-based key', () => {
// The whole point of the check is that it cannot reach anyone who did not
// ask to be centrally administered.
expect(() => validateEnvAtBoot({})).not.toThrow();
expect(() => validateEnvAtBoot({ TREK_MANAGED: 'off' })).not.toThrow();
expect(() => validateEnvAtBoot({ TREK_MANAGED: 'false' })).not.toThrow();
});
it('MANAGED-BOOT-005: reports alongside schema problems rather than instead of them', () => {
// Both kinds land in one report: fixing the env should take one pass, not a
// restart per problem.
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ TREK_MANAGED: '1', PORT: 'not-a-port' })).toThrow(
/2 problems/,
);
expect(err.mock.calls[0][0]).toMatch(/PORT/);
expect(err.mock.calls[0][0]).toMatch(/ENCRYPTION_KEY/);
});
});
describe('validateEnvAtBoot — S3 all-or-nothing preconditions', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const full = {
TREK_S3_ENDPOINT: 'http://127.0.0.1:9000',
TREK_S3_BUCKET: 'trek',
TREK_S3_ACCESS_KEY_ID: 'ak',
TREK_S3_SECRET_ACCESS_KEY: 'sk',
};
it('S3-BOOT-001: refuses a partial TREK_S3_* set', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ TREK_S3_ENDPOINT: full.TREK_S3_ENDPOINT })).toThrow(
/Invalid environment configuration \(3 problems\)/,
);
});
it('S3-BOOT-002: names each missing variable and the why', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ TREK_S3_REGION: 'auto' })).toThrow();
const report = error.mock.calls.map((c) => c.join(' ')).join('\n');
expect(report).toContain('TREK_S3_ENDPOINT is unset');
expect(report).toContain('TREK_S3_SECRET_ACCESS_KEY is unset');
expect(report).toContain('required when any TREK_S3_');
});
it('S3-BOOT-003: passes with the full required set', () => {
expect(() => validateEnvAtBoot(full)).not.toThrow();
});
it('S3-BOOT-004: inert when no TREK_S3_* variable is set', () => {
expect(() => validateEnvAtBoot({})).not.toThrow();
});
it('S3-BOOT-005: rejects a malformed key prefix alongside schema problems', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => validateEnvAtBoot({ ...full, TREK_S3_KEY_PREFIX: '../evil', TREK_S3_ENDPOINT: 'not a url' })).toThrow(
/Invalid environment configuration \(2 problems\)/,
);
});
});
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;
}
});
});