mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45: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,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user