mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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: [] });
|
||||
|
||||
Reference in New Issue
Block a user