mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
/**
|
|
* Two real plugins on disk, one offering a secret and one borrowing it, driven
|
|
* through the actual PluginLoader.
|
|
*
|
|
* Same borrowed-id trick as service-contract.test.ts: the in-process allowlist
|
|
* is hardcoded and deliberately unfakeable, so the fixtures use real
|
|
* first-party ids to reach that tier. The secret ("api-key") and the
|
|
* permission gating it ("testplugin.secrets.share") are fixture-owned, so
|
|
* nothing about the shipped ssh-terminal or docker plugins is under test.
|
|
*
|
|
* What the unit test cannot show, and this does: that the manifest declaration
|
|
* is enforced by the real ctx, and that a genuine uninstall through
|
|
* loader.deactivate() leaves the borrower with null rather than a handle onto
|
|
* a resolver whose plugin is gone.
|
|
*/
|
|
|
|
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>[],
|
|
}));
|
|
|
|
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 {
|
|
clearSecretRegistry,
|
|
getSecretRegistration,
|
|
PluginSecretPermissionError,
|
|
PluginSecretUndeclaredError,
|
|
} = await import("../../plugins/secret-registry.js");
|
|
const { pluginEvents } = await import("../../plugins/events.js");
|
|
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
|
|
|
const PROVIDER_ID = "ssh-terminal";
|
|
const CONSUMER_ID = "docker";
|
|
const KEY = "api-key";
|
|
const PERMISSION = "testplugin.secrets.share";
|
|
const SECRET = "sk-fixture-value";
|
|
|
|
/**
|
|
* The provider offers a resolver reading the current value from a file, so the
|
|
* test can rotate or clear it from outside without restarting the plugin. A
|
|
* module-level variable would not work: the plugin is imported by URL and
|
|
* lives in its own ESM module instance.
|
|
*/
|
|
function providerSource(valueFile: string): string {
|
|
const literal = JSON.stringify(valueFile);
|
|
return `
|
|
import fs from "node:fs";
|
|
export async function activate(ctx) {
|
|
ctx.secrets.offer(${JSON.stringify(KEY)}, async () => {
|
|
if (!fs.existsSync(${literal})) return null;
|
|
const value = fs.readFileSync(${literal}, "utf8");
|
|
return value.length > 0 ? value : null;
|
|
});
|
|
}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* The consumer stashes a bound read on globalThis so the test can invoke it as
|
|
* a specific user afterwards. Note it stashes a FUNCTION, not a value: there
|
|
* is no handle to hold, which is the property under test.
|
|
*/
|
|
const CONSUMER_SOURCE = `
|
|
export async function activate(ctx) {
|
|
globalThis.__borrow = (userId) =>
|
|
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, ${JSON.stringify(KEY)}, { userId });
|
|
globalThis.__borrowUndeclared = (userId) =>
|
|
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, "not-declared", { userId });
|
|
}
|
|
`;
|
|
|
|
function providerFixture(): Fixture {
|
|
return createFixturePlugin({
|
|
id: PROVIDER_ID,
|
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
|
manifestOverrides: {
|
|
category: "Terminal",
|
|
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
|
contributes: {
|
|
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function consumerFixture(
|
|
requiresSecret: Array<Record<string, unknown>> = [
|
|
{ plugin: PROVIDER_ID, key: KEY, optional: true },
|
|
],
|
|
): Fixture {
|
|
return createFixturePlugin({
|
|
id: CONSUMER_ID,
|
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
|
backendSource: CONSUMER_SOURCE,
|
|
manifestOverrides: { category: "Infrastructure", requiresSecret },
|
|
});
|
|
}
|
|
|
|
function borrow(userId = "user-1"): Promise<string | null> {
|
|
const fn = (globalThis as Record<string, unknown>).__borrow as (
|
|
id: string,
|
|
) => Promise<string | null>;
|
|
return fn(userId);
|
|
}
|
|
|
|
function borrowUndeclared(userId = "user-1"): Promise<string | null> {
|
|
const fn = (globalThis as Record<string, unknown>).__borrowUndeclared as (
|
|
id: string,
|
|
) => Promise<string | null>;
|
|
return fn(userId);
|
|
}
|
|
|
|
describe("cross-plugin secret references", () => {
|
|
let provider: Fixture | null = null;
|
|
let consumer: Fixture | null = null;
|
|
let loader: InstanceType<typeof PluginLoader> | null = null;
|
|
let valueFile = "";
|
|
|
|
beforeEach(() => {
|
|
state.granted = new Set([PERMISSION]);
|
|
state.auditEntries = [];
|
|
clearSecretRegistry();
|
|
pluginEvents.clear();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await loader?.shutdown();
|
|
loader = null;
|
|
provider?.cleanup();
|
|
consumer?.cleanup();
|
|
provider = null;
|
|
consumer = null;
|
|
delete (globalThis as Record<string, unknown>).__borrow;
|
|
delete (globalThis as Record<string, unknown>).__borrowUndeclared;
|
|
clearSecretRegistry();
|
|
pluginEvents.clear();
|
|
});
|
|
|
|
async function activateProvider() {
|
|
provider = providerFixture();
|
|
valueFile = path.join(provider.root, "secret.txt");
|
|
fs.writeFileSync(valueFile, SECRET);
|
|
fs.writeFileSync(
|
|
path.join(provider.dir, "backend", "index.mjs"),
|
|
providerSource(valueFile),
|
|
);
|
|
|
|
loader ??= new PluginLoader();
|
|
const plugin = await loader.load(provider.dir);
|
|
await loader.activate(plugin.id);
|
|
return plugin;
|
|
}
|
|
|
|
async function activateConsumer(
|
|
requiresSecret?: Array<Record<string, unknown>>,
|
|
) {
|
|
consumer = requiresSecret
|
|
? consumerFixture(requiresSecret)
|
|
: consumerFixture();
|
|
|
|
loader ??= new PluginLoader();
|
|
const plugin = await loader.load(consumer.dir);
|
|
await loader.activate(plugin.id);
|
|
return plugin;
|
|
}
|
|
|
|
async function activateBoth() {
|
|
loader = new PluginLoader();
|
|
const providerPlugin = await activateProvider();
|
|
const consumerPlugin = await activateConsumer();
|
|
return { providerPlugin, consumerPlugin };
|
|
}
|
|
|
|
it("registers the offer when the provider activates", async () => {
|
|
const { providerPlugin, consumerPlugin } = await activateBoth();
|
|
|
|
expect(providerPlugin.state).toBe("active");
|
|
expect(consumerPlugin.state).toBe("active");
|
|
|
|
const registration = getSecretRegistration(PROVIDER_ID, KEY);
|
|
expect(registration?.pluginId).toBe(PROVIDER_ID);
|
|
expect(registration?.permission).toBe(PERMISSION);
|
|
});
|
|
|
|
it("resolves for a user who holds the permission", async () => {
|
|
await activateBoth();
|
|
|
|
await expect(borrow()).resolves.toBe(SECRET);
|
|
});
|
|
|
|
it("denies a user without the permission, though the secret is set", async () => {
|
|
await activateBoth();
|
|
state.granted = new Set();
|
|
|
|
await expect(borrow("user-2")).rejects.toBeInstanceOf(
|
|
PluginSecretPermissionError,
|
|
);
|
|
// Still installed, still set: the difference is purely this user's role.
|
|
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeDefined();
|
|
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
|
});
|
|
|
|
it("lets one user borrow while another is denied, with no reinstall", async () => {
|
|
await activateBoth();
|
|
|
|
// The grant is per-user, so both answers come from the same live install.
|
|
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
|
|
|
state.granted = new Set();
|
|
await expect(borrow("denied-user")).rejects.toBeInstanceOf(
|
|
PluginSecretPermissionError,
|
|
);
|
|
|
|
state.granted = new Set([PERMISSION]);
|
|
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
|
});
|
|
|
|
it("returns null when the provider is installed but the key is not set", async () => {
|
|
await activateBoth();
|
|
fs.writeFileSync(valueFile, "");
|
|
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
|
|
it("returns null when the providing plugin was never installed", async () => {
|
|
loader = new PluginLoader();
|
|
await activateConsumer();
|
|
|
|
// The consumer activates regardless: the reference is resolved per call.
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
|
|
it("activates the consumer even when a non-optional reference is absent", async () => {
|
|
loader = new PluginLoader();
|
|
const plugin = await activateConsumer([{ plugin: PROVIDER_ID, key: KEY }]);
|
|
|
|
// Unlike a service requirement, an absent secret provider is recoverable
|
|
// by installing it later, so it must not be a hard ordering dependency.
|
|
expect(plugin.state).toBe("active");
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
|
|
it("refuses a read the consumer's manifest never declared", async () => {
|
|
await activateBoth();
|
|
|
|
await expect(borrowUndeclared()).rejects.toBeInstanceOf(
|
|
PluginSecretUndeclaredError,
|
|
);
|
|
});
|
|
|
|
it("refuses to offer a secret the provider's manifest never declared", async () => {
|
|
provider = createFixturePlugin({
|
|
id: PROVIDER_ID,
|
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
|
backendSource: `
|
|
export async function activate(ctx) {
|
|
ctx.secrets.offer("undeclared-key", () => "nope");
|
|
}
|
|
`,
|
|
manifestOverrides: {
|
|
category: "Terminal",
|
|
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
|
contributes: {
|
|
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
|
},
|
|
},
|
|
});
|
|
|
|
loader = new PluginLoader();
|
|
const plugin = await loader.load(provider.dir);
|
|
await loader.activate(plugin.id).catch(() => undefined);
|
|
|
|
expect(plugin.state).not.toBe("active");
|
|
expect(
|
|
getSecretRegistration(PROVIDER_ID, "undeclared-key"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
describe("the provider stays in control", () => {
|
|
it("serves a rotated value on the next borrow", async () => {
|
|
await activateBoth();
|
|
await expect(borrow()).resolves.toBe(SECRET);
|
|
|
|
fs.writeFileSync(valueFile, "sk-fixture-rotated");
|
|
|
|
// Nothing to invalidate: the borrower never held a copy to go stale.
|
|
await expect(borrow()).resolves.toBe("sk-fixture-rotated");
|
|
});
|
|
|
|
it("nulls the borrow out when the provider clears the secret", async () => {
|
|
await activateBoth();
|
|
await expect(borrow()).resolves.toBe(SECRET);
|
|
|
|
fs.rmSync(valueFile);
|
|
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("uninstalling the provider", () => {
|
|
it("nulls out the reference rather than leaving a dangling handle", async () => {
|
|
const { providerPlugin } = await activateBoth();
|
|
await expect(borrow()).resolves.toBe(SECRET);
|
|
|
|
await loader!.deactivate(providerPlugin.id);
|
|
|
|
// The borrower is untouched: still active, still permitted, still
|
|
// declaring the reference. It just gets null now, and does not throw.
|
|
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeUndefined();
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
|
|
it("keeps serving nothing even though the underlying value still exists", async () => {
|
|
const { providerPlugin } = await activateBoth();
|
|
await loader!.deactivate(providerPlugin.id);
|
|
|
|
// The file is still on disk. Access went away with the plugin that
|
|
// mediated it, which is the point: there was never a copy to fall back on.
|
|
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
|
await expect(borrow()).resolves.toBeNull();
|
|
});
|
|
|
|
it("resumes when the provider is reinstalled, with no borrower restart", async () => {
|
|
const { providerPlugin } = await activateBoth();
|
|
await loader!.deactivate(providerPlugin.id);
|
|
await expect(borrow()).resolves.toBeNull();
|
|
|
|
await loader!.activate(providerPlugin.id);
|
|
|
|
await expect(borrow()).resolves.toBe(SECRET);
|
|
});
|
|
});
|
|
|
|
describe("audit", () => {
|
|
it("records a borrow without the value", async () => {
|
|
await activateBoth();
|
|
await borrow("user-9");
|
|
|
|
const entry = state.auditEntries.at(-1);
|
|
expect(entry).toMatchObject({
|
|
userId: "user-9",
|
|
username: `plugin:${CONSUMER_ID}`,
|
|
action: "plugin_secret_shared_read",
|
|
resourceId: PROVIDER_ID,
|
|
success: true,
|
|
});
|
|
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
|
});
|
|
|
|
it("records a denial without the value", async () => {
|
|
await activateBoth();
|
|
state.granted = new Set();
|
|
await borrow().catch(() => undefined);
|
|
|
|
expect(state.auditEntries.at(-1)).toMatchObject({ success: false });
|
|
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
|
});
|
|
});
|
|
});
|