feat(plugins): install an older version of a plugin (#2035)

Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
This commit is contained in:
jubnl
2026-08-25 17:30:27 +02:00
committed by GitHub
parent b5c8182410
commit 4ba407aead
37 changed files with 1383 additions and 42 deletions
@@ -49,11 +49,14 @@ let runtime: PluginRuntimeService;
let registry: PluginRegistryService;
let controller: PluginsController;
let plugins: PluginsService;
beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn() } as unknown as PluginRegistryService;
controller = new PluginsController({} as PluginsService, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService;
plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService;
controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
});
describe('signature refusal codes survive to the client', () => {
@@ -98,6 +101,87 @@ describe('signature refusal codes survive to the client', () => {
});
});
// The rollback path: an admin picks a specific (possibly older) version, and the runtime
// must install exactly that one — the compat gate stays server-side in selectVersion.
describe('POST :id/update — explicit version', () => {
it('forwards the requested version to the runtime', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
const res = await controller.update('flight-tracker', { version: '1.2.0' });
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]);
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
});
it('an omitted body keeps the default newest-compatible resolution', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: undefined }]);
});
});
// The update hold: only a DELIBERATE version pick may set it, every successful
// install/update recomputes it (so landing back on the newest clears a stale one),
// and dependency resolution — which pins versions too, but never deliberately —
// must not touch it.
describe('update hold wiring', () => {
it('an explicit-version update recomputes the hold as deliberate', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker', { version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a default update recomputes as non-deliberate (clears a stale hold)', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '2.0.0', false);
});
it('an explicit-version install recomputes the hold as deliberate', async () => {
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' });
await controller.install({ id: 'flight-tracker', version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a dependency-resolution install does NOT touch the hold', async () => {
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] });
await controller.install({ id: 'flight-tracker', withDependencies: true });
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('a failed update leaves the hold untouched', async () => {
vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable'));
await expect(controller.update('flight-tracker', { version: '1.2.0' })).rejects.toThrow(HttpException);
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('POST :id/resume-updates clears the hold', async () => {
const res = await controller.resumeUpdates('flight-tracker');
expect(plugins.resumeUpdates).toHaveBeenCalledWith('flight-tracker');
expect(res).toEqual({ updateHold: false });
});
it('POST :id/resume-updates is a 404 for an unknown plugin', async () => {
vi.mocked(plugins.resumeUpdates).mockReturnValue(false);
const { status } = await wireFailure(async () => controller.resumeUpdates('ghost'));
expect(status).toBe(404);
});
});
describe('POST :id/retrust', () => {
it('re-pins AND installs in one call, returning the update result', async () => {
vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
@@ -11,7 +11,7 @@ const { testDb } = vi.hoisted(() => {
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range TEXT, sort_order INTEGER DEFAULT 0);
trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
return { testDb: db };
@@ -48,6 +48,30 @@ describe('PluginsService.list', () => {
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
});
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
.run();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
expect(svc.resumeUpdates('ghost')).toBe(false);
});
it('reports enabled by default (no kill switch set)', () => {
testDb
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')