mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +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:
@@ -4123,6 +4123,19 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// A deliberate non-latest plugin install sets `update_hold`: the row leaves the
|
||||
// update banner and "Update all" until the admin resumes updates, or until an
|
||||
// install lands back on the newest compatible version. Only an EXPLICIT version
|
||||
// pick ever sets it — dependency resolution pins versions too, but never
|
||||
// deliberately. Appended LAST — the array is index-addressed against schema_version.
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;');
|
||||
} catch (err) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto';
|
||||
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
|
||||
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
|
||||
@@ -81,8 +81,12 @@ export class PluginsController {
|
||||
// withDependencies (used by the "resolve missing dependency" admin flow) pulls
|
||||
// the target + its transitive plugin deps, resolving each to its latest
|
||||
// compatible version and reporting addons the admin still has to enable.
|
||||
// Dependency resolution pins versions internally but never DELIBERATELY, so
|
||||
// only the plain path below recomputes the update hold.
|
||||
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
|
||||
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
|
||||
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
|
||||
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
|
||||
return res;
|
||||
} catch (e) {
|
||||
throw registryFailure(e, 'install failed');
|
||||
}
|
||||
@@ -221,15 +225,31 @@ export class PluginsController {
|
||||
|
||||
@Post(':id/update')
|
||||
@HttpCode(200)
|
||||
async update(@Param('id') id: string) {
|
||||
async update(@Param('id') id: string, @Body() body?: PluginUpdateDto) {
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
try {
|
||||
return await this.runtime.update(id);
|
||||
// An explicit version is the rollback path: install exactly what the admin picked
|
||||
// (the TREK-compat gate still refuses in selectVersion). Absent, the runtime
|
||||
// resolves the newest compatible version itself.
|
||||
const res = await this.runtime.update(id, { version: body?.version });
|
||||
// A deliberate non-latest pick holds future updates; landing on the newest
|
||||
// (any path) releases a stale hold. Only after success — a failed update
|
||||
// changed nothing and must not touch the flag.
|
||||
await this.registry.recomputeUpdateHold(id, res.version, !!body?.version);
|
||||
return res;
|
||||
} catch (e) {
|
||||
throw registryFailure(e, 'update failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
|
||||
@Post(':id/resume-updates')
|
||||
@HttpCode(200)
|
||||
resumeUpdates(@Param('id') id: string) {
|
||||
if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404);
|
||||
return { updateHold: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
|
||||
*
|
||||
|
||||
@@ -41,6 +41,14 @@ export class PluginRetrustDto extends createZodDto(
|
||||
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `version` pins the exact version to install — the rollback path. Omitted, the
|
||||
* runtime resolves the newest TREK-compatible version itself (the classic update).
|
||||
*/
|
||||
export class PluginUpdateDto extends createZodDto(
|
||||
z.looseObject({ version: z.string().optional() }),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `hosts` is deliberately unknown, not `string[]`.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,7 @@ interface PluginRawRow {
|
||||
update_block_code: string | null;
|
||||
update_block_detail: string | null;
|
||||
update_block_version: string | null;
|
||||
update_hold: number;
|
||||
}
|
||||
|
||||
export interface PluginListItem {
|
||||
@@ -87,6 +88,9 @@ export interface PluginListItem {
|
||||
/** Why an update was refused, if one was. `version` is the registry version that was
|
||||
* refused, so a caller can treat the block as stale once a newer one is on offer. */
|
||||
updateBlock: { code: string; detail: string | null; version: string | null } | null;
|
||||
/** A deliberate non-latest install paused updates: excluded from the banner/Update all
|
||||
* until the admin resumes, or an install lands back on the newest compatible version. */
|
||||
updateHold: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -114,7 +118,7 @@ export class PluginsService {
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
permissions, capabilities, dependencies, operator_egress, trek_range,
|
||||
author_pubkey, update_block_code, update_block_detail, update_block_version
|
||||
author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold
|
||||
FROM plugins
|
||||
ORDER BY sort_order, name`,
|
||||
)
|
||||
@@ -143,6 +147,7 @@ export class PluginsService {
|
||||
update_block_code,
|
||||
update_block_detail,
|
||||
update_block_version,
|
||||
update_hold,
|
||||
...rest
|
||||
} = r as PluginRawRow & { operator_egress?: number };
|
||||
return {
|
||||
@@ -159,11 +164,20 @@ export class PluginsService {
|
||||
updateBlock: update_block_code
|
||||
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
|
||||
: null,
|
||||
updateHold: update_hold === 1,
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a per-plugin update hold (set by a deliberate non-latest install).
|
||||
* Returns whether the plugin row existed — the controller answers 404 otherwise.
|
||||
*/
|
||||
resumeUpdates(id: string): boolean {
|
||||
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge instance-scope settings into the plugin's config, encrypting fields
|
||||
* declared secret (unless the value is the unchanged mask sentinel). Only keys
|
||||
|
||||
@@ -304,6 +304,17 @@ export class PluginRegistryService {
|
||||
screenshotUrl: entry.screenshotUrl ?? (latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null),
|
||||
signed: !!entry.authorPublicKey && !!latest?.signature,
|
||||
authorPublicKey: entry.authorPublicKey ?? null,
|
||||
// The version picker's data: every published version with its OWN server-computed
|
||||
// compat verdict. The client has no semver and must never re-derive range logic —
|
||||
// a picker that disagreed with the install gate would offer a version that 400s.
|
||||
versions: entry.versions.map((v) => ({
|
||||
version: v.version,
|
||||
publishedAt: v.publishedAt ?? null,
|
||||
size: v.size ?? null,
|
||||
signed: !!entry.authorPublicKey && !!v.signature,
|
||||
trek: trekRequirementOrNull(v),
|
||||
compatible: hostCompatible(v, normalizedHost()),
|
||||
})),
|
||||
...this.hostCompat(entry),
|
||||
manifest,
|
||||
};
|
||||
@@ -508,6 +519,30 @@ export class PluginRegistryService {
|
||||
return { installed, requiredAddons: [...requiredAddons] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the per-plugin update hold after a successful install/update.
|
||||
*
|
||||
* The hold exists so a DELIBERATE rollback isn't immediately nagged away by the
|
||||
* update banner. It is set only when `explicit` (the admin picked this exact
|
||||
* version) AND a newer TREK-compatible version exists; every other outcome writes
|
||||
* 0, so landing back on the newest compatible version — by any path — releases a
|
||||
* stale hold. An unresolvable registry never sets a hold: refusing to pin on
|
||||
* missing information beats silently muting future updates.
|
||||
*/
|
||||
async recomputeUpdateHold(id: string, installedVersion: string, explicit: boolean): Promise<boolean> {
|
||||
let hold = false;
|
||||
if (explicit) {
|
||||
try {
|
||||
const newest = await this.resolveVersion(id);
|
||||
hold = semver.lt(installedVersion, newest.version);
|
||||
} catch {
|
||||
hold = false;
|
||||
}
|
||||
}
|
||||
this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id);
|
||||
return hold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sideload step 1: extract + validate an uploaded archive into a staging dir,
|
||||
* WITHOUT touching the live plugin. Returns the manifest id/version + the staged
|
||||
@@ -710,17 +745,23 @@ export function assertHostCompatible(range: string | null, id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
|
||||
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
|
||||
* this composes whatever bounds exist rather than interpolating a missing one as "null".
|
||||
* A version's declared TREK requirement as a range string, or null when the entry
|
||||
* carries no bounds at all. Each bound is optional — an entry may declare only a
|
||||
* ceiling, or (once `trek` is absent too) nothing — so this composes whatever
|
||||
* bounds exist rather than interpolating a missing one as "null".
|
||||
*/
|
||||
function trekRequirement(v: RegistryVersion): string {
|
||||
function trekRequirementOrNull(v: RegistryVersion): string | null {
|
||||
if (v.trek) return v.trek;
|
||||
const bounds = [
|
||||
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
|
||||
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
|
||||
].filter(Boolean);
|
||||
return bounds.length ? bounds.join(' ') : 'any version';
|
||||
return bounds.length ? bounds.join(' ') : null;
|
||||
}
|
||||
|
||||
/** How a version's TREK requirement reads in an error/UI string. */
|
||||
function trekRequirement(v: RegistryVersion): string {
|
||||
return trekRequirementOrNull(v) ?? 'any version';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0);
|
||||
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 };
|
||||
@@ -257,6 +257,123 @@ describe('PluginRegistryService', () => {
|
||||
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
|
||||
});
|
||||
|
||||
// The per-plugin update hold: a DELIBERATE non-latest install excludes the plugin from
|
||||
// the update banner until the admin resumes updates or lands back on the newest
|
||||
// compatible version. Dependency resolution pins versions too, but never deliberately —
|
||||
// the `explicit` flag is what separates the two.
|
||||
describe('recomputeUpdateHold', () => {
|
||||
const twoVersions = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', type: 'widget',
|
||||
versions: [
|
||||
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64) },
|
||||
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seedRow = (hold = 0) =>
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold);
|
||||
const holdInDb = () =>
|
||||
(testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
});
|
||||
|
||||
it('holds when the admin explicitly picked an older version', async () => {
|
||||
seedRow();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true);
|
||||
expect(holdInDb()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not hold when the explicit pick IS the newest compatible version', async () => {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
// The comparison is against the newest version THIS TREK can run, not the newest
|
||||
// published one. Both fixtures above leave `trek` unset, so every version is
|
||||
// compatible and the two readings agree; only a version this host must skip tells
|
||||
// them apart. Getting it wrong stamps a hold on an admin who picked the best
|
||||
// available version, and the row silently leaves the banner and "Update all".
|
||||
it('does not hold when a newer version exists but this TREK cannot run it', async () => {
|
||||
const withIncompatibleNewest = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
...twoVersions.plugins[0],
|
||||
versions: [
|
||||
{ version: '3.0.0', gitTag: 'v3.0.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/3', sha256: '3'.repeat(64), trek: '>=4.0.0' },
|
||||
...twoVersions.plugins[0].versions,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
try {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-deliberate update clears a stale hold', async () => {
|
||||
seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
it('an unresolvable registry never sets a hold', async () => {
|
||||
seedRow();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
__clearRegistryCacheForTests();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The version picker's data: every published version, each with its OWN server-computed
|
||||
// compat verdict — the client has no semver and must never re-derive range logic.
|
||||
it('detail lists every published version with its own compat verdict', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
const multi = {
|
||||
schemaVersion: 1,
|
||||
plugins: [
|
||||
{
|
||||
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
|
||||
type: 'widget', authorPublicKey: 'K'.repeat(44),
|
||||
versions: [
|
||||
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64), trek: '>=4.0.0', publishedAt: '2026-08-01', size: 2048, signature: 'sig2' },
|
||||
{ version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/15', sha256: '1'.repeat(64), trek: '>=3.0.0 <4.0.0', publishedAt: '2026-07-01', size: 1024 },
|
||||
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64), minTrekVersion: '3.0.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => multi }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
try {
|
||||
const d = (await svc.detail('flight-tracker')) as { versions: unknown };
|
||||
expect(d.versions).toEqual([
|
||||
{ version: '2.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
||||
{ version: '1.5.0', publishedAt: '2026-07-01', size: 1024, signed: false, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
// A legacy entry composes its requirement from the min/max bounds it has.
|
||||
{ version: '1.0.0', publishedAt: null, size: null, signed: false, trek: '>=3.0.0', compatible: true },
|
||||
]);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
|
||||
@@ -63,8 +63,11 @@ describe('budget_items ticket_json migration', () => {
|
||||
describe('recovering what the case-insensitive match destroyed', () => {
|
||||
/**
|
||||
* A database that already ran the buggy step: migrated to the tip, then wound
|
||||
* back one version so only the recovery step is left to run — which is exactly
|
||||
* what an existing install does on the next boot.
|
||||
* back to just before the recovery step so it runs again — which is exactly
|
||||
* what an existing install does on the next boot. The wind-back target is the
|
||||
* recovery step's ABSOLUTE version (the array is append-only and
|
||||
* index-addressed, so it never moves); anything appended after it replays too,
|
||||
* which every later migration must tolerate anyway.
|
||||
*/
|
||||
function makeDamagedDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
@@ -85,8 +88,8 @@ describe('recovering what the case-insensitive match destroyed', () => {
|
||||
db.prepare('INSERT INTO budget_items (id, trip_id, name, note, ticket_json) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(3, 1, 'Taxi', 'split at the hotel', '{"items":[]}');
|
||||
|
||||
const version = (db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number }).version;
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||
// 196 = the version just before the recovery step (migration #197).
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(196);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (?,?,?,?,?,?)')
|
||||
|
||||
Reference in New Issue
Block a user