Files
TREK/server/tests/unit/plugins/host-compat.test.ts
T
jubnl bc10fba389 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* 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.
2026-09-04 00:43:06 +02:00

157 lines
6.7 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest';
import {
hostVersion,
normalizedHost,
isValidTrekRange,
minTrekOf,
hostSatisfies,
trekRangeBypassed,
bypassedRange,
__resetHostWarningForTests,
} from '../../../src/nest/plugins/install/host-compat';
/**
* The TREK-version gate (#plugins). Every install front door and the activation gate
* answer "can this plugin run here?" through these functions, so their edges are the
* edges of the whole feature.
*/
const APP_VERSION = process.env.APP_VERSION;
afterEach(() => {
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
else process.env.APP_VERSION = APP_VERSION;
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
__resetHostWarningForTests();
});
describe('isValidTrekRange', () => {
it('accepts the ranges a plugin actually declares', () => {
for (const r of ['>=3.2.0 <4.0.0', '^3.2.0', '~3.2', '>=3', '<4.0.0', '3.x', '*']) {
expect(isValidTrekRange(r), r).toBe(true);
}
});
it('rejects a range no version can ever satisfy — validRange() alone would accept it', () => {
// Syntactically fine, semantically empty. A plugin declaring this would be
// uninstallable everywhere with nothing to tell its author why.
expect(isValidTrekRange('>=4.0.0 <3.0.0')).toBe(false);
});
it('rejects junk without letting semver.minVersion throw out of it', () => {
for (const r of ['latest', 'v3-ish', '3.2+', '', ' ', undefined, null, 42, {}]) {
expect(isValidTrekRange(r), String(r)).toBe(false);
}
});
});
describe('minTrekOf', () => {
it('reads the lower bound off the RANGE, not off the first version-shaped substring', () => {
// The regression this replaces: the old regex took the first X.Y.Z in the string, so
// "<4.0.0" reported a *minimum* of 4.0.0 — the exact inverse of what the plugin says.
expect(minTrekOf('<4.0.0')).toBeUndefined(); // lower bound is 0.0.0 → nothing to show
expect(minTrekOf('>=3.2.0 <4.0.0')).toBe('3.2.0');
expect(minTrekOf('^3.2.0')).toBe('3.2.0');
expect(minTrekOf('~3.2')).toBe('3.2.0');
expect(minTrekOf('>=3')).toBe('3.0.0');
expect(minTrekOf('>=3.2.0-beta.1')).toBe('3.2.0-beta.1');
});
it('has no lower bound to show for an unbounded range', () => {
expect(minTrekOf('*')).toBeUndefined();
});
});
describe('hostSatisfies', () => {
it('admits a host inside the range and refuses one outside it', () => {
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.3.0')).toBe(true);
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.2.0')).toBe(true);
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.1.9')).toBe(false);
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0')).toBe(false); // the exclusive upper bound
});
it('treats a prerelease host as its target release — both edges matter', () => {
// Plain semver would fail 3.4.0-rc.1 against a range that names no prerelease, which
// would make EVERY plugin incompatible the moment TREK shipped an rc.
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.4.0-rc.1')).toBe(true);
// And includePrerelease would order 4.0.0-rc.1 BEFORE 4.0.0, so it would satisfy
// "<4.0.0" — loading a plugin that disclaims TREK 4 onto a build that already carries
// TREK 4's breaking changes. An rc of 4.0 IS 4.0 for compatibility purposes.
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0-rc.1')).toBe(false);
expect(hostSatisfies('>=4.0.0', '4.0.0-rc.1')).toBe(true);
});
it('never blocks when the host version is not semver at all (APP_VERSION=dev)', () => {
// The Docker build ARG defaults to the literal "dev". An unversioned build must stay
// fully usable rather than refusing every plugin it has.
process.env.APP_VERSION = 'dev';
expect(normalizedHost()).toBeNull();
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
expect(hostSatisfies(null)).toBe(true);
});
it('warns once — and only once — about an unparseable host, so a misconfig is visible', () => {
process.env.APP_VERSION = 'nonsense';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
normalizedHost();
normalizedHost();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/not a semver version/);
warn.mockRestore();
});
it('refuses an undeclared or unsatisfiable range rather than waving it through', () => {
// The caller decides what an absent range MEANS (reject at install, "unknown" at
// activation) — it must not pass by accident here.
expect(hostSatisfies(null, '3.3.0')).toBe(false);
expect(hostSatisfies('', '3.3.0')).toBe(false);
expect(hostSatisfies('>=4.0.0 <3.0.0', '3.3.0')).toBe(false);
});
it('reads the running version from APP_VERSION, falling back to the package version', () => {
process.env.APP_VERSION = '3.9.1';
expect(hostVersion()).toBe('3.9.1');
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
expect(hostSatisfies('>=4.0.0')).toBe(false);
delete process.env.APP_VERSION;
expect(hostVersion()).toMatch(/^\d+\.\d+\.\d+/); // package.json
});
});
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE: an operator's explicit "install it anyway" for a plugin
* whose author has not updated its `trek` range. The gates warn instead of refusing, but
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is
* outside its range.
*/
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
it('is off by default and only an explicit truthy value turns it on', () => {
expect(trekRangeBypassed()).toBe(false);
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '0';
expect(trekRangeBypassed()).toBe(false);
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(trekRangeBypassed()).toBe(true);
});
it('reports a bypassed mismatch only when the bypass is on AND the range does not admit the host', () => {
process.env.APP_VERSION = '3.3.0';
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' });
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
});
it('covers a plugin that declared no range at all — the same author oversight', () => {
process.env.APP_VERSION = '3.3.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
});
it('leaves hostSatisfies telling the truth while bypassed', () => {
process.env.APP_VERSION = '3.3.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(hostSatisfies('>=4.0.0')).toBe(false);
});
});