From bfb3bcd63c4806c71970545584183148a3d722e3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 15 Aug 2026 11:41:19 +0200 Subject: [PATCH] feat(config): mark an install as centrally administered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some installs are run by somebody other than the person who administers them. The operator owns the credentials, the configuration and the upgrade schedule, and the admin of the instance is a user of the result rather than the one who set it up. Nothing in the app knows that today, so it keeps offering settings whose values are not the admin's to change, and the honest answer to half of them would be a 403 the UI never anticipated. TREK_MANAGED says it once. It is a single boolean and the surfaces that care read it themselves, so adding one never comes back to the config layer. Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs token: roughly sixty tests mutate env mid-lifetime, and a value frozen per built app breaks all of them. `=== true` on the way in, so an unset, empty or misspelled value means off — a managed install sets it deliberately, and a typo must not quietly take settings away from an admin who never asked for it. The guard keys on a decorator, not on a path. MfaPolicyGuard documents where the two literal path lists it replaced ended up: they stopped being maintained, and every endpoint added afterwards silently fell on the wrong side. A marker on the handler cannot drift away from the handler. It carries a mandatory reason, which is for the next reader of the source rather than for the client, so the marks stay auditable. It registers third, after the auth and MFA guards, so a stranger still gets the 401 those two would have given them instead of a 403 that would confirm the route exists. And it checks the mode before it touches metadata, because it runs on every request of every install and almost all of them are self-hosted. One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same condition and neither can be changed now without hunting for the other. The flag reaches the client through app-config so the UI can stop offering what the server would refuse anyway, but it is deliberately not persisted the way demo_mode is: two installs can share a browser profile, and a stale "this one is managed" would take settings away on an install that never set it. No route carries the marker yet. The per-domain marks follow. Unset, which is every self-hosted install, nothing about this changes anything. --- README.md | 1 + client/src/App.test.tsx | 24 ++++++ client/src/App.tsx | 5 +- client/src/pages/login/useLogin.ts | 1 + client/src/store/authStore.ts | 10 +++ client/src/types.ts | 2 + server/src/app-config/README.md | 2 +- server/src/app-config/derive.ts | 19 ++++ server/src/app-config/env.schema.ts | 1 + server/src/app-config/index.ts | 1 + .../nest/app-config/runtime-env.service.ts | 8 ++ server/src/nest/app.module.ts | 5 ++ server/src/nest/auth/auth.service.ts | 5 ++ server/src/nest/common/managed.guard.ts | 42 +++++++++ server/src/nest/common/managed.ts | 44 ++++++++++ server/tests/unit/nest/auth.service.test.ts | 17 ++++ server/tests/unit/nest/managed.guard.test.ts | 82 ++++++++++++++++++ server/tests/unit/nest/managed.test.ts | 86 +++++++++++++++++++ 18 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 server/src/nest/common/managed.guard.ts create mode 100644 server/src/nest/common/managed.ts create mode 100644 server/tests/unit/nest/managed.guard.test.ts create mode 100644 server/tests/unit/nest/managed.test.ts diff --git a/README.md b/README.md index 894ad5211..f0d08bc04 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ Caddy handles TLS and WebSockets automatically. | `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` | | `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | | **Other** | | | +| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index ecee3d3a6..29124210c 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -387,6 +387,30 @@ describe('App — on-mount effects', () => { await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)) }) + it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => { + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true })) + ) + const setManaged = vi.fn() + useAuthStore.setState({ + isLoading: false, + isAuthenticated: false, + loadUser: vi.fn().mockResolvedValue(undefined), + setManaged, + }) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true)) + + // An older server that does not send the field must not leave the flag + // stuck on from a previous install in the same browser profile. + setManaged.mockClear() + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({})) + ) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false)) + }) + it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => { const loadSettings = vi.fn().mockResolvedValue(undefined) seedAuth({ isAuthenticated: true, user: buildUser() }) diff --git a/client/src/App.tsx b/client/src/App.tsx index 18a057e8a..203edd221 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -278,7 +278,7 @@ function RouteFallback() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -295,7 +295,8 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record }) => { + setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index d6adffd4b..7e8d9dc1b 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -12,6 +12,7 @@ interface AppConfig { has_users: boolean allow_registration: boolean setup_complete: boolean + managed?: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 1eadbe1fa..4355d424c 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -38,6 +38,8 @@ interface AuthState { * ?redirect= pointing back at the page they just left. Transient. */ loggingOut: boolean error: string | null + /** The operator of this install owns its configuration, not the admin. */ + managed: boolean demoMode: boolean devMode: boolean isPrerelease: boolean @@ -63,6 +65,7 @@ interface AuthState { updateProfile: (profileData: Partial) => Promise uploadAvatar: (file: File) => Promise deleteAvatar: () => Promise + setManaged: (val: boolean) => void setDemoMode: (val: boolean) => void setDevMode: (val: boolean) => void setIsPrerelease: (val: boolean) => void @@ -103,6 +106,7 @@ export const useAuthStore = create()( authCheckFailed: false, loggingOut: false, error: null, + managed: false, demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, isPrerelease: false, @@ -327,6 +331,12 @@ export const useAuthStore = create()( set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null })) }, + // Not persisted, unlike demoMode above: two installs can share a browser + // profile, and a stale 'this one is managed' would then take settings away + // from an admin on an install that never set the flag. Re-read on every boot + // from app-config, which is one request the app makes anyway. + setManaged: (val: boolean) => set({ managed: val }), + setDemoMode: (val: boolean) => { if (val) localStorage.setItem('demo_mode', 'true') else localStorage.removeItem('demo_mode') diff --git a/client/src/types.ts b/client/src/types.ts index 7820afdcf..9b6385630 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -244,6 +244,8 @@ export interface GeoJsonFeatureCollection { export interface AppConfig { has_users: boolean allow_registration: boolean + /** True when the operator of this install owns its configuration, not the admin */ + managed?: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index 594bb2b36..a647820ae 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -69,7 +69,7 @@ override these set them at file top, before the SUT import.) **Runtime-toggled** (read live on every access via `readEnv()` / `RuntimeEnvService`; tests mutate these mid-lifetime): -DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, +TREK_MANAGED, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS, OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS, ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL, diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index d2c8ec2e8..645283a39 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -102,6 +102,24 @@ export function deriveSession(raw: RawEnv) { }; } +/** + * Whether this instance is centrally administered: somebody other than its + * admin user owns the configuration, the credentials and the upgrade schedule. + * + * Only a boolean, on purpose. It answers "who configures this install", and the + * surfaces that care read it themselves rather than being listed here, so adding + * one never touches this file. + * + * `=== true` so an unset, empty or unparseable value means off. A managed + * install sets it deliberately; nothing should be able to switch it on by + * accident, and a typo must not silently take settings away from an admin. + */ +export function deriveManaged(raw: RawEnv) { + return { + enabled: parseBool(raw.TREK_MANAGED) === true, + }; +} + export function deriveDemo(raw: RawEnv) { return { enabled: parseBool(raw.DEMO_MODE) === true, @@ -236,6 +254,7 @@ export function deriveAll(raw: RawEnv) { app: deriveApp(raw), http: deriveHttp(raw), session: deriveSession(raw), + managed: deriveManaged(raw), demo: deriveDemo(raw), adminBootstrap: deriveAdminBootstrap(raw), oidc: deriveOidc(raw), diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 2d8d489ac..7bb0f2689 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -130,6 +130,7 @@ export const envSchema = z.object({ // Admin / demo ADMIN_EMAIL: anyString, ADMIN_PASSWORD: anyString, + TREK_MANAGED: boolStr, DEMO_MODE: boolStr, DEMO_ADMIN_USER: anyString, DEMO_ADMIN_EMAIL: anyString, diff --git a/server/src/app-config/index.ts b/server/src/app-config/index.ts index 5da92b321..010423d20 100644 --- a/server/src/app-config/index.ts +++ b/server/src/app-config/index.ts @@ -6,6 +6,7 @@ export { deriveApp, deriveHttp, deriveSession, + deriveManaged, deriveDemo, deriveAdminBootstrap, deriveOidc, diff --git a/server/src/nest/app-config/runtime-env.service.ts b/server/src/nest/app-config/runtime-env.service.ts index 61eee77b3..0ff01fce1 100644 --- a/server/src/nest/app-config/runtime-env.service.ts +++ b/server/src/nest/app-config/runtime-env.service.ts @@ -21,6 +21,14 @@ export class RuntimeEnvService { return readEnv().demo.enabled; } + /** + * True when somebody other than this instance's admin owns its configuration. + * Runtime-toggled like DEMO_MODE, so never cache the result in a field. + */ + isManaged(): boolean { + return readEnv().managed.enabled; + } + /** True only under exactly NODE_ENV='test' (case-sensitive, like the :memory: DB pick). */ isTest(): boolean { return readEnv().app.isTest; diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e01b2d90d..a9dcfbb4d 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -9,6 +9,7 @@ import { PlatformModule } from './platform/platform.module'; import { McpTransportModule } from './mcp-transport/mcp-transport.module'; import { GlobalAuthGuard } from './auth/global-auth.guard'; import { MfaPolicyGuard } from './auth/mfa-policy.guard'; +import { ManagedGuard } from './common/managed.guard'; import { WeatherModule } from './weather/weather.module'; import { HelpModule } from './help/help.module'; import { AirportsModule } from './airports/airports.module'; @@ -81,6 +82,10 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; // instead of verifying the token a second time, which is what the Express // middleware it replaces did on every /api request. { provide: APP_GUARD, useClass: MfaPolicyGuard }, + // Third, and only third: a stranger gets the 401 the two above would have + // given them rather than a 403 that confirms the route exists. Inert unless + // the instance is centrally administered AND the route carries the marker. + { provide: APP_GUARD, useClass: ManagedGuard }, // Global error-envelope normaliser (DI-registered so it also catches // framework-level exceptions like the not-found handler). { provide: APP_FILTER, useClass: TrekExceptionFilter }, diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index bbbb356d1..f704e6e12 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -280,6 +280,11 @@ export class AuthService { oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, require_mfa: requireMfaRow?.value === 'true', allowed_file_types: this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'allowed_file_types'")?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', + // Whether the configuration belongs to whoever operates this install + // rather than to its admin. The client uses it to stop offering settings + // the server would refuse anyway — it is an honesty flag for the UI, never + // the boundary itself, which is the guard and the operator's network. + managed: readEnv().managed.enabled, demo_mode: isDemo, demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined, demo_password: isDemo ? 'demo12345' : undefined, diff --git a/server/src/nest/common/managed.guard.ts b/server/src/nest/common/managed.guard.ts new file mode 100644 index 000000000..57e6ae099 --- /dev/null +++ b/server/src/nest/common/managed.guard.ts @@ -0,0 +1,42 @@ +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR } from './managed'; + +/** + * Refuses the routes a centrally administered instance does not hand to its own + * admin: the ones whose value comes from whoever operates the install. + * + * Registered as the third APP_GUARD, after GlobalAuthGuard and MfaPolicyGuard. + * The order is the whole point — a stranger has to get the 401 the other two + * would have given them, not a 403 that confirms the route exists and tells them + * something about how this install is run. + * + * Marked routes only. An unmarked route is untouched in every mode, so the + * guard is inert on a self-hosted install and stays that way even if somebody + * sets the flag there. + * + * Explicitly not a path list. `MfaPolicyGuard` documents where the two literal + * path lists it replaced ended up: they stopped being maintained, and every + * endpoint added afterwards silently fell on the wrong side. A decorator lives + * on the handler it describes and cannot drift away from it. + */ +@Injectable() +export class ManagedGuard implements CanActivate { + constructor( + private readonly env: RuntimeEnvService, + private readonly reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + if (!this.env.isManaged()) return true; + + const marked = this.reflector.getAllAndOverride(MANAGED_FORBIDDEN, [ + context.getHandler(), + context.getClass(), + ]); + if (!marked) return true; + + throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403); + } +} diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts new file mode 100644 index 000000000..b8847db41 --- /dev/null +++ b/server/src/nest/common/managed.ts @@ -0,0 +1,44 @@ +import { SetMetadata } from '@nestjs/common'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; + +/** Metadata key `@ManagedForbidden()` writes. */ +export const MANAGED_FORBIDDEN = 'trek:managed-forbidden'; + +/** + * The one 403 body a managed instance returns, shared by REST and MCP. + * + * Deliberately a single constant rather than a per-route message. DEMO_MODE grew + * two texts for the same condition — 'Uploads are disabled in demo mode. Self-host + * TREK for full functionality.' on REST and 'Write operations are disabled in demo + * mode.' on MCP — and now neither can be changed without hunting for the other. + * + * It says who owns the setting and stops there. A caller learns that the value is + * not theirs to change, which is all they can act on; the reason belongs to + * whoever runs the install, not in an API response. + */ +export const MANAGED_FORBIDDEN_ERROR = { + error: 'This is configured by the operator of this instance.', + code: 'MANAGED_FORBIDDEN', +} as const; + +/** + * Marks a route as unavailable while the instance is centrally administered. + * + * The reason is mandatory and is for the next reader of the source, not for the + * client: it records why this particular surface cannot be left to the instance + * admin. Without it the marks turn into a list nobody can audit, which is how + * the path lists this codebase has already replaced once went stale. + */ +export const ManagedForbidden = (reason: string) => SetMetadata(MANAGED_FORBIDDEN, { reason }); + +/** + * The handler-side flavour of the guard below. + * + * Needed because a guard throws before the multipart parser has drained the + * request body, which leaves the client with an ECONNRESET instead of the 403 + * (PROFILE-015, and the reason `isDemoWriteBlocked` is a function too). Upload + * routes call this after the interceptor instead of carrying the decorator. + */ +export function isManagedBlocked(env: RuntimeEnvService): boolean { + return env.isManaged(); +} diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 1fc96769e..8f092acbd 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -571,6 +571,23 @@ describe('getAppConfig', () => { expect(cfg.demo_password).toBe('demo12345'); vi.unstubAllEnvs(); }); + + it('AUTH-DB-052b: managed is false unless the install says otherwise', () => { + createUser(testDb); + expect(svc.getAppConfig(null).managed).toBe(false); + }); + + it('AUTH-DB-052c: managed reaches the client, so the UI can stop offering what the server refuses', () => { + vi.stubEnv('TREK_MANAGED', 'true'); + createUser(testDb); + const cfg = svc.getAppConfig(null); + expect(cfg.managed).toBe(true); + // Additive only: the flag says who owns the configuration and changes + // nothing else about the instance. + expect(cfg.demo_mode).toBe(false); + expect(cfg.password_registration).toBe(true); + vi.unstubAllEnvs(); + }); }); describe('demoLogin', () => { diff --git a/server/tests/unit/nest/managed.guard.test.ts b/server/tests/unit/nest/managed.guard.test.ts new file mode 100644 index 000000000..ec0a716d3 --- /dev/null +++ b/server/tests/unit/nest/managed.guard.test.ts @@ -0,0 +1,82 @@ +/** + * ManagedGuard — refuses the routes a centrally administered instance does not + * hand to its own admin. + * + * Two properties carry the design and both are pinned below: it is inert unless + * the route was marked (MANAGED-GUARD-002), so a self-hosted install cannot + * notice it exists, and it keys on a decorator rather than a path list + * (MANAGED-GUARD-005). The path lists MfaPolicyGuard replaced are the reason for + * the second one: they stopped being maintained, and every endpoint added after + * they were written silently fell on the wrong side. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ManagedGuard } from '../../../src/nest/common/managed.guard'; +import { MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR } from '../../../src/nest/common/managed'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; + +function makeGuard(managed: boolean, marked: boolean) { + const getAllAndOverride = vi.fn((key: string) => + key === MANAGED_FORBIDDEN && marked ? { reason: 'the operator holds this' } : undefined, + ); + const guard = new ManagedGuard( + { isManaged: () => managed } as unknown as RuntimeEnvService, + { getAllAndOverride } as unknown as Reflector, + ); + return { guard, getAllAndOverride }; +} + +const ctx = { + getHandler: () => function handler() {}, + getClass: () => class Controller {}, +} as never; + +const thrown = (run: () => unknown) => { + try { + run(); + } catch (err) { + return err as HttpException; + } + return undefined; +}; + +describe('ManagedGuard', () => { + it('MANAGED-GUARD-001: lets a marked route through on a self-hosted install', () => { + const { guard } = makeGuard(false, true); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('MANAGED-GUARD-002: lets an unmarked route through on a managed install', () => { + const { guard } = makeGuard(true, false); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('MANAGED-GUARD-003: refuses a marked route on a managed install', () => { + const { guard } = makeGuard(true, true); + const err = thrown(() => guard.canActivate(ctx)); + + expect(err).toBeInstanceOf(HttpException); + expect(err?.getStatus()).toBe(403); + expect(err?.getResponse()).toEqual(MANAGED_FORBIDDEN_ERROR); + }); + + it('MANAGED-GUARD-004: does not read metadata at all while the mode is off', () => { + // The cheap check comes first because this guard runs on every request of + // every install, and all but a handful of them are self-hosted. + const { guard, getAllAndOverride } = makeGuard(false, true); + guard.canActivate(ctx); + + expect(getAllAndOverride).not.toHaveBeenCalled(); + }); + + it('MANAGED-GUARD-005: asks for the decorator on handler and controller, not for a path', () => { + const { guard, getAllAndOverride } = makeGuard(true, false); + guard.canActivate(ctx); + + expect(getAllAndOverride).toHaveBeenCalledWith(MANAGED_FORBIDDEN, [ + expect.any(Function), + expect.any(Function), + ]); + }); +}); diff --git a/server/tests/unit/nest/managed.test.ts b/server/tests/unit/nest/managed.test.ts new file mode 100644 index 000000000..f30a375a9 --- /dev/null +++ b/server/tests/unit/nest/managed.test.ts @@ -0,0 +1,86 @@ +/** + * TREK_MANAGED — the switch that says the configuration belongs to whoever runs + * this install rather than to its admin. + * + * Runtime-toggled, exactly like DEMO_MODE: read live on every access, never + * snapshotted into a field or a registerAs token, because roughly sixty tests + * mutate process.env mid-lifetime and a frozen value breaks all of them. + * MANAGED-004 is the case that pins it. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { isManagedBlocked, MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR, ManagedForbidden } from '../../../src/nest/common/managed'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { deriveManaged } from '../../../src/app-config/derive'; + +const env = new RuntimeEnvService(); + +afterEach(() => { + delete process.env.TREK_MANAGED; +}); + +describe('deriveManaged', () => { + it('MANAGED-001: off when the variable is unset', () => { + expect(deriveManaged({}).enabled).toBe(false); + }); + + it('MANAGED-002: on for the documented truthy spellings', () => { + for (const value of ['true', 'TRUE', '1', 'yes', 'on']) { + expect(deriveManaged({ TREK_MANAGED: value }).enabled).toBe(true); + } + }); + + it('MANAGED-003: off for an empty, unparseable or falsy value', () => { + // A typo must not take settings away from an admin who never asked for it, + // so anything that is not recognisably true stays off. + for (const value of ['', 'false', '0', 'no', 'off', 'maybe', 'TREK_MANAGED']) { + expect(deriveManaged({ TREK_MANAGED: value }).enabled).toBe(false); + } + }); +}); + +describe('RuntimeEnvService.isManaged', () => { + it('MANAGED-004: reads the env live, so a toggle takes effect without a restart', () => { + expect(env.isManaged()).toBe(false); + + process.env.TREK_MANAGED = 'true'; + expect(env.isManaged()).toBe(true); + + delete process.env.TREK_MANAGED; + expect(env.isManaged()).toBe(false); + }); +}); + +describe('isManagedBlocked', () => { + it('MANAGED-005: false on a self-hosted install', () => { + expect(isManagedBlocked(env)).toBe(false); + }); + + it('MANAGED-006: true once the instance is centrally administered', () => { + process.env.TREK_MANAGED = 'true'; + expect(isManagedBlocked(env)).toBe(true); + }); +}); + +describe('the shared 403', () => { + it('MANAGED-007: one body, and it does not name a reason', () => { + // Pinned because DEMO_MODE grew two texts for one condition (REST says + // 'Self-host TREK for full functionality.', MCP says something else) and + // neither can now be changed without hunting for the other. + expect(MANAGED_FORBIDDEN_ERROR).toEqual({ + error: 'This is configured by the operator of this instance.', + code: 'MANAGED_FORBIDDEN', + }); + }); +}); + +describe('ManagedForbidden', () => { + it('MANAGED-008: writes the reason onto the handler for the next reader', () => { + class Controller { + @ManagedForbidden('the operator holds this credential') + handler() {} + } + + const meta = Reflect.getMetadata(MANAGED_FORBIDDEN, Controller.prototype.handler); + expect(meta).toEqual({ reason: 'the operator holds this credential' }); + }); +});