mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
/**
|
|
* 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,
|
|
getServiceImplementation,
|
|
listProviderNames,
|
|
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);
|
|
});
|
|
|
|
it("hands core the implementation of a compatible version, or nothing", () => {
|
|
expect(
|
|
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
|
).toBeUndefined();
|
|
const registration = register({ version: "1.4.0" });
|
|
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
|
|
registration.implementation,
|
|
);
|
|
expect(
|
|
getServiceImplementation("testplugin.greet", "^2.0.0"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
describe("named providers", () => {
|
|
it("keeps several providers of one service side by side", () => {
|
|
const ssh = register({
|
|
name: "ssh",
|
|
implementation: { kind: async () => "ssh" },
|
|
});
|
|
const rdp = register({
|
|
name: "rdp",
|
|
pluginId: "other-plugin",
|
|
implementation: { kind: async () => "rdp" },
|
|
});
|
|
|
|
expect(listProviderNames("testplugin.greet").sort()).toEqual([
|
|
"rdp",
|
|
"ssh",
|
|
]);
|
|
expect(getRegistration("testplugin.greet", "ssh")).toBe(ssh);
|
|
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
|
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
|
expect(listServices()).toHaveLength(2);
|
|
});
|
|
|
|
it("hands core a named implementation only by its name", () => {
|
|
const ssh = register({ name: "ssh" });
|
|
expect(
|
|
getServiceImplementation("testplugin.greet", "^1.0.0", "ssh"),
|
|
).toBe(ssh.implementation);
|
|
expect(
|
|
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("revokes one provider and leaves the other", () => {
|
|
const ssh = register({ name: "ssh" });
|
|
const rdp = register({ name: "rdp" });
|
|
|
|
expect(revokeService("testplugin.greet", ssh)).toBe(true);
|
|
expect(getRegistration("testplugin.greet", "ssh")).toBeUndefined();
|
|
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
|
});
|
|
|
|
it("routes a named handle to its provider and fails typed once it goes", async () => {
|
|
const ssh = register({
|
|
name: "ssh",
|
|
implementation: { hello: async () => "from ssh" },
|
|
});
|
|
register({
|
|
name: "rdp",
|
|
implementation: { hello: async () => "from rdp" },
|
|
});
|
|
const context = {
|
|
resolveUserId: () => "user-1",
|
|
hasPermission: async () => true,
|
|
audit: () => {},
|
|
};
|
|
const handle = createServiceHandle<{ hello: () => Promise<string> }>(
|
|
"testplugin.greet",
|
|
"consumer-plugin",
|
|
context,
|
|
"ssh",
|
|
);
|
|
const unnamed = createServiceHandle<{ hello?: () => Promise<string> }>(
|
|
"testplugin.greet",
|
|
"consumer-plugin",
|
|
context,
|
|
);
|
|
|
|
await expect(handle.hello()).resolves.toBe("from ssh");
|
|
expect("hello" in unnamed).toBe(false);
|
|
|
|
revokeService("testplugin.greet", ssh);
|
|
expect(() => handle.hello()).toThrow(PluginServiceUnavailableError);
|
|
});
|
|
|
|
it("satisfies a requirement with any compatible provider", () => {
|
|
register({ name: "rdp", version: "2.0.0" });
|
|
register({ name: "ssh", version: "1.2.0" });
|
|
|
|
expect(
|
|
resolveRequirements(
|
|
manifestRequiring([
|
|
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
|
]),
|
|
).satisfied,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
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("runs the provider as the user the permission was checked for", async () => {
|
|
const { getActor } = await import("../../plugins/actor.js");
|
|
register({
|
|
implementation: { hello: async () => getActor() ?? "nobody" },
|
|
});
|
|
const { handle } = handleFor(true, "user-7");
|
|
|
|
await expect(handle.hello("x")).resolves.toBe("user-7");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|