Files
TREK/server/tests/unit/plugins/protocol-paths.test.ts
T
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00

104 lines
4.7 KiB
TypeScript

/**
* Small pure helpers of the plugin module (#plugins): permission recognition and
* the code/data path resolution (both the env-override and default branches).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS, HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope';
import path from 'node:path';
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs, pluginRealCodeDir, ensurePluginModuleType } from '../../../src/nest/plugins/paths';
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
});
describe('envelope helpers', () => {
it('recognises known permissions, http:outbound:<host>, and rejects unknown', () => {
expect(isKnownPermission('db:own')).toBe(true);
expect(isKnownPermission('http:outbound')).toBe(true);
expect(isKnownPermission('http:outbound:api.example.com')).toBe(true);
expect(isKnownPermission('fs:read')).toBe(false);
expect(isKnownPermission('')).toBe(false);
});
// The compile side of this is `as const satisfies Record<KnownMethod, KnownPermission>`
// on METHOD_PERMISSION. These are the runtime mirror, and they also cover what the type
// system cannot: isKnownPermission's http:outbound: prefix branch.
it('every known method maps to a permission the host actually knows', () => {
for (const m of KNOWN_METHODS) {
const perm = METHOD_PERMISSION[m];
expect(isKnownPermission(perm), `${m} -> ${perm}`).toBe(true);
}
});
it('every hook permission is a known permission', () => {
for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) {
expect(isKnownPermission(perm), `${hook} -> ${perm}`).toBe(true);
}
});
});
describe('paths', () => {
it('uses the env override when set', () => {
process.env.TREK_PLUGINS_DIR = '/custom/code';
process.env.TREK_PLUGINS_DATA_DIR = '/custom/data';
expect(pluginsCodeRoot()).toBe('/custom/code');
expect(pluginsDataRoot()).toBe('/custom/data');
expect(pluginCodeDir('x')).toBe(path.join('/custom/code', 'x'));
expect(path.basename(pluginDbFile('x'))).toBe('plugin.db');
});
it('falls back to the data-dir default when unset', () => {
expect(pluginsCodeRoot()).toContain('plugins');
expect(pluginsDataRoot()).toContain('plugins-data');
});
it('resolves a child entry with fork args', () => {
const r = resolveChildEntry();
expect(r.entry).toMatch(/plugin-host-entry\.(js|ts)$/);
expect(Array.isArray(r.execArgv)).toBe(true);
expect(typeof r.jsMode).toBe('boolean');
});
it('builds scoped OS-permission flags for a plugin child (default on)', () => {
delete process.env.TREK_PLUGIN_PERMISSIONS;
const args = pluginPermissionArgs('flight-tracker');
expect(args).toContain('--permission');
// read is scoped to the compiled server dir + this plugin's own code dir…
expect(args.some((a) => a === `--allow-fs-read=${serverCodeRoot()}`)).toBe(true);
expect(args.some((a) => a === `--allow-fs-read=${pluginCodeDir('flight-tracker')}`)).toBe(true);
// …and never grants fs-write / child_process / the data root.
expect(args.some((a) => a.startsWith('--allow-fs-write'))).toBe(false);
expect(args.some((a) => a.startsWith('--allow-child-process'))).toBe(false);
expect(serverCodeRoot()).not.toContain(`${require('node:path').sep}data`);
});
it('lets an operator opt out of the permission model', () => {
process.env.TREK_PLUGIN_PERMISSIONS = 'off';
expect(pluginPermissionArgs('flight-tracker')).toEqual([]);
delete process.env.TREK_PLUGIN_PERMISSIONS;
});
it('pluginRealCodeDir falls back to the lexical path when the dir is absent', () => {
// no such plugin installed -> realpathSync throws -> lexical path returned
expect(pluginRealCodeDir('does-not-exist')).toBe(pluginCodeDir('does-not-exist'));
});
it('ensurePluginModuleType writes a commonjs package.json only when absent', () => {
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'plug-mod-'));
try {
ensurePluginModuleType(dir);
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))).toEqual({ type: 'commonjs' });
// an author-provided package.json is left untouched
fs.writeFileSync(path.join(dir, 'package.json'), '{"type":"module"}');
ensurePluginModuleType(dir);
expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))).toEqual({ type: 'module' });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});