mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: add a permission-gated backend service registry for plugins
This commit is contained in:
1 parent
ee2d6b4697
commit
225f618337
17 files changed
+1755
-2
No files matched your search
@@ -53,6 +53,103 @@ describe("plugin manifest validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("provides / requires", () => {
|
||||
const permissionGroup = {
|
||||
group: "testplugin",
|
||||
permissions: ["testplugin.greet.use"],
|
||||
};
|
||||
|
||||
it("accepts a declared service and requirement", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
provides: [
|
||||
{
|
||||
service: "testplugin.greet",
|
||||
version: "1.0.0",
|
||||
permission: "testplugin.greet.use",
|
||||
},
|
||||
],
|
||||
requires: [
|
||||
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
|
||||
],
|
||||
contributes: { permissionGroup },
|
||||
}),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a service name that is not dotted", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-semver service version", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a duplicate provided service", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
provides: [
|
||||
{ service: "a.b", version: "1.0.0", permission: "x.y" },
|
||||
{ service: "a.b", version: "2.0.0", permission: "x.y" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid semver range", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
requires: [{ service: "a.b", versionRange: "not a range" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-boolean optional", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
requires: [
|
||||
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("optional"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a service gated by a permission the group never declares", () => {
|
||||
// Otherwise every call through it would deny against a permission no
|
||||
// admin could ever grant.
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({
|
||||
provides: [
|
||||
{
|
||||
service: "testplugin.greet",
|
||||
version: "1.0.0",
|
||||
permission: "testplugin.undeclared",
|
||||
},
|
||||
],
|
||||
contributes: { permissionGroup },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors[0]).toContain("testplugin.undeclared");
|
||||
expect(errors[0]).toContain("permissionGroup");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown permission", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({ permissions: ["credentials.read"] }),
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Two real plugins on disk, one providing a service and one requiring it,
|
||||
* driven through the actual PluginLoader.
|
||||
*
|
||||
* The ids are the real first-party ones because the in-process allowlist is
|
||||
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
|
||||
* does. The service ("testplugin.greet") and the permission gating it
|
||||
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
|
||||
* ssh-terminal or docker plugins is under test here; only their ids are
|
||||
* borrowed to reach the in-process tier.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
granted: new Set<string>(),
|
||||
auditEntries: [] as Record<string, unknown>[],
|
||||
/** Records every delegated call, to prove the gate runs before the provider. */
|
||||
providerCalls: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async (params: Record<string, unknown>) => {
|
||||
state.auditEntries.push(params);
|
||||
}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.granted.has(permission),
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const { TRANSPORT_OWNER_CAPABILITY } =
|
||||
await import("../../plugins/first-party.js");
|
||||
const {
|
||||
clearServiceRegistry,
|
||||
getRegistration,
|
||||
PluginServicePermissionError,
|
||||
PluginServiceUnavailableError,
|
||||
} = await import("../../plugins/service-registry.js");
|
||||
const { pluginEvents } = await import("../../plugins/events.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
|
||||
const SERVICE = "testplugin.greet";
|
||||
const PERMISSION = "testplugin.greet.use";
|
||||
|
||||
/**
|
||||
* The provider publishes a greet service and records every delegated call to
|
||||
* a file, so the test can prove a denied call never reached it. A module-level
|
||||
* variable would not work: the plugin is imported by URL into this process and
|
||||
* lives in its own ESM module instance.
|
||||
*/
|
||||
function providerSource(callLog: string): string {
|
||||
const literal = JSON.stringify(callLog);
|
||||
return `
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.services.provide(${JSON.stringify(SERVICE)}, {
|
||||
hello: async (name) => {
|
||||
fs.appendFileSync(${literal}, name + "\\n");
|
||||
return "hello " + name;
|
||||
},
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer takes a handle at activation and stashes it on globalThis so
|
||||
* the test can call it as a specific user afterwards.
|
||||
*/
|
||||
const CONSUMER_SOURCE = `
|
||||
export async function activate(ctx) {
|
||||
globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)});
|
||||
}
|
||||
`;
|
||||
|
||||
function providerFixture(): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "ssh-terminal",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
provides: [
|
||||
{ service: SERVICE, version: "1.2.0", permission: PERMISSION },
|
||||
],
|
||||
contributes: {
|
||||
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function consumerFixture(
|
||||
requires: Array<Record<string, unknown>> = [
|
||||
{ service: SERVICE, versionRange: "^1.2.0" },
|
||||
],
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "docker",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requires },
|
||||
});
|
||||
}
|
||||
|
||||
type GreetHandle = {
|
||||
hello: (name: string) => Promise<string>;
|
||||
asUser: (userId: string) => { hello: (name: string) => Promise<string> };
|
||||
};
|
||||
|
||||
function handle(): GreetHandle {
|
||||
return (globalThis as Record<string, unknown>)
|
||||
.__consumerHandle as GreetHandle;
|
||||
}
|
||||
|
||||
describe("plugin service contracts", () => {
|
||||
let provider: Fixture | null = null;
|
||||
let consumer: Fixture | null = null;
|
||||
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||
let callLog = "";
|
||||
|
||||
beforeEach(() => {
|
||||
state.granted = new Set([PERMISSION]);
|
||||
state.auditEntries = [];
|
||||
state.providerCalls = [];
|
||||
clearServiceRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
loader = null;
|
||||
provider?.cleanup();
|
||||
consumer?.cleanup();
|
||||
provider = null;
|
||||
consumer = null;
|
||||
delete (globalThis as Record<string, unknown>).__consumerHandle;
|
||||
clearServiceRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
async function activateBoth(
|
||||
consumerRequires?: Array<Record<string, unknown>>,
|
||||
) {
|
||||
provider = providerFixture();
|
||||
callLog = path.join(provider.root, "calls.txt");
|
||||
fs.writeFileSync(
|
||||
path.join(provider.dir, "backend", "index.mjs"),
|
||||
providerSource(callLog),
|
||||
);
|
||||
|
||||
consumer = consumerRequires
|
||||
? consumerFixture(consumerRequires)
|
||||
: consumerFixture();
|
||||
|
||||
loader = new PluginLoader();
|
||||
const providerPlugin = await loader.load(provider.dir);
|
||||
await loader.activate(providerPlugin.id);
|
||||
|
||||
const consumerPlugin = await loader.load(consumer.dir);
|
||||
await loader.activate(consumerPlugin.id);
|
||||
|
||||
return { providerPlugin, consumerPlugin };
|
||||
}
|
||||
|
||||
function providerCallCount(): number {
|
||||
if (!fs.existsSync(callLog)) return 0;
|
||||
return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
|
||||
.length;
|
||||
}
|
||||
|
||||
it("resolves structurally when both plugins are active", async () => {
|
||||
const { providerPlugin, consumerPlugin } = await activateBoth();
|
||||
|
||||
expect(providerPlugin.state).toBe("active");
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
const registration = getRegistration(SERVICE);
|
||||
expect(registration?.version).toBe("1.2.0");
|
||||
expect(registration?.pluginId).toBe("ssh-terminal");
|
||||
expect(registration?.permission).toBe(PERMISSION);
|
||||
});
|
||||
|
||||
it("fails activation when a hard requirement is unsatisfied", async () => {
|
||||
consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]);
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
|
||||
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||
/no active plugin provides|provides 1\.2\.0/,
|
||||
);
|
||||
expect(plugin.state).toBe("crashed");
|
||||
});
|
||||
|
||||
it("activates anyway when an unsatisfied requirement is optional", async () => {
|
||||
consumer = consumerFixture([
|
||||
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
|
||||
]);
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
await loader.activate(plugin.id);
|
||||
|
||||
expect(plugin.state).toBe("active");
|
||||
});
|
||||
|
||||
it("allows a call from a user who holds the permission", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(handle().asUser("alice").hello("world")).resolves.toBe(
|
||||
"hello world",
|
||||
);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry?.success).toBe(true);
|
||||
expect(entry?.username).toBe("plugin:docker");
|
||||
expect(entry?.userId).toBe("alice");
|
||||
expect(entry?.action).toBe("plugin_service_testplugin_greet_hello");
|
||||
expect(entry?.resourceId).toBe("ssh-terminal");
|
||||
});
|
||||
|
||||
it("denies a user without the permission, with the standard shape", async () => {
|
||||
await activateBoth();
|
||||
state.granted.delete(PERMISSION);
|
||||
|
||||
await handle()
|
||||
.asUser("mallory")
|
||||
.hello("world")
|
||||
.then(
|
||||
() => expect.unreachable("should have been denied"),
|
||||
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||
expect(error.name).toBe("PluginServicePermissionError");
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: PERMISSION,
|
||||
});
|
||||
expect(error.message).toContain("do not have access");
|
||||
},
|
||||
);
|
||||
|
||||
// The gate runs before delegation: the provider never saw the call.
|
||||
expect(providerCallCount()).toBe(0);
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry?.success).toBe(false);
|
||||
expect(entry?.userId).toBe("mallory");
|
||||
expect(String(entry?.errorMessage)).toContain(PERMISSION);
|
||||
});
|
||||
|
||||
it("honours a revoke mid-session on the very next call", async () => {
|
||||
await activateBoth();
|
||||
const live = handle().asUser("alice");
|
||||
|
||||
await expect(live.hello("first")).resolves.toBe("hello first");
|
||||
|
||||
// No restart, no reinstall, same handle: the check is per call, which is
|
||||
// exactly why it lives in the proxy rather than at handle creation.
|
||||
state.granted.delete(PERMISSION);
|
||||
|
||||
await expect(live.hello("second")).rejects.toThrow(
|
||||
PluginServicePermissionError,
|
||||
);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
|
||||
// And granting it back takes effect just as immediately.
|
||||
state.granted.add(PERMISSION);
|
||||
await expect(live.hello("third")).resolves.toBe("hello third");
|
||||
expect(providerCallCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => {
|
||||
const { consumerPlugin } = await activateBoth();
|
||||
const live = handle().asUser("alice");
|
||||
|
||||
await expect(live.hello("before")).resolves.toBe("hello before");
|
||||
|
||||
await loader!.deactivate("ssh-terminal");
|
||||
|
||||
expect(getRegistration(SERVICE)).toBeUndefined();
|
||||
|
||||
// Documented behaviour: the consumer stays active and decides for itself
|
||||
// what to do. Deactivating one plugin must not silently stop others.
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await live.hello("after");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(PluginServiceUnavailableError);
|
||||
expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE");
|
||||
expect((thrown as Error).message).toContain(SERVICE);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses to provide a service the manifest never declared", async () => {
|
||||
provider = createFixturePlugin({
|
||||
id: "ssh-terminal",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
backendSource: `
|
||||
export async function activate(ctx) {
|
||||
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
|
||||
}
|
||||
`,
|
||||
manifestOverrides: { category: "Terminal" },
|
||||
});
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
|
||||
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||
/not declared in the manifest's provides array/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Unit coverage for the declared service registry: registration, versioned
|
||||
* structural resolution, and the per-call permission gate on a handle.
|
||||
*
|
||||
* The two-fixture end-to-end test lives in service-contract.test.ts.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
clearServiceRegistry,
|
||||
createServiceHandle,
|
||||
getRegistration,
|
||||
listServices,
|
||||
provideService,
|
||||
resolveRequirements,
|
||||
revokeService,
|
||||
PluginServiceActorError,
|
||||
PluginServicePermissionError,
|
||||
PluginServiceUnavailableError,
|
||||
} = await import("../../plugins/service-registry.js");
|
||||
|
||||
type Registration = ReturnType<typeof provideService>;
|
||||
|
||||
function register(
|
||||
overrides: Partial<Parameters<typeof provideService>[0]> = {},
|
||||
): Registration {
|
||||
return provideService({
|
||||
service: "testplugin.greet",
|
||||
version: "1.0.0",
|
||||
permission: "testplugin.greet.use",
|
||||
pluginId: "provider-plugin",
|
||||
pluginName: "Provider Plugin",
|
||||
implementation: { hello: async (name: string) => `hello ${name}` },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function manifestRequiring(
|
||||
requires: Array<{
|
||||
service: string;
|
||||
versionRange: string;
|
||||
optional?: boolean;
|
||||
}>,
|
||||
) {
|
||||
return { id: "consumer-plugin", requires } as never;
|
||||
}
|
||||
|
||||
describe("service registry", () => {
|
||||
beforeEach(() => clearServiceRegistry());
|
||||
afterEach(() => clearServiceRegistry());
|
||||
|
||||
it("round-trips a registration", () => {
|
||||
const registration = register();
|
||||
|
||||
expect(getRegistration("testplugin.greet")).toBe(registration);
|
||||
expect(listServices()).toHaveLength(1);
|
||||
expect(registration.generation).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("only revokes the registration that is actually current", () => {
|
||||
const first = register();
|
||||
const second = register();
|
||||
|
||||
// A crashed-and-restarted provider must not revoke the replacement its own
|
||||
// restart installed.
|
||||
expect(revokeService("testplugin.greet", first)).toBe(false);
|
||||
expect(getRegistration("testplugin.greet")).toBe(second);
|
||||
|
||||
expect(revokeService("testplugin.greet", second)).toBe(true);
|
||||
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a revoke of something never registered", () => {
|
||||
expect(revokeService("nothing.here")).toBe(false);
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("is satisfied when the version is in range", () => {
|
||||
register({ version: "1.2.0" });
|
||||
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.2.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(true);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails a hard requirement nothing provides", () => {
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(false);
|
||||
expect(resolution.errors[0]).toContain("no active plugin provides");
|
||||
});
|
||||
|
||||
it("fails when the provided version is out of range", () => {
|
||||
register({ version: "2.0.0" });
|
||||
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(false);
|
||||
expect(resolution.errors[0]).toContain("provides 2.0.0");
|
||||
});
|
||||
|
||||
it("skips an unsatisfied optional requirement", () => {
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{
|
||||
service: "testplugin.greet",
|
||||
versionRange: "^1.0.0",
|
||||
optional: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(true);
|
||||
expect(resolution.missingOptional).toEqual(["testplugin.greet"]);
|
||||
});
|
||||
|
||||
it("treats a manifest with no requires as satisfied", () => {
|
||||
expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handle", () => {
|
||||
function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) {
|
||||
const userId = actor.length === 0 ? "user-1" : actor[0];
|
||||
const audits: Array<Record<string, unknown>> = [];
|
||||
const handle = createServiceHandle<{
|
||||
hello: (name: string) => Promise<string>;
|
||||
}>("testplugin.greet", "consumer-plugin", {
|
||||
resolveUserId: () => userId,
|
||||
hasPermission: async () => allowed,
|
||||
audit: (entry) => void audits.push(entry as never),
|
||||
});
|
||||
return { handle, audits };
|
||||
}
|
||||
|
||||
it("delegates to the implementation when permitted", async () => {
|
||||
register();
|
||||
const { handle, audits } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("denies without the permission and never reaches the provider", async () => {
|
||||
const hello = vi.fn(async () => "should not run");
|
||||
register({ implementation: { hello } });
|
||||
const { handle, audits } = handleFor(false);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow(
|
||||
PluginServicePermissionError,
|
||||
);
|
||||
// The gate has to run before delegation, not after.
|
||||
expect(hello).not.toHaveBeenCalled();
|
||||
expect(audits[0].success).toBe(false);
|
||||
});
|
||||
|
||||
it("denies with the same 403 shape requirePermission sends", async () => {
|
||||
register();
|
||||
const { handle } = handleFor(false);
|
||||
|
||||
await handle.hello("world").then(
|
||||
() => expect.unreachable("should have denied"),
|
||||
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: "testplugin.greet.use",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a call it cannot attribute to a user", async () => {
|
||||
register();
|
||||
const { handle } = handleFor(true, undefined);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow(
|
||||
PluginServiceActorError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws once the provider is revoked", async () => {
|
||||
const registration = register();
|
||||
const { handle } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||
|
||||
revokeService("testplugin.greet", registration);
|
||||
|
||||
expect(() => handle.hello("world")).toThrow(
|
||||
/no longer provided|is not a function/,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes only the functions the provider handed out", () => {
|
||||
register({
|
||||
implementation: {
|
||||
hello: async () => "hi",
|
||||
secret: "not a function",
|
||||
},
|
||||
});
|
||||
const { handle } = handleFor(true);
|
||||
|
||||
expect(typeof handle.hello).toBe("function");
|
||||
expect(
|
||||
(handle as unknown as Record<string, unknown>).secret,
|
||||
).toBeUndefined();
|
||||
expect(Object.keys(handle)).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("rebinds the acting user with asUser", async () => {
|
||||
register();
|
||||
const seen: string[] = [];
|
||||
const handle = createServiceHandle<{
|
||||
hello: (name: string) => Promise<string>;
|
||||
asUser: (userId: string) => { hello: (n: string) => Promise<string> };
|
||||
}>("testplugin.greet", "consumer-plugin", {
|
||||
resolveUserId: () => undefined,
|
||||
hasPermission: async (userId) => {
|
||||
seen.push(userId);
|
||||
return true;
|
||||
},
|
||||
audit: () => {},
|
||||
});
|
||||
|
||||
await handle.asUser("user-42").hello("world");
|
||||
expect(seen).toEqual(["user-42"]);
|
||||
});
|
||||
|
||||
it("surfaces an unavailable service rather than a generic undefined", () => {
|
||||
const { handle } = handleFor(true);
|
||||
expect(
|
||||
(handle as unknown as Record<string, unknown>).hello,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("audits a failure thrown by the provider itself", async () => {
|
||||
register({
|
||||
implementation: {
|
||||
hello: async () => {
|
||||
throw new Error("provider exploded");
|
||||
},
|
||||
},
|
||||
});
|
||||
const { handle, audits } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow("provider exploded");
|
||||
expect(audits[0].success).toBe(false);
|
||||
expect(audits[0].errorMessage).toBe("provider exploded");
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the unavailable error with a stable code", () => {
|
||||
const error = new PluginServiceUnavailableError("a.b", "provider");
|
||||
expect(error.code).toBe("EPLUGINSVCGONE");
|
||||
expect(error.message).toContain("a.b");
|
||||
expect(error.message).toContain("provider");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user