mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint
1397 lines
44 KiB
TypeScript
1397 lines
44 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(),
|
|
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",
|
|
"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: {},
|
|
frontend: false,
|
|
css: false,
|
|
assetVersion: null,
|
|
locales: [],
|
|
});
|
|
});
|
|
|
|
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("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();
|
|
});
|
|
});
|
|
});
|