mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
feat(config): mark an install as centrally administered
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.
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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() })
|
||||
|
||||
+3
-2
@@ -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<string, PermissionLevel> }) => {
|
||||
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<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<User>) => Promise<void>
|
||||
uploadAvatar: (file: File) => Promise<AvatarResponse>
|
||||
deleteAvatar: () => Promise<void>
|
||||
setManaged: (val: boolean) => void
|
||||
setDemoMode: (val: boolean) => void
|
||||
setDevMode: (val: boolean) => void
|
||||
setIsPrerelease: (val: boolean) => void
|
||||
@@ -103,6 +106,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
deriveApp,
|
||||
deriveHttp,
|
||||
deriveSession,
|
||||
deriveManaged,
|
||||
deriveDemo,
|
||||
deriveAdminBootstrap,
|
||||
deriveOidc,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user