mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
61bef334d5
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule.
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import {
|
|
deriveApp,
|
|
deriveHttp,
|
|
deriveSession,
|
|
deriveDemo,
|
|
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');
|
|
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('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();
|
|
}
|
|
});
|
|
});
|