Files
Termix/src/backend/tests/database/routes/plugins.test.ts
T

1430 lines
45 KiB
TypeScript

/**
* The plugin control plane: listing installed plugins (with their declared
* and granted capabilities) and granting/revoking a capability. Enable/disable
* is not re-tested here beyond what already existed; the grant/revoke routes
* are the new surface this file covers.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginManageError } from "../../../plugins/manage.js";
import { RegistryError } from "../../../plugins/registries.js";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
interface PluginRow {
id: string;
name: string;
version: string;
tier: string;
source: string;
state: string;
lastError?: string | null;
manifestJson: string;
}
const state = vi.hoisted(() => ({
plugins: new Map<string, PluginRow>(),
grants: [] as {
pluginId: string;
capability: string;
grantedBy: string | null;
source?: string;
}[],
// Which users have admin.plugins.manage, keyed by userId.
managers: new Set<string>(["admin-1"]),
// Plugins the loader reports as running.
active: new Set<string>(),
}));
vi.mock("../../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
vi.mock("../../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
/**
* The caller identifies as whichever user the x-test-user-id header names,
* mirroring the real JWT middleware's job of setting req.userId -- these
* tests only need to vary which user is calling, not verify real tokens.
*/
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown> & { headers: Record<string, string> },
_res: unknown,
next: () => void,
) => {
req.userId = req.headers["x-test-user-id"] ?? "admin-1";
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
// The list route asks directly rather than gating the whole mount, so
// it can return a narrower shape to a non-admin instead of a 403.
hasPermission: async (userId: string) => state.managers.has(userId),
requirePermission:
(_permission: string) =>
(
req: Record<string, unknown>,
res: {
status: (code: number) => { json: (body: unknown) => void };
},
next: () => void,
) => {
const userId = req.userId as string;
if (!state.managers.has(userId)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}
next();
},
}),
},
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
list: () => [],
get: (id: string) =>
state.active.has(id)
? {
id,
state: "active",
dir: "/nonexistent/plugin",
manifest: { id, locales: "locales" },
}
: undefined,
},
}),
activatePlugin: vi.fn(),
deactivatePlugin: vi.fn(),
}));
vi.mock("../../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
listAll: async () => [...state.plugins.values()],
findById: async (id: string) => state.plugins.get(id) ?? null,
update: async (id: string, changes: Partial<PluginRow>) => {
const existing = state.plugins.get(id);
if (existing) state.plugins.set(id, { ...existing, ...changes });
},
}),
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants
.filter((g) => g.pluginId === pluginId)
.map((g) => ({ capability: g.capability })),
findGrant: async (pluginId: string, capability: string) =>
state.grants.find(
(g) => g.pluginId === pluginId && g.capability === capability,
) ?? null,
grant: async (input: {
pluginId: string;
capability: string;
grantedBy: string | null;
source?: string;
}) => {
state.grants.push(input);
return input;
},
revoke: async (pluginId: string, capability: string) => {
const before = state.grants.length;
state.grants = state.grants.filter(
(g) => !(g.pluginId === pluginId && g.capability === capability),
);
return state.grants.length < before;
},
}),
}));
const manageMock = vi.hoisted(() => ({
listRegistry: vi.fn(),
installPlugin: vi.fn(),
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
getPluginChangelog: vi.fn(),
deletePluginData: vi.fn(),
planStateChange: vi.fn(),
setPluginState: vi.fn(),
enablePluginWithConsent: vi.fn(),
getDeveloperMode: vi.fn(),
setDeveloperMode: vi.fn(),
stageUpload: vi.fn(),
installUpload: vi.fn(),
isLinkedDesktop: vi.fn(async () => false),
}));
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
vi.mock("../../../plugins/manage.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/manage.js")>()),
...manageMock,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: auditMock.logAudit,
getAuditUsername: async () => "admin",
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const registriesMock = vi.hoisted(() => ({
readRegistriesConfig: vi.fn(),
previewRegistry: vi.fn(),
addRegistry: vi.fn(),
setRegistryEnabled: vi.fn(),
removeRegistry: vi.fn(),
}));
vi.mock("../../../plugins/registries.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/registries.js")>()),
...registriesMock,
}));
const onboardingMock = vi.hoisted(() => ({
describeOnboardingPlugins: vi.fn(),
applyOnboardingChoices: vi.fn(),
}));
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
const syncPluginsMock = vi.hoisted(() => ({
isServerManaged: vi.fn((_id: string) => true),
}));
vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock);
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
function makePlugin(overrides: Partial<PluginRow> = {}): PluginRow {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
tier: "community",
source: "community",
state: "enabled",
manifestJson: JSON.stringify({
capabilities: ["hosts:read", "kv:own"],
}),
...overrides,
};
}
describe("plugins route", () => {
let server: Server | null = null;
let baseUrl = "";
beforeEach(async () => {
state.plugins = new Map();
state.grants = [];
state.managers = new Set(["admin-1"]);
state.active = new Set();
const app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
server = await new Promise<Server>((resolve) => {
const created = app.listen(0, "127.0.0.1", () => resolve(created));
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = null;
});
describe("GET /plugins/public", () => {
it("lists only enabled plugins that opt into guest pages", async () => {
state.plugins.set(
"guest-plugin",
makePlugin({
id: "guest-plugin",
manifestJson: JSON.stringify({
capabilities: ["hosts:read"],
contributes: { guest: true, guestViews: ["shared"] },
}),
}),
);
state.plugins.set(
"off-guest",
makePlugin({
id: "off-guest",
state: "disabled",
manifestJson: JSON.stringify({ contributes: { guest: true } }),
}),
);
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/public`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
"guest-plugin",
]);
expect(body[0].contributes).toEqual({
guest: true,
guestViews: ["shared"],
tabs: [],
panels: [],
});
expect(body[0]).not.toHaveProperty("capabilities");
expect(body[0]).not.toHaveProperty("lastError");
expect(body[0].version).toBe("");
});
it("leaves out dependencies that do not load on guest pages", async () => {
state.plugins.set(
"sharing",
makePlugin({
id: "sharing",
manifestJson: JSON.stringify({
contributes: {
guest: true,
guestViews: ["shared"],
tabs: [{ id: "collab", titleKey: "nav.collab" }],
},
dependencies: { terminal: "^1.0.0", desktop: "^1.0.0" },
optionalDependencies: { recorder: "^1.0.0" },
}),
}),
);
state.plugins.set(
"desktop",
makePlugin({
id: "desktop",
manifestJson: JSON.stringify({ contributes: { guest: true } }),
}),
);
state.plugins.set("terminal", makePlugin({ id: "terminal" }));
const res = await fetch(`${baseUrl}/plugins/public`);
const body = await res.json();
const sharing = body.find(
(plugin: { id: string }) => plugin.id === "sharing",
);
expect(sharing.dependencies).toEqual({ desktop: "^1.0.0" });
expect(sharing.optionalDependencies).toEqual({});
expect(sharing.contributes.tabs).toEqual([
{ id: "collab", titleKey: "nav.collab" },
]);
});
it("skips a plugin whose manifest does not parse", async () => {
state.plugins.set(
"broken",
makePlugin({ id: "broken", manifestJson: "{" }),
);
const res = await fetch(`${baseUrl}/plugins/public`);
expect(await res.json()).toEqual([]);
});
});
describe("GET /plugins/public-manifest", () => {
it("lists only running plugins with login or second-factor UI, and nothing sensitive", async () => {
state.plugins.set(
"login-plugin",
makePlugin({
id: "login-plugin",
manifestJson: JSON.stringify({
capabilities: ["auth:provide", "credentials:read"],
contributes: {
auth: { loginMethods: ["corp-sso"], sshAuthTypes: ["corp"] },
settings: { admin: [{ key: "clientSecret", type: "secret" }] },
permissions: [{ name: "manage" }],
},
}),
}),
);
state.plugins.set(
"factor-plugin",
makePlugin({
id: "factor-plugin",
manifestJson: JSON.stringify({
contributes: { auth: { secondFactors: ["pin"] } },
}),
}),
);
state.plugins.set(
"stopped-login",
makePlugin({
id: "stopped-login",
manifestJson: JSON.stringify({
contributes: { auth: { loginMethods: ["other"] } },
}),
}),
);
state.plugins.set(
"disabled-login",
makePlugin({
id: "disabled-login",
state: "disabled",
manifestJson: JSON.stringify({
contributes: { auth: { loginMethods: ["x"] } },
}),
}),
);
state.plugins.set("sample-plugin", makePlugin());
state.active = new Set([
"login-plugin",
"factor-plugin",
"disabled-login",
"sample-plugin",
]);
const res = await fetch(`${baseUrl}/plugins/public-manifest`);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
"login-plugin",
"factor-plugin",
]);
expect(body[0].contributes).toEqual({
auth: { loginMethods: ["corp-sso"], secondFactors: [] },
});
expect(body.map((plugin: { version: string }) => plugin.version)).toEqual(
["", ""],
);
for (const plugin of body) {
expect(Object.keys(plugin).sort()).toEqual(
[
"assetVersion",
"contributes",
"css",
"dependencies",
"enabled",
"frontend",
"id",
"locales",
"name",
"optionalDependencies",
"state",
"themeVersion",
"version",
].sort(),
);
}
const raw = JSON.stringify(body);
expect(raw).not.toContain("clientSecret");
expect(raw).not.toContain("credentials:read");
expect(raw).not.toContain("sshAuthTypes");
});
});
it("lists a plugin's declared and granted capabilities", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`);
const body = await res.json();
expect(body).toEqual([
expect.objectContaining({
id: "sample-plugin",
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
}),
]);
});
it("marks server-only plugins on a linked desktop", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ manifestJson: JSON.stringify({ desktop: "server" }) }),
);
const read = async () =>
(
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
runsOnServer: boolean;
}>
)[0].runsOnServer;
expect(await read()).toBe(false);
manageMock.isLinkedDesktop.mockResolvedValue(true);
try {
expect(await read()).toBe(true);
} finally {
manageMock.isLinkedDesktop.mockResolvedValue(false);
}
});
it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts:read",
granted: true,
});
expect(state.grants).toEqual([
{
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
},
]);
});
it("rejects granting a capability the manifest does not declare", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "ssh.exec" }),
});
expect(res.status).toBe(400);
expect(state.grants).toEqual([]);
});
it("404s granting a capability for a plugin that does not exist", async () => {
const res = await fetch(`${baseUrl}/plugins/ghost/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(404);
});
it("does not duplicate an already-granted capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts:read" }),
});
await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(state.grants).toHaveLength(1);
});
it("revokes a granted capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "sample-plugin",
capability: "hosts:read",
granted: false,
});
expect(state.grants).toEqual([]);
});
it("404s revoking a capability that was not granted", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{ method: "DELETE" },
);
expect(res.status).toBe(404);
});
describe("admin.plugins.manage gate", () => {
it("lets a non-admin list plugins", async () => {
// GET stays open to any authenticated user on purpose: the app shell
// calls it for every session to know which plugin tabs to register.
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
expect(res.status).toBe(200);
});
// What a plugin may do, what it has been granted and why it failed are
// operational details. The shell needs none of them to render a tab.
it("hides capabilities, grants and lastError from a non-admin", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const [plugin] = await res.json();
expect(plugin).toEqual({
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
enabled: true,
state: "enabled",
runsOnServer: false,
contributes: null,
dependencies: {},
optionalDependencies: {},
defaultLook: false,
frontend: false,
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
it("shows the full record to a holder of admin.plugins.manage", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({ lastError: "port already in use" }),
);
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "admin-1" },
});
const [plugin] = await res.json();
expect(plugin).toMatchObject({
id: "sample-plugin",
capabilities: ["hosts:read", "kv:own"],
grantedCapabilities: ["hosts:read"],
lastError: "port already in use",
});
});
it("403s a non-admin enabling or disabling a plugin", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/state`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ enabled: false }),
});
expect(res.status).toBe(403);
expect(state.plugins.get("sample-plugin")?.state).toBe("enabled");
});
it("403s a non-admin granting a capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-test-user-id": "regular-user",
},
body: JSON.stringify({ capability: "hosts:read" }),
});
expect(res.status).toBe(403);
expect(state.grants).toEqual([]);
});
it("403s a non-admin revoking a capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
state.grants.push({
pluginId: "sample-plugin",
capability: "hosts:read",
grantedBy: "admin-1",
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`,
{
method: "DELETE",
headers: { "x-test-user-id": "regular-user" },
},
);
expect(res.status).toBe(403);
expect(state.grants).toHaveLength(1);
});
});
describe("onboarding plugin picker", () => {
const post = (body: unknown, user = "admin-1") =>
fetch(`${baseUrl}/plugins/onboarding/apply`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
onboardingMock.describeOnboardingPlugins.mockReset();
onboardingMock.applyOnboardingChoices.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("lists plugins for a manager only", async () => {
onboardingMock.describeOnboardingPlugins.mockResolvedValue({
pending: true,
managedByLinkedServer: false,
plugins: [],
});
const ok = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "admin-1" },
});
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ pending: true });
const denied = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "user-2" },
});
expect(denied.status).toBe(403);
});
it("applies choices and audits each change", async () => {
onboardingMock.applyOnboardingChoices.mockResolvedValue({
resolved: {},
adjustments: [],
enabled: ["a"],
disabled: ["b"],
removed: ["c"],
failed: [],
});
const res = await post({
choices: { a: "enabled", b: "disabled", c: "remove" },
});
expect(res.status).toBe(200);
expect(onboardingMock.applyOnboardingChoices).toHaveBeenCalledWith(
{ a: "enabled", b: "disabled", c: "remove" },
{ dryRun: false },
);
const actions = auditMock.logAudit.mock.calls.map(
(call) => (call as unknown as [{ action: string }])[0].action,
);
expect(actions).toEqual([
"enable_plugin",
"disable_plugin",
"uninstall_plugin",
]);
});
it("rejects bad choices", async () => {
expect((await post({})).status).toBe(400);
expect((await post({ choices: ["a"] })).status).toBe(400);
expect((await post({ choices: { a: "delete" } })).status).toBe(400);
expect(
(await post({ choices: { a: "enabled" }, dryRun: "yes" })).status,
).toBe(400);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
it("refuses on a linked desktop and for non-managers", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
expect((await post({ choices: { a: "enabled" } })).status).toBe(409);
manageMock.isLinkedDesktop.mockResolvedValue(false);
expect((await post({ choices: { a: "enabled" } }, "user-2")).status).toBe(
403,
);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
});
describe("plugin registries", () => {
const call = (
method: string,
path: string,
body?: unknown,
user = "admin-1",
) =>
fetch(`${baseUrl}${path}`, {
method,
headers: { "content-type": "application/json", "x-test-user-id": user },
body: body === undefined ? undefined : JSON.stringify(body),
});
beforeEach(async () => {
for (const fn of Object.values(registriesMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("is admin only", async () => {
for (const [method, path, body] of [
["GET", "/plugins/registries", undefined],
[
"POST",
"/plugins/registries/preview",
{ url: "https://x.com/i.json" },
],
["POST", "/plugins/registries", { url: "https://x.com/i.json" }],
["PATCH", "/plugins/registries/community", { enabled: true }],
["DELETE", "/plugins/registries/acme", undefined],
] as const) {
const res = await call(method, path, body, "user-2");
expect(res.status).toBe(403);
}
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
expect(registriesMock.removeRegistry).not.toHaveBeenCalled();
});
it("lists the registries without the full public keys", async () => {
registriesMock.readRegistriesConfig.mockResolvedValue({
community: { enabled: true },
custom: [
{
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
publicKey: "secret-looking",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
},
],
});
const res = await call("GET", "/plugins/registries");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.community.enabled).toBe(true);
expect(body.custom[0]).toEqual({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
});
});
it("previews and adds a registry and audits the add", async () => {
registriesMock.previewRegistry.mockResolvedValue({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
keyId: "abcd",
plugins: 2,
});
const preview = await call("POST", "/plugins/registries/preview", {
url: "https://acme.example/index.json",
});
expect(preview.status).toBe(200);
expect((await preview.json()).keyId).toBe("abcd");
registriesMock.addRegistry.mockResolvedValue({
id: "acme",
name: "Acme",
url: "https://acme.example/index.json",
publicKey: "k",
keyId: "abcd",
enabled: true,
addedAt: "2026-10-11T00:00:00Z",
});
const added = await call("POST", "/plugins/registries", {
url: "https://acme.example/index.json",
keyId: "abcd",
});
expect(added.status).toBe(200);
expect(registriesMock.addRegistry).toHaveBeenCalledWith(
"https://acme.example/index.json",
"abcd",
);
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "add_plugin_registry",
resourceType: "plugin_registry",
resourceId: "acme",
}),
);
});
it("passes registry errors through with their code", async () => {
registriesMock.addRegistry.mockRejectedValue(
new RegistryError("changed", 409, "KEY_MISMATCH", { keyId: "new" }),
);
const res = await call("POST", "/plugins/registries", {
url: "https://acme.example/index.json",
keyId: "old",
});
expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: "changed",
code: "KEY_MISMATCH",
keyId: "new",
});
});
it("turns a registry on and off and checks the body", async () => {
expect(
(await call("PATCH", "/plugins/registries/community", { enabled: "x" }))
.status,
).toBe(400);
const res = await call("PATCH", "/plugins/registries/community", {
enabled: true,
});
expect(res.status).toBe(200);
expect(registriesMock.setRegistryEnabled).toHaveBeenCalledWith(
"community",
true,
);
});
it("refuses to remove a registry still in use", async () => {
registriesMock.removeRegistry.mockRejectedValue(
new RegistryError("in use", 409, "REGISTRY_IN_USE", {
plugins: ["tool"],
}),
);
const res = await call("DELETE", "/plugins/registries/acme");
expect(res.status).toBe(409);
expect((await res.json()).plugins).toEqual(["tool"]);
});
it("leaves registries to the server on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await call("PATCH", "/plugins/registries/community", {
enabled: true,
});
expect(res.status).toBe(409);
expect(registriesMock.setRegistryEnabled).not.toHaveBeenCalled();
});
it("installs from the registry the caller names", async () => {
manageMock.installPlugin.mockReset();
manageMock.installPlugin.mockResolvedValue({
id: "neat",
version: "1.0.0",
state: "active",
});
const res = await call("POST", "/plugins/neat/install", {
registryId: "community",
capabilities: [],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"neat",
expect.objectContaining({ registryId: "community" }),
);
const bad = await call("POST", "/plugins/neat/install", {
registryId: "../x",
capabilities: [],
});
expect(bad.status).toBe(400);
});
});
describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("installs a plugin as the caller and audits it", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/install", {
version: "1.0.0",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
channel: "stable",
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin",
resourceId: "docker",
}),
);
});
it("installs on the beta channel when asked", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.1.0-beta.1",
state: "active",
});
const res = await post("/plugins/docker/install", {
channel: "beta",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"docker",
expect.objectContaining({ channel: "beta" }),
);
expect(
(
await post("/plugins/docker/install", {
channel: "nightly",
capabilities: [],
})
).status,
).toBe(400);
});
it("moves every plugin to one channel", async () => {
manageMock.setAllPluginChannels.mockResolvedValue({
changed: ["docker"],
});
const res = await post("/plugins/channel", { channel: "beta" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ changed: ["docker"] });
expect(manageMock.setAllPluginChannels).toHaveBeenCalledWith("beta");
expect((await post("/plugins/channel", { channel: "x" })).status).toBe(
400,
);
});
it("saves a plugin's channel and refuses an unknown one", async () => {
manageMock.setPluginOptions.mockResolvedValue({
autoUpdate: false,
pinnedVersion: null,
channel: "beta",
});
const patch = (body: unknown) =>
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "admin-1",
},
body: JSON.stringify(body),
});
expect((await patch({ channel: "beta" })).status).toBe(200);
expect(manageMock.setPluginOptions).toHaveBeenCalledWith("docker", {
autoUpdate: undefined,
pinned: undefined,
channel: "beta",
});
expect((await patch({ channel: "nightly" })).status).toBe(400);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
(await post("/plugins/docker/install", { version: 7 })).status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: "kv:own" }))
.status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: [1] })).status,
).toBe(400);
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
it("passes a refusal through with its code and details", async () => {
manageMock.updatePlugin.mockRejectedValue(
new PluginManageError("new caps", 409, "CAPABILITIES_ADDED", {
capabilities: ["notify:send"],
}),
);
const res = await post("/plugins/docker/update", {});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({
code: "CAPABILITIES_ADDED",
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: false,
capabilities: undefined,
});
});
it("passes the consented list on an accepted update", async () => {
manageMock.updatePlugin.mockResolvedValue({
id: "docker",
version: "1.1.0",
state: "active",
});
await post("/plugins/docker/update", {
acceptCapabilities: true,
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: true,
capabilities: ["notify:send"],
});
});
it("uninstalls", async () => {
manageMock.uninstallPlugin.mockResolvedValue({
id: "docker",
removed: { tables: ["p_docker_x"], kvKeys: 0 },
});
const res = await fetch(`${baseUrl}/plugins/docker`, {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
describe("on a linked desktop", () => {
const switchOn = (id: string) =>
fetch(`${baseUrl}/plugins/${id}/state`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
enabled: true,
version: "1.0.0",
capabilities: ["kv:own"],
}),
});
beforeEach(() => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
state.plugins.set("sample-plugin", makePlugin({ state: "disabled" }));
});
afterEach(() => {
syncPluginsMock.isServerManaged.mockImplementation(() => true);
});
it("refuses to switch a plugin the server decides", async () => {
const res = await switchOn("sample-plugin");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
expect(manageMock.setPluginState).not.toHaveBeenCalled();
});
it("lets the desktop's admin switch one the server does not", async () => {
syncPluginsMock.isServerManaged.mockImplementation(() => false);
manageMock.enablePluginWithConsent.mockResolvedValue({
enable: [],
disable: [],
});
const res = await switchOn("sample-plugin");
expect(res.status).toBe(200);
expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith(
"sample-plugin",
expect.objectContaining({
version: "1.0.0",
capabilities: ["kv:own"],
}),
);
});
it("says per plugin whether the server decides it", async () => {
state.plugins.set("other", makePlugin({ id: "other" }));
syncPluginsMock.isServerManaged.mockImplementation(
(id) => id === "other",
);
const listed = (await (
await fetch(`${baseUrl}/plugins`)
).json()) as Array<{ id: string; managedByServer: boolean }>;
expect(
Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])),
).toEqual({ "sample-plugin": false, other: true });
});
});
it("previews a state change without making it", async () => {
state.plugins.set("sample-plugin", makePlugin());
manageMock.planStateChange.mockResolvedValue({
enable: [],
disable: ["addon"],
missing: [],
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/state?dryRun=1`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
},
);
expect(await res.json()).toMatchObject({ disable: ["addon"] });
expect(manageMock.setPluginState).not.toHaveBeenCalled();
});
it("returns an installed plugin's release notes", async () => {
manageMock.getPluginChangelog.mockResolvedValue([
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
]);
const res = await fetch(`${baseUrl}/plugins/docker/changelog`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
releases: [
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
],
});
expect(manageMock.getPluginChangelog).toHaveBeenCalledWith("docker");
});
it("answers 502 when the registry is unreachable", async () => {
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
const res = await fetch(`${baseUrl}/plugins/registry`);
expect(res.status).toBe(502);
});
it("403s a non-admin on every management route", async () => {
const user = "regular-user";
const headers = {
"content-type": "application/json",
"x-test-user-id": user,
};
const calls = [
fetch(`${baseUrl}/plugins/registry`, { headers }),
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
post("/plugins/channel", { channel: "beta" }, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers,
body: JSON.stringify({ autoUpdate: true }),
}),
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
fetch(`${baseUrl}/plugins/docker/changelog`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers,
body: JSON.stringify({ enabled: true }),
}),
fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-test-user-id": user,
},
body: Buffer.from("x"),
}),
post(`/plugins/upload/${"a".repeat(32)}/install`, {}, user),
];
for (const res of await Promise.all(calls)) {
expect(res.status).toBe(403);
}
for (const fn of Object.values(manageMock)) {
if (fn !== manageMock.isLinkedDesktop) {
expect(fn).not.toHaveBeenCalled();
}
}
});
});
describe("developer mode and file installs", () => {
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("reads and saves developer mode, auditing the change", async () => {
manageMock.getDeveloperMode.mockResolvedValue(true);
const read = await fetch(`${baseUrl}/plugins/developer-mode`);
expect(await read.json()).toEqual({ enabled: true, signedOnly: false });
const bad = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: "yes" }),
});
expect(bad.status).toBe(400);
const saved = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
});
expect(saved.status).toBe(200);
expect(manageMock.setDeveloperMode).toHaveBeenCalledWith(false);
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: "plugin_developer_mode" }),
);
});
it("hands the raw upload to the stager", async () => {
manageMock.stageUpload.mockResolvedValue({ token: "t", id: "notes" });
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(200);
const [buffer] = manageMock.stageUpload.mock.calls[0];
expect(Buffer.from(buffer).toString()).toBe("archive");
});
it("refuses an upload that is not octet-stream", async () => {
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
expect(manageMock.stageUpload).not.toHaveBeenCalled();
});
it("passes the developer mode refusal through", async () => {
manageMock.stageUpload.mockRejectedValue(
new PluginManageError("off", 403, "DEVELOPER_MODE_OFF"),
);
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(403);
expect((await res.json()).code).toBe("DEVELOPER_MODE_OFF");
});
it("installs a staged upload and audits it as unverified", async () => {
manageMock.installUpload.mockResolvedValue({
id: "notes",
version: "0.1.0",
state: "active",
});
const token = "b".repeat(32);
const res = await fetch(`${baseUrl}/plugins/upload/${token}/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: ["kv:own"] }),
});
expect(res.status).toBe(200);
expect(manageMock.installUpload).toHaveBeenCalledWith(token, {
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin_from_file",
resourceId: "notes",
}),
);
});
it("rejects a malformed upload token", async () => {
const res = await fetch(`${baseUrl}/plugins/upload/nope/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: [] }),
});
expect(res.status).toBe(400);
expect(manageMock.installUpload).not.toHaveBeenCalled();
});
});
});