Files
TREK/server/tests/unit/app-config/derive.test.ts
T
Maurice 4a88dbcbcf fix(places): say where a result came from, and stop asking questions in the form
Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
2026-09-08 20:41:57 +02:00

287 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { OVERPASS_TIMEOUT_DEFAULT_MS } from '../../../src/nest/maps/maps.helpers';
import {
deriveApp,
deriveHttp,
deriveSession,
deriveDemo,
deriveMaps,
deriveOidc,
deriveSmtp,
deriveMcp,
derivePlugins,
deriveIntegrations,
deriveBackup,
deriveNet,
derivePaths,
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/');
});
it('DEFAULT_LANGUAGE resolves: case-insensitive, canonical code, en fallback', () => {
expect(deriveApp({ DEFAULT_LANGUAGE: 'EN' }).defaultLanguage).toBe('en');
expect(deriveApp({ DEFAULT_LANGUAGE: 'de' }).defaultLanguage).toBe('de');
expect(deriveApp({ DEFAULT_LANGUAGE: 'klingon' }).defaultLanguage).toBe('en');
expect(deriveApp({}).defaultLanguage).toBe('en');
// Mixed-case canonical codes resolve to their canonical form (legacy could
// never match zh-TW and silently served English).
expect(deriveApp({ DEFAULT_LANGUAGE: 'zh-TW' }).defaultLanguage).toBe('zh-TW');
expect(deriveApp({ DEFAULT_LANGUAGE: 'zh-tw' }).defaultLanguage).toBe('zh-TW');
});
});
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: numeric hop count wins, 1 only as fallback (globalMiddleware)', () => {
expect(deriveHttp({ TRUST_PROXY: '2' }).trustProxy).toBe(2);
// 0 is a valid hop count ("trust no proxy"), not a missing value.
expect(deriveHttp({ TRUST_PROXY: '0' }).trustProxy).toBe(0);
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');
});
it('DEMO_ADMIN_PASS: adminPassSet tells the default apart from an explicit value', () => {
expect(deriveDemo({}).adminPassSet).toBe(false);
expect(deriveDemo({ DEMO_ADMIN_PASS: '' }).adminPassSet).toBe(false);
expect(deriveDemo({ DEMO_ADMIN_PASS: 'hunter22' }).adminPassSet).toBe(true);
});
});
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('ignoreTrekRange is off unless explicitly truthy — the version gate must never bypass by accident', () => {
expect(derivePlugins({}).ignoreTrekRange).toBe(false);
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: '' }).ignoreTrekRange).toBe(false);
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: 'off' }).ignoreTrekRange).toBe(false);
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: '1' }).ignoreTrekRange).toBe(true);
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: 'TRUE' }).ignoreTrekRange).toBe(true);
});
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');
// Against the constant, not a literal: the client budget is derived from the timeout
// the query itself carries, and pinning the number here is how the two drifted apart.
expect(deriveIntegrations({}).overpassTimeoutMs).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
expect(deriveIntegrations({ OVERPASS_TIMEOUT_MS: '-1' }).overpassTimeoutMs).toBe(OVERPASS_TIMEOUT_DEFAULT_MS);
});
it('floors the LLM ceiling to a whole number — undici rejects a fractional headersTimeout', () => {
expect(deriveIntegrations({}).llmTimeoutMs).toBe(900_000);
expect(deriveIntegrations({ LLM_TIMEOUT_MS: '-1' }).llmTimeoutMs).toBe(900_000);
expect(deriveIntegrations({ LLM_TIMEOUT_MS: '60000.5' }).llmTimeoutMs).toBe(60_000);
});
});
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('derivePaths', () => {
it('passes the path vars through raw — defaulting stays at the consumer', () => {
expect(derivePaths({ TREK_WIKI_DIR: '/w' }).wikiDir).toBe('/w');
expect(derivePaths({ TREK_PLACE_PHOTO_DIR: '/p' }).placePhotoDir).toBe('/p');
expect(derivePaths({}).wikiDir).toBeUndefined();
expect(derivePaths({}).placePhotoDir).toBeUndefined();
});
});
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();
}
});
});
describe('deriveMaps', () => {
it('TREK_PLACES_ENABLED is on unless it says the literal "false"', () => {
// Fail-open, and deliberately not the boolean-like family the switches
// above accept: a typo must not silently drop a whole install back to
// Nominatim, whose usage policy forbids what TREK was doing with it.
expect(deriveMaps({} as never).trekPlacesEnabled).toBe(true);
expect(deriveMaps({ TREK_PLACES_ENABLED: 'false' } as never).trekPlacesEnabled).toBe(false);
for (const value of ['true', 'FALSE', '0', 'no', '']) {
expect(deriveMaps({ TREK_PLACES_ENABLED: value } as never).trekPlacesEnabled, value).toBe(true);
}
});
});