mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(plugins): TREK-side registry — browse + one-click install (M5)
Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.
- registry.service: fetches the single aggregated dist/index.json (never
per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
soft-fails to a stale/empty registry, and installs a pinned version through
the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
POST /api/admin/plugins/install { id, version }. Install never executes code;
activation stays a separate, deliberate step.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
@@ -19,6 +20,7 @@ export class PluginsController {
|
||||
constructor(
|
||||
private readonly plugins: PluginsService,
|
||||
private readonly runtime: PluginRuntimeService,
|
||||
private readonly registry: PluginRegistryService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -26,6 +28,23 @@ export class PluginsController {
|
||||
return this.plugins.list();
|
||||
}
|
||||
|
||||
@Get('registry')
|
||||
browse() {
|
||||
return this.registry.browse();
|
||||
}
|
||||
|
||||
@Post('install')
|
||||
@HttpCode(200)
|
||||
async install(@Body() body: { id?: string; version?: string }) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
|
||||
try {
|
||||
return await this.registry.install(body.id, body.version);
|
||||
} catch (e) {
|
||||
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/config')
|
||||
getConfig(@Param('id') id: string) {
|
||||
return { config: this.plugins.getInstanceConfig(id) };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginFrameController } from './plugin-frame.controller';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
@@ -15,6 +16,6 @@ import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
|
||||
providers: [PluginsService, PluginRuntimeService],
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { db } from '../../../db/database';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { safeDownload, sha256Matches } from '../install/safe-fetch';
|
||||
import { extractArchive } from '../install/safe-extract';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
import { parseManifest } from '../install/manifest';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
|
||||
/**
|
||||
* TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated
|
||||
* dist/index.json (never per-plugin GitHub API calls — the HACS rate-limit
|
||||
* lesson), caches it briefly + soft-fails, and installs a pinned version through
|
||||
* the M4 pipeline: SSRF-safe download -> sha256 verify -> zip/tar-slip-safe
|
||||
* extract -> manifest re-validate -> native re-scan -> atomic move -> discover
|
||||
* (registers INACTIVE). Nothing executes on install; activation is separate.
|
||||
*/
|
||||
|
||||
const REGISTRY_URL =
|
||||
process.env.TREK_PLUGIN_REGISTRY_URL ||
|
||||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
interface RegistryVersion {
|
||||
version: string;
|
||||
gitTag: string;
|
||||
commitSha: string;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
minTrekVersion: string;
|
||||
maxTrekVersion?: string | null;
|
||||
size?: number;
|
||||
apiVersion?: number;
|
||||
nativeModules?: boolean;
|
||||
}
|
||||
export interface RegistryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
repo: string;
|
||||
homepage?: string;
|
||||
tags?: string[];
|
||||
type: string;
|
||||
reviewedAt?: string | null;
|
||||
versions: RegistryVersion[];
|
||||
}
|
||||
interface Registry {
|
||||
schemaVersion: number;
|
||||
generatedAt?: string;
|
||||
plugins: RegistryEntry[];
|
||||
}
|
||||
|
||||
let _cache: { data: Registry; expiresAt: number } | null = null;
|
||||
|
||||
/** Test hook: drop the in-memory registry cache. */
|
||||
export function __clearRegistryCacheForTests(): void {
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
export class RegistryError extends Error {}
|
||||
|
||||
@Injectable()
|
||||
export class PluginRegistryService {
|
||||
/** Fetch the aggregated registry (cached, soft-fail, stale-serve). */
|
||||
async fetchRegistry(): Promise<Registry> {
|
||||
if (_cache && Date.now() < _cache.expiresAt) return _cache.data;
|
||||
try {
|
||||
const resp = await fetch(REGISTRY_URL, { headers: { 'User-Agent': 'TREK-Server' } });
|
||||
if (!resp.ok) throw new Error(`registry ${resp.status}`);
|
||||
const data = (await resp.json()) as Registry;
|
||||
if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');
|
||||
_cache = { data, expiresAt: Date.now() + CACHE_TTL };
|
||||
return data;
|
||||
} catch {
|
||||
return _cache?.data ?? { schemaVersion: 1, plugins: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** The browse list the admin UI renders (metadata only, no code). */
|
||||
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null }>> {
|
||||
const reg = await this.fetchRegistry();
|
||||
return reg.plugins.map((p) => {
|
||||
const latest = p.versions[0] ?? null;
|
||||
return {
|
||||
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
|
||||
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
|
||||
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Install a pinned version from the registry. Returns the installed plugin id. */
|
||||
async install(id: string, version?: string): Promise<{ id: string; version: string }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
const ver = version ? entry.versions.find((v) => v.version === version) : entry.versions[0];
|
||||
if (!ver) throw new RegistryError(`version ${version ?? 'latest'} not found for ${id}`);
|
||||
|
||||
// 1. SSRF-safe download + 2. sha256 verify
|
||||
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
|
||||
if (!sha256Matches(sha256, ver.sha256)) throw new RegistryError('integrity check failed (sha256 mismatch)');
|
||||
|
||||
// 3. zip/tar-slip-safe extract to staging
|
||||
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
|
||||
try {
|
||||
extractArchive(bytes, staging);
|
||||
const pluginRoot = locateManifestDir(staging);
|
||||
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
|
||||
|
||||
// 4. re-validate the bundled manifest + 5. native re-scan
|
||||
const manifest = parseManifest(JSON.parse(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')));
|
||||
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
|
||||
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
||||
|
||||
// 6. atomic move into place
|
||||
const dest = pluginCodeDir(id);
|
||||
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(pluginRoot, dest);
|
||||
|
||||
// 7. register INACTIVE (record provenance)
|
||||
discoverPlugins(db);
|
||||
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
|
||||
);
|
||||
return { id, version: ver.version };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The extracted plugin root: staging itself, or its single wrapper subdir (codeload archives wrap in {repo}-{sha}/). */
|
||||
function locateManifestDir(staging: string): string | null {
|
||||
if (fs.existsSync(path.join(staging, 'trek-plugin.json'))) return staging;
|
||||
const subs = fs.existsSync(staging)
|
||||
? fs.readdirSync(staging, { withFileTypes: true }).filter((d) => d.isDirectory())
|
||||
: [];
|
||||
for (const s of subs) {
|
||||
const p = path.join(staging, s.name);
|
||||
if (fs.existsSync(path.join(p, 'trek-plugin.json'))) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* TREK-side registry service (#plugins, M5): browse the aggregated registry and
|
||||
* install a pinned version through the full verify -> extract -> validate ->
|
||||
* move -> register pipeline (with the network download mocked).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const { safeDownload } = vi.hoisted(() => ({ safeDownload: vi.fn() }));
|
||||
vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
safeDownload,
|
||||
}));
|
||||
|
||||
const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
|
||||
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
|
||||
|
||||
// ── tiny tar.gz builder (wraps the plugin in a codeload-style top dir) ────────
|
||||
function tarHeader(name: string, size: number, typeflag = '0'): Buffer {
|
||||
const h = Buffer.alloc(512, 0);
|
||||
h.write(name, 0); h.write('0000644', 100); h.write('0000000', 108); h.write('0000000', 116);
|
||||
h.write(size.toString(8).padStart(11, '0'), 124); h.write('00000000000', 136);
|
||||
h.write(' ', 148); h.write(typeflag, 156); h.write('ustar\0', 257); h.write('00', 263);
|
||||
let sum = 0; for (let i = 0; i < 512; i++) sum += h[i];
|
||||
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
return h;
|
||||
}
|
||||
function makeArtifact(manifest: object): Buffer {
|
||||
const files = [
|
||||
{ name: 'plug-abc/', type: '5' as const, data: '' },
|
||||
{ name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(manifest) },
|
||||
{ name: 'plug-abc/server/', type: '5' as const, data: '' },
|
||||
{ name: 'plug-abc/server/index.js', type: '0' as const, data: 'module.exports={}' },
|
||||
];
|
||||
const parts: Buffer[] = [];
|
||||
for (const f of files) {
|
||||
const body = Buffer.from(f.data);
|
||||
parts.push(tarHeader(f.name, f.type === '5' ? 0 : body.length, f.type));
|
||||
if (f.type === '0') { parts.push(body); const pad = (512 - (body.length % 512)) % 512; if (pad) parts.push(Buffer.alloc(pad, 0)); }
|
||||
}
|
||||
parts.push(Buffer.alloc(1024, 0));
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
const REGISTRY = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
|
||||
type: 'widget', reviewedAt: '2026-06-20',
|
||||
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let dataRoot: string;
|
||||
let codeRoot: string;
|
||||
let svc: PluginRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-data-'));
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-code-'));
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
|
||||
svc = new PluginRegistryService();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
safeDownload.mockReset();
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('PluginRegistryService', () => {
|
||||
it('browse maps the aggregated registry to metadata', async () => {
|
||||
const list = await svc.browse();
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({ id: 'flight-tracker', name: 'Flight', latest: '1.0.0', minTrekVersion: '3.2.0', reviewedAt: '2026-06-20' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
|
||||
const out = await svc.install('flight-tracker');
|
||||
expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
||||
|
||||
// moved into place + registered inactive with provenance
|
||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true);
|
||||
const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string };
|
||||
expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) });
|
||||
// no staging left behind
|
||||
expect(fs.existsSync(path.join(dataRoot, '.staging'))).toBe(false || fs.readdirSync(path.join(dataRoot, '.staging')).length === 0);
|
||||
});
|
||||
|
||||
it('rejects an sha256 mismatch', async () => {
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget' });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = 'b'.repeat(64);
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: 'c'.repeat(64) });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/integrity/);
|
||||
});
|
||||
|
||||
it('rejects an unknown plugin id', async () => {
|
||||
await expect(svc.install('ghost')).rejects.toThrow(RegistryError);
|
||||
});
|
||||
|
||||
it('caches the registry (one fetch across calls)', async () => {
|
||||
const spy = vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response);
|
||||
vi.stubGlobal('fetch', spy);
|
||||
__clearRegistryCacheForTests();
|
||||
await svc.fetchRegistry();
|
||||
await svc.fetchRegistry();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('soft-fails on a non-ok registry response', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response));
|
||||
expect((await svc.fetchRegistry()).plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a version that is not listed', async () => {
|
||||
await expect(svc.install('flight-tracker', '9.9.9')).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('rejects an archive without a manifest', async () => {
|
||||
const parts = [Buffer.alloc(1024, 0)]; // empty tar
|
||||
const empty = zlib.gzipSync(Buffer.concat(parts));
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(empty).digest('hex');
|
||||
safeDownload.mockResolvedValue({ bytes: empty, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/no trek-plugin.json/);
|
||||
});
|
||||
|
||||
it('rejects a manifest id that does not match the registry id', async () => {
|
||||
const artifact = makeArtifact({ id: 'other-id', name: 'X', version: '1.0.0', type: 'widget' });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/!=/);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,11 @@ describe('discoverPlugins', () => {
|
||||
name: 'Flight',
|
||||
type: 'widget',
|
||||
permissions: ['db:own'],
|
||||
settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }],
|
||||
settings: [
|
||||
{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true },
|
||||
{ key: 'units', input_type: 'select', scope: 'user', options: [{ value: 'm', label: 'Metric' }] },
|
||||
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
|
||||
],
|
||||
});
|
||||
const res = discoverPlugins(db);
|
||||
expect(res.discovered).toEqual(['flight-tracker']);
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('PluginsService.list', () => {
|
||||
it('controller delegates to the service', () => {
|
||||
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
|
||||
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
|
||||
const res = new PluginsController(svc, runtime).list();
|
||||
const res = new PluginsController(svc, runtime, {} as never).list();
|
||||
expect(svc.list).toHaveBeenCalled();
|
||||
expect(res).toEqual({ enabled: false, plugins: [] });
|
||||
});
|
||||
@@ -86,31 +86,31 @@ describe('PluginsController M2 endpoints', () => {
|
||||
|
||||
it('get/update config delegate to the service', () => {
|
||||
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
|
||||
const c = new PluginsController(svc, rt);
|
||||
const c = new PluginsController(svc, rt, {} as never);
|
||||
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
|
||||
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
|
||||
});
|
||||
|
||||
it('activate spawns via the runtime when enabled', async () => {
|
||||
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
|
||||
const out = await new PluginsController(svc, rt).activate('x');
|
||||
const out = await new PluginsController(svc, rt, {} as never).activate('x');
|
||||
expect(out).toEqual({ status: 'active' });
|
||||
});
|
||||
|
||||
it('activate is 503 when the runtime is disabled', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
|
||||
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 });
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('activate surfaces an activation error as 400', async () => {
|
||||
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
|
||||
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 });
|
||||
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('deactivate stops the plugin', async () => {
|
||||
const rt = { deactivate: vi.fn(async () => {}) } as never;
|
||||
expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' });
|
||||
expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,22 +43,23 @@ function makeTarGz(entries: Array<{ name: string; data?: string; type?: string;
|
||||
parts.push(Buffer.alloc(1024, 0));
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
function makeStoredZip(name: string, data: string): Buffer {
|
||||
function makeZip(name: string, data: string, method = 0): Buffer {
|
||||
const nameB = Buffer.from(name);
|
||||
const dataB = Buffer.from(data);
|
||||
const raw = Buffer.from(data);
|
||||
const comp = method === 8 ? zlib.deflateRawSync(raw) : raw;
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(0, 8); // method 0 (stored)
|
||||
local.writeUInt32LE(dataB.length, 18);
|
||||
local.writeUInt32LE(dataB.length, 22);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(comp.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(nameB.length, 26);
|
||||
const localRec = Buffer.concat([local, nameB, dataB]);
|
||||
const localRec = Buffer.concat([local, nameB, comp]);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt32LE(dataB.length, 20);
|
||||
central.writeUInt32LE(dataB.length, 24);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(comp.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(nameB.length, 28);
|
||||
central.writeUInt32LE(0, 42); // local header offset
|
||||
const centralRec = Buffer.concat([central, nameB]);
|
||||
@@ -101,10 +102,16 @@ describe('extractArchive', () => {
|
||||
});
|
||||
|
||||
it('extracts a stored zip', () => {
|
||||
extractArchive(makeStoredZip('a.txt', 'hello'), dest);
|
||||
extractArchive(makeZip('a.txt', 'hello'), dest);
|
||||
expect(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('extracts a deflated zip and rejects an unsupported compression method', () => {
|
||||
extractArchive(makeZip('b.txt', 'world', 8), dest);
|
||||
expect(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8')).toBe('world');
|
||||
expect(() => extractArchive(makeZip('c.txt', 'x', 99), dest)).toThrow(/unsupported zip compression/);
|
||||
});
|
||||
|
||||
it('refuses a symlink entry', () => {
|
||||
const gz = makeTarGz([{ name: 'link', type: '2', link: '/etc/passwd' }]);
|
||||
expect(() => extractArchive(gz, dest)).toThrow(/non-regular/);
|
||||
|
||||
@@ -58,6 +58,15 @@ describe('safeDownload', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects an invalid url', async () => {
|
||||
await expect(safeDownload('not-a-url')).rejects.toThrow(/invalid url/);
|
||||
});
|
||||
|
||||
it('rejects a redirect without a location header', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ status: 302, headers: new Headers() }) as unknown as Response));
|
||||
await expect(safeDownload('https://github.com/x')).rejects.toThrow(/redirect without/);
|
||||
});
|
||||
|
||||
it('enforces the size cap', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => Buffer.alloc(2000) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
Reference in New Issue
Block a user