Files
TREK/server/tests/unit/plugins/host-compat.test.ts
T
Maurice 0067b0ff19 fix(plugins): let an admin run a plugin outside its TREK range, and tidy the plugins panel
TREK 5.0 left most registry plugins blocked because their authors still declare
"<5.0.0", and the only way past that was TREK_PLUGINS_IGNORE_TREK_RANGE for every
plugin at once. An admin can now install or switch on one such plugin after a
warning; the consent is kept on its row for that plugin version and TREK major,
so an update or a new major asks again. The environment switch works as before.

Discover marks a plugin not confirmed for this TREK on its card, and Install opens
the warning first. Admin > Plugins reads cleaner: one row of alike facts per plugin
with each state that needs attention on its own line, type, status and order behind
one Filter button, and the containment notes in a dialog behind the shield in the
header. The row, the filter menu and the dialog moved into their own files.
2026-10-26 09:49:22 +01:00

192 lines
8.5 KiB
TypeScript

import {
hostVersion,
normalizedHost,
isValidTrekRange,
minTrekOf,
hostSatisfies,
trekRangeBypassed,
bypassedRange,
acceptanceHolds,
__resetHostWarningForTests,
} from '../../../src/nest/plugins/install/host-compat';
import { describe, it, expect, afterEach, vi } from 'vitest';
/**
* 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('acceptanceHolds', () => {
it('holds for the same plugin version on the same TREK major only', () => {
process.env.APP_VERSION = '5.0.1';
const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' };
expect(acceptanceHolds('1.0.3', given)).toBe(true);
expect(acceptanceHolds('1.1.0', given)).toBe(false); // an update asks again
process.env.APP_VERSION = '6.0.0';
expect(acceptanceHolds('1.0.3', given)).toBe(false); // so does a new TREK major
});
it('holds nothing without a full consent or a version to compare', () => {
process.env.APP_VERSION = '5.0.0';
expect(acceptanceHolds('1.0.0', null)).toBe(false);
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: null })).toBe(false);
expect(acceptanceHolds(null, { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: 'not-a-version' })).toBe(false);
process.env.APP_VERSION = 'dev';
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
});
});
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', acceptedByAdmin: false });
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', acceptedByAdmin: false });
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false });
});
it("an admin's consent for one plugin lets it through without the switch, and says it was theirs", () => {
process.env.APP_VERSION = '5.0.0';
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toEqual({
trekRange: '>=3.2.0 <4.0.0',
hostVersion: '5.0.0',
acceptedByAdmin: true,
});
expect(bypassedRange('>=5.0.0', true)).toBeNull(); // fits: nothing was set aside
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toMatchObject({ acceptedByAdmin: false });
});
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);
});
});