mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
feat: add the operations layer for declared plugin actions
This commit is contained in:
1 parent
b87fe3c6a5
commit
cc725c7889
54 files changed
+22319
-8
No files matched your search
@@ -0,0 +1,277 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
apiKeyId: undefined as string | undefined,
|
||||
apiKeyScopes: undefined as string[] | null | undefined,
|
||||
actingAdminUserId: undefined as string | undefined,
|
||||
viewAll: false,
|
||||
hostAccess: false,
|
||||
runs: new Map<string, Record<string, unknown>>(),
|
||||
runOperation: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: Record<string, unknown>,
|
||||
_res: unknown,
|
||||
next: () => void,
|
||||
): void => {
|
||||
req.userId = state.userId;
|
||||
req.apiKeyId = state.apiKeyId;
|
||||
req.apiKeyScopes = state.apiKeyScopes;
|
||||
req.actingAdminUserId = state.actingAdminUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => state.viewAll,
|
||||
canAccessHost: async () => ({ hasAccess: state.hostAccess }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentOperationRepository: () => ({
|
||||
findRun: async (id: string) => state.runs.get(id) ?? null,
|
||||
listRuns: async (filters: { userId?: string }) =>
|
||||
[...state.runs.values()].filter(
|
||||
(run) => !filters.userId || run.userId === filters.userId,
|
||||
),
|
||||
listPendingApprovals: async () => [],
|
||||
listEvents: async () => [],
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../operations/catalog.js", () => ({
|
||||
buildCatalog: vi.fn(async () => ({
|
||||
actions: [{ id: "demo.restart" }],
|
||||
events: [],
|
||||
resources: [],
|
||||
signals: [],
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../../operations/resources.js", () => ({
|
||||
flattenResourceRows: vi.fn(),
|
||||
readAcrossHosts: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("../../../operations/gate.js", () => {
|
||||
class OperationError extends Error {
|
||||
status: number;
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
status = 403,
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
return {
|
||||
OperationError,
|
||||
runOperation: state.runOperation,
|
||||
decideOperationApproval: state.decide,
|
||||
cancelOperationRun: state.cancel,
|
||||
};
|
||||
});
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/operations-routes.js");
|
||||
const { OperationError } = await import("../../../operations/gate.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/operations", router);
|
||||
|
||||
const run = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "run-1",
|
||||
actionId: "demo.restart",
|
||||
pluginId: "demo",
|
||||
pluginVersion: "1.0.0",
|
||||
userId: "user-1",
|
||||
username: "name",
|
||||
origin: '{"kind":"ui"}',
|
||||
hostId: 1,
|
||||
hostName: "web",
|
||||
batchId: null,
|
||||
dryRun: false,
|
||||
risk: "change",
|
||||
status: "success",
|
||||
summary: "done",
|
||||
changed: true,
|
||||
input: '{"token":"[redacted]"}',
|
||||
sealedInput: "sealed:secret",
|
||||
output: null,
|
||||
undoInput: null,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
undoOf: null,
|
||||
createdAt: "2026-01-01",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
state.userId = "user-1";
|
||||
state.apiKeyId = undefined;
|
||||
state.apiKeyScopes = undefined;
|
||||
state.actingAdminUserId = undefined;
|
||||
state.viewAll = false;
|
||||
state.hostAccess = false;
|
||||
state.runs.clear();
|
||||
state.runOperation.mockReset();
|
||||
state.runOperation.mockResolvedValue({ runId: "r", status: "success" });
|
||||
state.decide.mockReset();
|
||||
state.decide.mockResolvedValue({ runId: "r", status: "success" });
|
||||
});
|
||||
|
||||
describe("POST /operations/:id/run", () => {
|
||||
it("passes a session as a ui origin", async () => {
|
||||
await request(app)
|
||||
.post("/operations/demo.restart/run")
|
||||
.send({ hostId: 1, input: { name: "x" } })
|
||||
.expect(200);
|
||||
expect(state.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionId: "demo.restart",
|
||||
hostId: 1,
|
||||
userId: "user-1",
|
||||
origin: { kind: "ui" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a key as an api origin with its scopes", async () => {
|
||||
state.apiKeyId = "key";
|
||||
state.apiKeyScopes = ["operations:read"];
|
||||
await request(app)
|
||||
.post("/operations/demo.restart/run")
|
||||
.send({})
|
||||
.expect(200);
|
||||
expect(state.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
origin: { kind: "api" },
|
||||
apiKeyScopes: ["operations:read"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not take the user or origin from the body", async () => {
|
||||
await request(app)
|
||||
.post("/operations/demo.restart/run")
|
||||
.send({ userId: "admin", origin: { kind: "plugin", via: "automation" } })
|
||||
.expect(200);
|
||||
expect(state.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a refusal's status and code", async () => {
|
||||
state.runOperation.mockRejectedValue(
|
||||
new (
|
||||
OperationError as never as new (code: string, message: string) => Error
|
||||
)("FORBIDDEN", "No"),
|
||||
);
|
||||
const response = await request(app)
|
||||
.post("/operations/demo.restart/run")
|
||||
.send({})
|
||||
.expect(403);
|
||||
expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs", () => {
|
||||
it("never returns the sealed input", async () => {
|
||||
state.runs.set("run-1", run());
|
||||
const response = await request(app)
|
||||
.get("/operations/runs/run-1")
|
||||
.expect(200);
|
||||
expect(JSON.stringify(response.body)).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("hides another user's run", async () => {
|
||||
state.runs.set("run-1", run({ userId: "someone" }));
|
||||
await request(app).get("/operations/runs/run-1").expect(404);
|
||||
});
|
||||
|
||||
it("shows a run to someone who manages its host", async () => {
|
||||
state.runs.set("run-1", run({ userId: "someone" }));
|
||||
state.hostAccess = true;
|
||||
await request(app).get("/operations/runs/run-1").expect(200);
|
||||
});
|
||||
|
||||
it("lists only a user's own runs", async () => {
|
||||
state.runs.set("a", run({ id: "a" }));
|
||||
state.runs.set("b", run({ id: "b", userId: "someone" }));
|
||||
const response = await request(app).get("/operations/runs").expect(200);
|
||||
expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists everything with the view permission", async () => {
|
||||
state.viewAll = true;
|
||||
state.runs.set("a", run({ id: "a" }));
|
||||
state.runs.set("b", run({ id: "b", userId: "someone" }));
|
||||
const response = await request(app).get("/operations/runs").expect(200);
|
||||
expect(response.body.runs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not cancel another user's run", async () => {
|
||||
state.runs.set("run-1", run({ userId: "someone" }));
|
||||
await request(app).post("/operations/runs/run-1/cancel").expect(404);
|
||||
expect(state.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /operations/approvals/:id", () => {
|
||||
it("needs a clear answer", async () => {
|
||||
await request(app)
|
||||
.post("/operations/approvals/a")
|
||||
.send({ approve: "yes" })
|
||||
.expect(400);
|
||||
expect(state.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a scoped key", async () => {
|
||||
state.apiKeyScopes = ["operations:run"];
|
||||
await request(app)
|
||||
.post("/operations/approvals/a")
|
||||
.send({ approve: true })
|
||||
.expect(403);
|
||||
expect(state.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks an impersonated request", async () => {
|
||||
state.actingAdminUserId = "admin";
|
||||
await request(app)
|
||||
.post("/operations/approvals/a")
|
||||
.send({ approve: true })
|
||||
.expect(200);
|
||||
expect(state.decide).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ impersonated: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /operations/catalog", () => {
|
||||
it("answers", async () => {
|
||||
const response = await request(app).get("/operations/catalog").expect(200);
|
||||
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OperationAction } from "@termix-ssh/plugin-sdk/operations";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
permissions: new Set<string>(),
|
||||
hostLevels: new Map<number, string>(),
|
||||
plugin: {
|
||||
state: "active",
|
||||
source: "bundled",
|
||||
signedTier: undefined as string | undefined,
|
||||
manifest: {
|
||||
version: "1.0.0",
|
||||
capabilities: ["operations:provide"],
|
||||
},
|
||||
},
|
||||
capability: true,
|
||||
safeMode: false,
|
||||
host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as {
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
},
|
||||
runs: new Map<string, Record<string, unknown>>(),
|
||||
approvals: new Map<string, Record<string, unknown>>(),
|
||||
audits: [] as Record<string, unknown>[],
|
||||
events: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (text: string) => `sealed:${text}`,
|
||||
decryptSystemSecret: async (text: string) => text.slice("sealed:".length),
|
||||
isSystemEncrypted: (text: string) => text.startsWith("sealed:"),
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (userId: string, permission: string) =>
|
||||
state.permissions.has(`${userId}:${permission}`),
|
||||
canAccessHost: async (userId: string, hostId: number, level: string) => {
|
||||
const held = state.hostLevels.get(hostId);
|
||||
const rank = ["connect", "view", "edit", "manage"];
|
||||
return {
|
||||
hasAccess:
|
||||
held !== undefined && rank.indexOf(held) >= rank.indexOf(level),
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async () => state.capability,
|
||||
}));
|
||||
vi.mock("../../plugins/safe-mode.js", () => ({
|
||||
safeModeOn: () => state.safeMode,
|
||||
}));
|
||||
vi.mock("../../plugins/manifest.js", () => ({
|
||||
qualifyPermission: (pluginId: string, name: string) =>
|
||||
name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`,
|
||||
}));
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { get: () => state.plugin } }),
|
||||
}));
|
||||
vi.mock("../../plugins/host-status-provider.js", () => ({
|
||||
hostStatus: { get: () => null },
|
||||
}));
|
||||
vi.mock("../../plugins/ctx-probe.js", () => ({
|
||||
loadProbeTargets: async () => [],
|
||||
}));
|
||||
vi.mock("../../operations/hosts.js", () => ({
|
||||
loadOperationHost: async (id: number) => ({
|
||||
id,
|
||||
...state.host,
|
||||
os: null,
|
||||
settings: {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({ username: `name-${id}` }),
|
||||
}),
|
||||
createCurrentOperationRepository: () => ({
|
||||
insertRun: async (run: Record<string, unknown>) => {
|
||||
state.runs.set(run.id as string, { ...run });
|
||||
},
|
||||
findRun: async (id: string) => state.runs.get(id) ?? null,
|
||||
findByIdempotencyKey: async (userId: string, key: string) =>
|
||||
[...state.runs.values()].find(
|
||||
(run) => run.userId === userId && run.idempotencyKey === key,
|
||||
) ?? null,
|
||||
updateRunFrom: async (
|
||||
id: string,
|
||||
from: string,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
const run = state.runs.get(id);
|
||||
if (!run || run.status !== from) return false;
|
||||
Object.assign(run, patch);
|
||||
return true;
|
||||
},
|
||||
insertApproval: async (row: Record<string, unknown>) => {
|
||||
state.approvals.set(row.id as string, { ...row });
|
||||
},
|
||||
findApproval: async (id: string) => state.approvals.get(id) ?? null,
|
||||
findApprovalForRun: async (runId: string) =>
|
||||
[...state.approvals.values()].find((row) => row.runId === runId) ?? null,
|
||||
decideApproval: async (id: string, status: string) => {
|
||||
const row = state.approvals.get(id);
|
||||
if (!row || row.status !== "pending") return false;
|
||||
row.status = status;
|
||||
return true;
|
||||
},
|
||||
insertEvent: async (event: Record<string, unknown>) => {
|
||||
state.events.push(event);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerAction, clearOperationRegistry } =
|
||||
await import("../../operations/registry.js");
|
||||
const { provideApprovalPolicy, clearApprovalPolicies } =
|
||||
await import("../../operations/approvals.js");
|
||||
const {
|
||||
cancelOperationRun,
|
||||
clearOperationGate,
|
||||
decideOperationApproval,
|
||||
drainPlugin,
|
||||
OperationError,
|
||||
OperationNeedsInteraction,
|
||||
runOperation,
|
||||
} = await import("../../operations/gate.js");
|
||||
|
||||
const USER = "user-1";
|
||||
const APPROVER = "user-2";
|
||||
|
||||
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
|
||||
id: "demo.restart",
|
||||
titleKey: "t",
|
||||
scope: "host",
|
||||
risk: "change",
|
||||
permission: "use",
|
||||
hostAccess: "edit",
|
||||
inputs: [
|
||||
{ key: "name", type: "string", required: true },
|
||||
{ key: "token", type: "secret" },
|
||||
],
|
||||
dryRun: "native",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function register(
|
||||
overrides: Partial<OperationAction> = {},
|
||||
run = vi.fn(async () => ({ summary: "done", changed: true })),
|
||||
) {
|
||||
registerAction("demo", action(overrides), { run });
|
||||
return run;
|
||||
}
|
||||
|
||||
const manifestForPolicy = {
|
||||
capabilities: ["operations:approve"],
|
||||
} as never;
|
||||
|
||||
const base = {
|
||||
actionId: "demo.restart",
|
||||
hostId: 1,
|
||||
input: { name: "nginx" },
|
||||
userId: USER,
|
||||
origin: { kind: "ui" } as const,
|
||||
};
|
||||
|
||||
async function refused(request: Parameters<typeof runOperation>[0]) {
|
||||
try {
|
||||
await runOperation(request);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(OperationError);
|
||||
return (error as InstanceType<typeof OperationError>).code;
|
||||
}
|
||||
throw new Error("Expected a refusal");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]);
|
||||
state.hostLevels = new Map([[1, "edit"]]);
|
||||
state.plugin.state = "active";
|
||||
state.plugin.source = "bundled";
|
||||
state.plugin.manifest.version = "1.0.0";
|
||||
state.capability = true;
|
||||
state.safeMode = false;
|
||||
state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] };
|
||||
state.runs.clear();
|
||||
state.approvals.clear();
|
||||
state.audits.length = 0;
|
||||
state.events.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOperationRegistry();
|
||||
clearApprovalPolicies();
|
||||
clearOperationGate();
|
||||
});
|
||||
|
||||
describe("refusals", () => {
|
||||
it("does not run an unknown action", async () => {
|
||||
expect(await refused(base)).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("does not run in safe mode", async () => {
|
||||
register();
|
||||
state.safeMode = true;
|
||||
expect(await refused(base)).toBe("SAFE_MODE");
|
||||
});
|
||||
|
||||
it("does not run for a plugin that stopped", async () => {
|
||||
register();
|
||||
state.plugin.state = "disabled";
|
||||
expect(await refused(base)).toBe("UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("rechecks the grant at use", async () => {
|
||||
const run = register();
|
||||
state.capability = false;
|
||||
expect(await refused(base)).toBe("UNAVAILABLE");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("needs the role permission", async () => {
|
||||
const run = register();
|
||||
state.permissions.clear();
|
||||
expect(await refused(base)).toBe("FORBIDDEN");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("needs the declared host level", async () => {
|
||||
const run = register();
|
||||
state.hostLevels.set(1, "connect");
|
||||
expect(await refused(base)).toBe("FORBIDDEN");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses bad input before anything runs", async () => {
|
||||
const run = register();
|
||||
expect(await refused({ ...base, input: { nope: 1 } })).toBe(
|
||||
"INVALID_INPUT",
|
||||
);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("needs a host for a host action", async () => {
|
||||
register();
|
||||
expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT");
|
||||
});
|
||||
|
||||
it("needs a confirm for destructive actions", async () => {
|
||||
register({ risk: "destructive" });
|
||||
expect(await refused(base)).toBe("NEEDS_CONFIRM");
|
||||
expect((await runOperation({ ...base, confirmed: true })).status).toBe(
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops long chains", async () => {
|
||||
register();
|
||||
expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("api key scopes", () => {
|
||||
it("lets a read key run reads only", async () => {
|
||||
register();
|
||||
register({ id: "demo.read", risk: "read", hostAccess: "connect" });
|
||||
expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe(
|
||||
"SCOPE",
|
||||
);
|
||||
const read = await runOperation({
|
||||
...base,
|
||||
actionId: "demo.read",
|
||||
apiKeyScopes: ["operations:read"],
|
||||
});
|
||||
expect(read.status).toBe("success");
|
||||
});
|
||||
|
||||
it("treats no scopes as full access", async () => {
|
||||
register();
|
||||
const outcome = await runOperation({ ...base, apiKeyScopes: null });
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs", () => {
|
||||
it("records a run and never stores a secret", async () => {
|
||||
const run = register();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
input: { name: "nginx", token: "hunter2" },
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
|
||||
const row = state.runs.get(outcome.runId!)!;
|
||||
expect(row.status).toBe("success");
|
||||
expect(JSON.stringify(row)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(state.audits)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(state.events)).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("keeps failures in the run, not thrown", async () => {
|
||||
register(
|
||||
{},
|
||||
vi.fn(async () => {
|
||||
throw new Error("no such unit");
|
||||
}),
|
||||
);
|
||||
const outcome = await runOperation(base);
|
||||
expect(outcome).toMatchObject({ status: "failed", error: "no such unit" });
|
||||
expect(state.runs.get(outcome.runId!)!.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("reports hosts that need a person", async () => {
|
||||
register(
|
||||
{},
|
||||
vi.fn(async () => {
|
||||
throw new OperationNeedsInteraction("Sign in first");
|
||||
}),
|
||||
);
|
||||
const outcome = await runOperation(base);
|
||||
expect(outcome.status).toBe("needs_interaction");
|
||||
});
|
||||
|
||||
it("passes dry run to a native handler", async () => {
|
||||
const run = register();
|
||||
await runOperation({ ...base, dryRun: true });
|
||||
expect(run.mock.calls[0]).toBeDefined();
|
||||
expect(
|
||||
(run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1]
|
||||
.dryRun,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never calls run for a describe dry run", async () => {
|
||||
const run = vi.fn(async () => ({ summary: "x" }));
|
||||
registerAction("demo", action({ dryRun: "describe" }), {
|
||||
run,
|
||||
describe: () => "Would restart nginx",
|
||||
});
|
||||
const outcome = await runOperation({ ...base, dryRun: true });
|
||||
expect(outcome.summary).toBe("Would restart nginx");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops sensitive outputs", async () => {
|
||||
register(
|
||||
{ outputs: [{ key: "conf", type: "textarea", sensitive: true }] },
|
||||
vi.fn(async () => ({
|
||||
summary: "ok",
|
||||
data: { conf: "private key" },
|
||||
})),
|
||||
);
|
||||
const outcome = await runOperation(base);
|
||||
expect(JSON.stringify(outcome)).not.toContain("private key");
|
||||
expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain(
|
||||
"private key",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes no row for a background read", async () => {
|
||||
register({ risk: "read", hostAccess: "connect" });
|
||||
const outcome = await runOperation({ ...base, internal: true });
|
||||
expect(outcome.runId).toBeNull();
|
||||
expect(state.runs.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not run twice for one idempotency key", async () => {
|
||||
const run = register();
|
||||
const first = await runOperation({ ...base, idempotencyKey: "abc" });
|
||||
const second = await runOperation({ ...base, idempotencyKey: "abc" });
|
||||
expect(second.runId).toBe(first.runId);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits a run event for a change", async () => {
|
||||
register();
|
||||
await runOperation(base);
|
||||
await vi.waitFor(() =>
|
||||
expect(state.events[0]).toMatchObject({
|
||||
eventId: "operation.succeeded",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops a run on cancel", async () => {
|
||||
let started = () => {};
|
||||
const ready = new Promise<void>((resolve) => (started = resolve));
|
||||
register(
|
||||
{},
|
||||
vi.fn(
|
||||
(_input, context: { signal: AbortSignal }) =>
|
||||
new Promise((_, reject) => {
|
||||
started();
|
||||
context.signal.addEventListener("abort", () =>
|
||||
reject(new Error("stopped")),
|
||||
);
|
||||
}),
|
||||
) as never,
|
||||
);
|
||||
const running = runOperation(base);
|
||||
await ready;
|
||||
const row = [...state.runs.values()][0]!;
|
||||
expect(await cancelOperationRun(row as never)).toBe(true);
|
||||
expect((await running).status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("keeps AI and MCP away from changes with no rule", async () => {
|
||||
register();
|
||||
for (const via of ["assistant", "mcp"]) {
|
||||
expect(
|
||||
await refused({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via },
|
||||
}),
|
||||
).toBe("DENIED");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a plugin with no stated purpose", async () => {
|
||||
register();
|
||||
expect(
|
||||
await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }),
|
||||
).toBe("DENIED");
|
||||
});
|
||||
|
||||
it("lets a person's automation go by RBAC", async () => {
|
||||
register();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via: "automation" },
|
||||
});
|
||||
expect(outcome.status).toBe("success");
|
||||
});
|
||||
|
||||
it("treats a community read as a change", async () => {
|
||||
state.plugin.source = "user";
|
||||
state.plugin.signedTier = "community";
|
||||
register({ risk: "read", hostAccess: "connect" });
|
||||
expect(
|
||||
await refused({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
|
||||
}),
|
||||
).toBe("DENIED");
|
||||
state.plugin.signedTier = undefined;
|
||||
});
|
||||
|
||||
it("denies when a provider throws", async () => {
|
||||
register();
|
||||
provideApprovalPolicy("rules", manifestForPolicy, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(await refused(base)).toBe("DENIED");
|
||||
});
|
||||
|
||||
it("takes the strictest answer", async () => {
|
||||
register();
|
||||
provideApprovalPolicy("a", manifestForPolicy, () => ({
|
||||
decision: "allow",
|
||||
}));
|
||||
provideApprovalPolicy("b", manifestForPolicy, () => ({
|
||||
decision: "deny",
|
||||
reason: "No",
|
||||
}));
|
||||
expect(await refused(base)).toBe("DENIED");
|
||||
});
|
||||
|
||||
it("audits a denial", async () => {
|
||||
register();
|
||||
provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" }));
|
||||
await refused(base);
|
||||
expect(state.audits[0]).toMatchObject({
|
||||
action: "operation_denied",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
const needApproval = () =>
|
||||
provideApprovalPolicy("rules", manifestForPolicy, () => ({
|
||||
decision: "approve",
|
||||
}));
|
||||
|
||||
async function pending(run = register()) {
|
||||
needApproval();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
input: { name: "nginx", token: "hunter2" },
|
||||
});
|
||||
expect(outcome.status).toBe("pending_approval");
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const decide = (approvalId: string, approverId = APPROVER) =>
|
||||
decideOperationApproval({ approvalId, approverId, approve: true });
|
||||
|
||||
it("runs the sealed input once approved, then wipes it", async () => {
|
||||
const run = register();
|
||||
const { approvalId, runId } = await pending(run);
|
||||
|
||||
const row = state.runs.get(runId!)!;
|
||||
expect(row.input).not.toContain("hunter2");
|
||||
expect(row.sealedInput).toBeTruthy();
|
||||
|
||||
const outcome = await decide(approvalId!);
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
|
||||
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
|
||||
});
|
||||
|
||||
it("is one shot", async () => {
|
||||
const run = register();
|
||||
const { approvalId } = await pending(run);
|
||||
await decide(approvalId!);
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let a person approve their own request", async () => {
|
||||
const { approvalId } = await pending();
|
||||
await expect(decide(approvalId!, USER)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a person approve what an AI asked for", async () => {
|
||||
register();
|
||||
needApproval();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
|
||||
});
|
||||
const done = await decide(outcome.approvalId!, USER);
|
||||
expect(done.status).toBe("success");
|
||||
});
|
||||
|
||||
it("does not let someone impersonating approve", async () => {
|
||||
const { approvalId } = await pending();
|
||||
await expect(
|
||||
decideOperationApproval({
|
||||
approvalId: approvalId!,
|
||||
approverId: APPROVER,
|
||||
approve: true,
|
||||
impersonated: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("needs the approver to hold the action's rights", async () => {
|
||||
const { approvalId } = await pending();
|
||||
state.permissions.delete(`${APPROVER}:demo.use`);
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rechecks the requester when it runs", async () => {
|
||||
const run = register();
|
||||
const { approvalId, runId } = await pending(run);
|
||||
state.permissions.delete(`${USER}:demo.use`);
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(state.runs.get(runId!)!.status).toBe("rejected");
|
||||
});
|
||||
|
||||
it("goes stale when the host changed", async () => {
|
||||
const run = register();
|
||||
const { approvalId } = await pending(run);
|
||||
state.host.updatedAt = "2026-02-02";
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("goes stale when the plugin changed", async () => {
|
||||
const run = register();
|
||||
const { approvalId } = await pending(run);
|
||||
state.plugin.manifest.version = "2.0.0";
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a sealed copy from another run", async () => {
|
||||
const run = register();
|
||||
const first = await pending(run);
|
||||
const second = await runOperation(base);
|
||||
state.runs.get(second.runId!)!.sealedInput = state.runs.get(
|
||||
first.runId!,
|
||||
)!.sealedInput;
|
||||
await expect(decide(second.approvalId!)).rejects.toMatchObject({
|
||||
code: "STALE",
|
||||
});
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an expired approval", async () => {
|
||||
const run = register();
|
||||
const { approvalId } = await pending(run);
|
||||
state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z";
|
||||
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wipes the sealed input on reject", async () => {
|
||||
const { approvalId, runId } = await pending();
|
||||
const outcome = await decideOperationApproval({
|
||||
approvalId: approvalId!,
|
||||
approverId: APPROVER,
|
||||
approve: false,
|
||||
});
|
||||
expect(outcome.status).toBe("rejected");
|
||||
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("drain", () => {
|
||||
it("waits for a handler that is still stopping", async () => {
|
||||
let finish = () => {};
|
||||
register(
|
||||
{},
|
||||
vi.fn(
|
||||
() =>
|
||||
new Promise<never>((_, reject) => {
|
||||
finish = () => reject(new Error("closed"));
|
||||
}),
|
||||
) as never,
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
void runOperation(base).catch(() => {});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
let drained = false;
|
||||
const draining = drainPlugin("demo").then(() => (drained = true));
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(drained).toBe(false);
|
||||
|
||||
finish();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await draining;
|
||||
vi.useRealTimers();
|
||||
expect(drained).toBe(true);
|
||||
});
|
||||
|
||||
it("stops running work and marks it interrupted", async () => {
|
||||
register({}, vi.fn(() => new Promise(() => {})) as never);
|
||||
vi.useFakeTimers();
|
||||
const running = runOperation(base);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const draining = drainPlugin("demo");
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await draining;
|
||||
await running;
|
||||
vi.useRealTimers();
|
||||
expect([...state.runs.values()][0]!.status).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("refuses new runs while draining", async () => {
|
||||
register({}, vi.fn(() => new Promise(() => {})) as never);
|
||||
vi.useFakeTimers();
|
||||
void runOperation(base);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const draining = drainPlugin("demo");
|
||||
await expect(runOperation(base)).rejects.toMatchObject({
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
await draining;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js";
|
||||
|
||||
const READ = ["operations:read"];
|
||||
const RUN = ["operations:run"];
|
||||
|
||||
describe("keyScopeAllows", () => {
|
||||
it("leaves a key with no scopes alone", () => {
|
||||
expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true);
|
||||
expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a scoped key off every other route", () => {
|
||||
for (const path of [
|
||||
"/users/ws-ticket/api-key",
|
||||
"/host/1",
|
||||
"/plugin-api/docker/1/containers",
|
||||
"/credentials",
|
||||
]) {
|
||||
expect(keyScopeAllows(RUN, "GET", path)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a read key read and start runs the gate then checks", () => {
|
||||
expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reaches a plugin's MCP route only", () => {
|
||||
expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match lookalike paths", () => {
|
||||
expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants nothing with an empty list", () => {
|
||||
expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKeyScopes", () => {
|
||||
it("reads a list", () => {
|
||||
expect(parseKeyScopes('["operations:read"]')).toEqual(READ);
|
||||
});
|
||||
|
||||
it("keeps old keys at full access", () => {
|
||||
expect(parseKeyScopes(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed on junk", () => {
|
||||
expect(parseKeyScopes("{nope")).toEqual([]);
|
||||
expect(parseKeyScopes('"x"')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "../../plugins/manifest.js";
|
||||
|
||||
const gate = vi.hoisted(() => ({
|
||||
runOperation: vi.fn(async () => ({ runId: "r", status: "success" })),
|
||||
drainPlugin: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../operations/gate.js", () => ({
|
||||
...gate,
|
||||
OperationError: class extends Error {
|
||||
constructor(_code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("../../operations/catalog.js", () => ({
|
||||
buildCatalog: vi.fn(async () => ({ actions: [] })),
|
||||
}));
|
||||
vi.mock("../../operations/events.js", () => ({
|
||||
emitOperationEvent: vi.fn(async () => {}),
|
||||
subscribeOperationEvents: vi.fn(() => () => {}),
|
||||
}));
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
assertCapability: vi.fn(
|
||||
async (_id: string, capability: string, declared: string[]) => {
|
||||
if (!declared.includes(capability)) throw new Error("refused");
|
||||
},
|
||||
),
|
||||
capabilityRefused: (pluginId: string, capability: string) =>
|
||||
new Error(`${pluginId} lacks ${capability}`),
|
||||
}));
|
||||
|
||||
const { createPluginOperations } =
|
||||
await import("../../plugins/ctx-operations.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { clearOperationRegistry, getAction } =
|
||||
await import("../../operations/registry.js");
|
||||
const { runAsActor } = await import("../../plugins/actor.js");
|
||||
|
||||
const action = {
|
||||
id: "demo.restart",
|
||||
titleKey: "t",
|
||||
scope: "host",
|
||||
risk: "change",
|
||||
permission: "use",
|
||||
hostAccess: "edit",
|
||||
};
|
||||
|
||||
function build(capabilities: string[], invokes?: string[]) {
|
||||
const bag = new DisposableBag("demo");
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
capabilities,
|
||||
contributes: { operations: { actions: [action], invokes } },
|
||||
} as unknown as PluginManifest;
|
||||
return { bag, operations: createPluginOperations({ manifest, bag }) };
|
||||
}
|
||||
|
||||
const handler = { run: async () => ({ summary: "ok" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
clearOperationRegistry();
|
||||
gate.runOperation.mockClear();
|
||||
gate.drainPlugin.mockClear();
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("needs the capability", () => {
|
||||
const { operations } = build([]);
|
||||
expect(() => operations.register("demo.restart", handler)).toThrow(
|
||||
"operations:provide",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an undeclared action", () => {
|
||||
const { operations } = build(["operations:provide"]);
|
||||
expect(() => operations.register("demo.other", handler)).toThrow(
|
||||
"not declared",
|
||||
);
|
||||
});
|
||||
|
||||
it("drains, then revokes, when the plugin stops", async () => {
|
||||
const { bag, operations } = build(["operations:provide"]);
|
||||
operations.register("demo.restart", handler);
|
||||
expect(getAction("demo.restart")).toBeDefined();
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(gate.drainPlugin).toHaveBeenCalledWith("demo");
|
||||
expect(getAction("demo.restart")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the new handler when an old disposer runs after a restart", async () => {
|
||||
const first = build(["operations:provide"]);
|
||||
first.operations.register("demo.restart", handler);
|
||||
const second = build(["operations:provide"]);
|
||||
const fresh = { run: async () => ({ summary: "new" }) };
|
||||
second.operations.register("demo.restart", fresh);
|
||||
|
||||
await first.bag.disposeAll();
|
||||
expect(getAction("demo.restart")?.handler).toBe(fresh);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invoke", () => {
|
||||
const asUser = <T>(fn: () => Promise<T>) =>
|
||||
runAsActor("user", "request", fn, undefined, {
|
||||
origin: { kind: "ui" },
|
||||
apiKeyScopes: ["operations:read"],
|
||||
});
|
||||
|
||||
it("needs the capability", async () => {
|
||||
const { operations } = build([], ["*"]);
|
||||
await expect(
|
||||
asUser(() => operations.invoke("docker.restart")),
|
||||
).rejects.toThrow("refused");
|
||||
});
|
||||
|
||||
it("only reaches what the manifest lists", async () => {
|
||||
const { operations } = build(["operations:invoke"], ["docker.*"]);
|
||||
await expect(
|
||||
asUser(() => operations.invoke("firewall.allow")),
|
||||
).rejects.toThrow("did not declare");
|
||||
expect(gate.runOperation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the plugin as origin and keeps the key's limits", async () => {
|
||||
const { operations } = build(["operations:invoke"], ["docker.*"]);
|
||||
await asUser(() =>
|
||||
operations.invoke("docker.restart", { via: "automation" }),
|
||||
);
|
||||
expect(gate.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user",
|
||||
origin: { kind: "plugin", pluginId: "demo", via: "automation" },
|
||||
apiKeyScopes: ["operations:read"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a plugin pick its own origin", async () => {
|
||||
const { operations } = build(["operations:invoke"], ["*"]);
|
||||
await asUser(() =>
|
||||
operations.invoke("docker.restart", {
|
||||
origin: { kind: "ui" },
|
||||
} as never),
|
||||
);
|
||||
expect(gate.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("needs an acting user", async () => {
|
||||
const { operations } = build(["operations:invoke"], ["*"]);
|
||||
await expect(operations.invoke("docker.restart")).rejects.toThrow(
|
||||
"acting user",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("keeps the caller as origin for a plugin's own action", async () => {
|
||||
const { operations } = build(["operations:provide"]);
|
||||
await runAsActor(
|
||||
"user",
|
||||
"request",
|
||||
() => operations.run("demo.restart", { hostId: 1 }),
|
||||
undefined,
|
||||
{ origin: { kind: "ui" } },
|
||||
);
|
||||
expect(gate.runOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses another plugin's action", async () => {
|
||||
const { operations } = build(["operations:provide"]);
|
||||
await expect(
|
||||
runAsActor("user", "request", () => operations.run("docker.restart")),
|
||||
).rejects.toThrow("not declared");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emit", () => {
|
||||
it("refuses an undeclared event", async () => {
|
||||
const { operations } = build(["operations:provide"]);
|
||||
await expect(operations.emit("demo.nothing")).rejects.toThrow(
|
||||
"not declared",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvals", () => {
|
||||
it("needs the capability", () => {
|
||||
const { operations } = build([]);
|
||||
expect(() => operations.approvals.provide(() => null)).toThrow(
|
||||
"operations:approve",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
invokeAllowed,
|
||||
matchesWhen,
|
||||
onSurface,
|
||||
parseOperationInput,
|
||||
redactFields,
|
||||
toJsonSchema,
|
||||
type OperationAction,
|
||||
type OperationField,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
|
||||
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
|
||||
id: "demo.allow-port",
|
||||
titleKey: "ops.allow",
|
||||
scope: "host",
|
||||
risk: "change",
|
||||
permission: "use",
|
||||
hostAccess: "edit",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function manifest(operations: unknown, capabilities = ["operations:provide"]) {
|
||||
return {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
version: "1.0.0",
|
||||
description: "A demo.",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=26.11.1-0", api: "^1.13" },
|
||||
capabilities,
|
||||
contributes: {
|
||||
permissions: [
|
||||
{ name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" },
|
||||
],
|
||||
operations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorsFor(operations: unknown, capabilities?: string[]): string[] {
|
||||
return parseManifest(manifest(operations, capabilities)).errors ?? [];
|
||||
}
|
||||
|
||||
describe("contributes.operations", () => {
|
||||
it("accepts a full declaration", () => {
|
||||
const errors = errorsFor({
|
||||
actions: [
|
||||
action({
|
||||
check: "demo.port-allowed",
|
||||
inputs: [
|
||||
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
|
||||
{ key: "name", type: "resource", resource: "demo.rule" },
|
||||
],
|
||||
}),
|
||||
action({
|
||||
id: "demo.port-allowed",
|
||||
risk: "read",
|
||||
hostAccess: "connect",
|
||||
}),
|
||||
action({
|
||||
id: "demo.list-rules",
|
||||
risk: "read",
|
||||
hostAccess: "connect",
|
||||
}),
|
||||
],
|
||||
events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }],
|
||||
resources: [
|
||||
{
|
||||
id: "demo.rule",
|
||||
titleKey: "r",
|
||||
list: "demo.list-rules",
|
||||
key: "id",
|
||||
name: "name",
|
||||
},
|
||||
],
|
||||
signals: [
|
||||
{
|
||||
id: "demo.rules",
|
||||
titleKey: "s",
|
||||
read: "demo.port-allowed",
|
||||
scope: "host",
|
||||
kind: "count",
|
||||
fix: "demo.allow-port",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("needs the capability", () => {
|
||||
expect(errorsFor({ actions: [action()] }, [])).toContainEqual(
|
||||
expect.stringContaining("operations:provide"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an id outside the plugin", () => {
|
||||
expect(
|
||||
errorsFor({ actions: [action({ id: "docker.stop" })] }),
|
||||
).toContainEqual(expect.stringContaining('must start with "demo."'));
|
||||
});
|
||||
|
||||
it("refuses a destructive action a connect level user could run", () => {
|
||||
expect(
|
||||
errorsFor({
|
||||
actions: [action({ risk: "destructive", hostAccess: "connect" })],
|
||||
}),
|
||||
).toContainEqual(expect.stringContaining("edit or manage"));
|
||||
});
|
||||
|
||||
it("lets a change declare its own level", () => {
|
||||
expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an undeclared permission", () => {
|
||||
expect(
|
||||
errorsFor({ actions: [action({ permission: "nope" })] }),
|
||||
).toContainEqual(expect.stringContaining("is not declared"));
|
||||
});
|
||||
|
||||
it("refuses a check that is not a read", () => {
|
||||
expect(
|
||||
errorsFor({
|
||||
actions: [
|
||||
action({ check: "demo.other" }),
|
||||
action({ id: "demo.other" }),
|
||||
],
|
||||
}),
|
||||
).toContainEqual(expect.stringContaining("must be a read action"));
|
||||
});
|
||||
|
||||
it("refuses references that go nowhere", () => {
|
||||
const errors = errorsFor({
|
||||
actions: [action({ undo: "demo.gone" })],
|
||||
resources: [
|
||||
{
|
||||
id: "demo.rule",
|
||||
titleKey: "r",
|
||||
list: "demo.gone",
|
||||
key: "id",
|
||||
name: "n",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(errors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("refuses unknown fields", () => {
|
||||
expect(
|
||||
errorsFor({ actions: [{ ...action(), extra: true }] }),
|
||||
).toContainEqual(expect.stringContaining('Unknown field "extra"'));
|
||||
});
|
||||
|
||||
it("refuses a default on a secret", () => {
|
||||
expect(
|
||||
errorsFor({
|
||||
actions: [
|
||||
action({
|
||||
inputs: [{ key: "token", type: "secret", default: "abc" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toContainEqual(expect.stringContaining("not allowed on a secret"));
|
||||
});
|
||||
|
||||
it("needs operations:invoke for invokes", () => {
|
||||
expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual(
|
||||
expect.stringContaining("operations:invoke"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOperationInput", () => {
|
||||
const fields: OperationField[] = [
|
||||
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
|
||||
{
|
||||
key: "proto",
|
||||
type: "select",
|
||||
default: "tcp",
|
||||
options: [
|
||||
{ value: "tcp", labelKey: "t" },
|
||||
{ value: "udp", labelKey: "u" },
|
||||
],
|
||||
},
|
||||
{ key: "name", type: "string", pattern: "[a-z]+" },
|
||||
{ key: "data", type: "json" },
|
||||
];
|
||||
|
||||
it("coerces strings and fills defaults", () => {
|
||||
expect(parseOperationInput(fields, { port: "443" })).toEqual({
|
||||
values: { port: 443, proto: "tcp" },
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses keys no field declares", () => {
|
||||
expect(
|
||||
parseOperationInput(fields, { port: 1, sudo: true }).errors,
|
||||
).toContainEqual(expect.stringContaining('"sudo"'));
|
||||
});
|
||||
|
||||
it("needs required fields", () => {
|
||||
expect(parseOperationInput(fields, {}).errors).toEqual([
|
||||
'"port" is required',
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks bounds, options and the whole pattern", () => {
|
||||
expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1);
|
||||
expect(
|
||||
parseOperationInput(fields, { port: 1, proto: "icmp" }).errors,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors,
|
||||
).toEqual(['"name" has the wrong format']);
|
||||
});
|
||||
|
||||
it("parses json strings", () => {
|
||||
expect(
|
||||
parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data,
|
||||
).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("refuses a redacted echo for a secret", () => {
|
||||
const secret: OperationField[] = [{ key: "token", type: "secret" }];
|
||||
expect(
|
||||
parseOperationInput(secret, { token: { set: true } }).errors,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses input that is not an object", () => {
|
||||
expect(parseOperationInput(fields, "nope").errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("redacts secrets and sensitive outputs", () => {
|
||||
expect(
|
||||
redactFields(
|
||||
[
|
||||
{ key: "token", type: "secret" },
|
||||
{ key: "conf", type: "textarea", sensitive: true },
|
||||
{ key: "port", type: "number" },
|
||||
],
|
||||
{ token: "abc", conf: "key", port: 1 },
|
||||
),
|
||||
).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 });
|
||||
});
|
||||
|
||||
it("builds JSON Schema", () => {
|
||||
const schema = toJsonSchema([
|
||||
{ key: "port", type: "number", required: true, min: 1 },
|
||||
{ key: "token", type: "secret" },
|
||||
]) as { properties: Record<string, unknown>; required: string[] };
|
||||
expect(schema.required).toEqual(["port"]);
|
||||
expect(schema.properties.port).toEqual({ type: "number", minimum: 1 });
|
||||
expect(schema.properties.token).toEqual({
|
||||
type: "string",
|
||||
writeOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps AI and MCP opt in, the rest opt out", () => {
|
||||
expect(onSurface({}, "assistant")).toBe(false);
|
||||
expect(onSurface({}, "mcp")).toBe(false);
|
||||
expect(onSurface({}, "cli")).toBe(true);
|
||||
expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches invokes patterns", () => {
|
||||
expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true);
|
||||
expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false);
|
||||
expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false);
|
||||
expect(invokeAllowed(["*"], "anything.at-all")).toBe(true);
|
||||
expect(invokeAllowed(undefined, "docker.stop")).toBe(false);
|
||||
});
|
||||
|
||||
it("tests when against a host", () => {
|
||||
const when = { settings: { enableDocker: true }, os: ["linux"] };
|
||||
expect(
|
||||
matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }),
|
||||
).toBe(true);
|
||||
expect(matchesWhen(when, { settings: {} })).toBe(false);
|
||||
expect(
|
||||
matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }),
|
||||
).toBe(false);
|
||||
expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user