Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/services/.server/PluginLifecycleService.capability-grants.test.ts
T

545 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const pluginFindUniqueMock = vi.fn();
const grantUpsertMock = vi.fn();
const grantFindManyMock = vi.fn();
const grantDeleteManyMock = vi.fn();
const pluginFindFirstMock = vi.fn();
const pluginUpdateMock = vi.fn();
const transactionMock = vi.fn();
const auditMock = vi.fn();
const auditOutboxMock = vi.fn();
vi.mock("~/utils/db.server", () => ({
db: {
plugin: {
findUnique: (...args: unknown[]) => pluginFindUniqueMock(...args),
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
update: (...args: unknown[]) => pluginUpdateMock(...args),
},
$transaction: (...args: unknown[]) => transactionMock(...args),
tenantPluginCapabilityGrant: {
upsert: (...args: unknown[]) => grantUpsertMock(...args),
findMany: (...args: unknown[]) => grantFindManyMock(...args),
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
},
},
}));
vi.mock("@toolctrl/db/audit-events.server", () => ({
createAuditEvent: (...args: unknown[]) => auditMock(...args),
publishAuditEventViaOutbox: (...args: unknown[]) => auditOutboxMock(...args),
}));
vi.mock("~/utils/logger.server", () => ({
createScopedLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
const actor = { userId: "u-1", email: "admin@example.com", ip: null };
const originalNodeEnv = process.env.NODE_ENV;
const manifest = {
schemaVersion: 1,
id: "com.acme.outbound",
version: "1.0.0",
name: "Outbound",
entry: "plugin.wasm",
wasmSha256: "0".repeat(64),
signature: "0".repeat(128),
signedBy: "0".repeat(64),
allowedHosts: [],
hooks: [{ name: "tenant.created", export: "handleTenantCreated" }],
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }, { name: "log" }],
};
beforeEach(() => {
pluginFindUniqueMock.mockReset();
grantUpsertMock.mockReset();
grantFindManyMock.mockReset();
grantDeleteManyMock.mockReset();
pluginFindFirstMock.mockReset();
pluginUpdateMock.mockReset();
transactionMock.mockReset();
auditMock.mockReset();
auditOutboxMock.mockReset();
transactionMock.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
plugin: {
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
update: (...args: unknown[]) => pluginUpdateMock(...args),
},
tenantPluginCapabilityGrant: {
findMany: (...args: unknown[]) => grantFindManyMock(...args),
upsert: (...args: unknown[]) => grantUpsertMock(...args),
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
},
})
);
auditOutboxMock.mockResolvedValue(undefined);
});
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
});
describe("plugin lifecycle capability grants", () => {
test("accepts grants whose scope matches or narrows the manifest", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
grantUpsertMock.mockImplementation(async ({ create }) => ({
id: "grant-1",
tenantId: create.tenantId,
pluginId: create.pluginId,
capabilityName: create.capabilityName,
scopeJson: create.scopeJson,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: create.grantedByUserId,
}));
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
actor,
})
).resolves.toHaveLength(1);
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.granted",
targetType: "plugin",
targetId: "row-1",
sourceModule: "plugin-lifecycle",
outcome: "success",
metadata: expect.objectContaining({
pluginId: manifest.id,
capabilityReviewDigest: null,
grantedCapabilities: ["http.outbound"],
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
manifestCapabilities: [
expect.objectContaining({
capabilityName: "http.outbound",
declaredScope: { allowedHosts: ["api.example.com"] },
kind: "capability",
}),
],
}),
})
);
});
test("rejects grants that broaden manifest scope", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects unavailable host capability grants in production", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
actor,
})
).rejects.toMatchObject({ code: "capability_unavailable" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects malformed or invented scope for unscoped capabilities", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "log", scopeJson: { tablePrefix: "tenant_" } }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "log", scopeJson: "all" }],
actor,
})
).rejects.toMatchObject({ code: "capability_scope_invalid" });
});
test("does not treat stale grants as sufficient after manifest scope changes", async () => {
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest: {
...manifest,
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com", "api2.example.com"] } }],
},
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "http.outbound",
scopeJson: { allowedHosts: ["api.example.com", "internal.example"] },
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_consent_missing",
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("requires manifest hook consent before enabling hook-only plugins", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest: hookOnlyManifest,
});
grantFindManyMock.mockResolvedValue([]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_consent_missing",
message: expect.stringContaining("hook:workflow.executed"),
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("allows hook consent grants declared by the manifest", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookOnlyManifest });
grantUpsertMock.mockImplementation(async ({ create }) => ({
id: "grant-1",
tenantId: create.tenantId,
pluginId: create.pluginId,
capabilityName: create.capabilityName,
scopeJson: create.scopeJson,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: create.grantedByUserId,
}));
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:workflow.executed", scopeJson: null }],
actor,
})
).resolves.toHaveLength(1);
expect(grantUpsertMock).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ capabilityName: "hook:workflow.executed" }) }));
});
test("rejects production hook consent grants for registered hooks outside the production allowlist", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
const { registerHook } = await import("@toolctrl/plugin-runtime");
const { z } = await import("zod");
registerHook({
name: "billing.refunded",
outboxEventType: "plugin.hook.billing.refunded",
payloadSchema: z.object({ invoiceId: z.string() }),
});
const hookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "billing.refunded", export: "onBillingRefunded" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookManifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:billing.refunded", scopeJson: null }],
actor,
})
).rejects.toMatchObject({ code: "hook_unavailable" });
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("rejects hook consent grants for hooks not registered by the host", async () => {
const unregisteredHookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
};
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: unregisteredHookManifest });
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
await expect(
grantPluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
grants: [{ capabilityName: "hook:workflow.finished", scopeJson: null }],
actor,
})
).rejects.toMatchObject({
code: "manifest_invalid",
message: expect.stringContaining("workflow.finished"),
});
expect(grantUpsertMock).not.toHaveBeenCalled();
});
test("enables hook-only plugins after hook consent is granted", async () => {
const hookOnlyManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: hookOnlyManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:workflow.executed",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
pluginUpdateMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: true,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
manifest: hookOnlyManifest,
});
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).resolves.toMatchObject({ enabled: true });
expect(pluginUpdateMock).toHaveBeenCalledWith({ where: { id: "row-1" }, data: { enabled: true } });
});
test("rejects enable for installed plugins with unregistered manifest hooks even when consent exists", async () => {
const unregisteredHookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: unregisteredHookManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:workflow.finished",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "manifest_invalid",
message: expect.stringContaining("workflow.finished"),
});
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("rejects enable for registered hooks outside the production allowlist", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
const { registerHook } = await import("@toolctrl/plugin-runtime");
const { z } = await import("zod");
registerHook({
name: "billing.failed",
outboxEventType: "plugin.hook.billing.failed",
payloadSchema: z.object({ invoiceId: z.string() }),
});
const hookManifest = {
...manifest,
capabilities: [],
hooks: [{ name: "billing.failed", export: "onBillingFailed" }],
};
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
tenantId: "t-1",
pluginId: manifest.id,
version: "1.0.0",
enabled: false,
installedAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
manifest: hookManifest,
});
grantFindManyMock.mockResolvedValue([
{
id: "grant-1",
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: "hook:billing.failed",
scopeJson: null,
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
grantedByUserId: "u-1",
},
]);
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "hook_unavailable",
});
expect(grantFindManyMock).not.toHaveBeenCalled();
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("rejects unavailable host capabilities before enabling in production", async () => {
process.env.NODE_ENV = "production";
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
pluginFindFirstMock.mockResolvedValue({
id: "row-1",
pluginId: manifest.id,
manifest,
});
const { enablePlugin } = await import("./PluginLifecycleService");
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
code: "capability_unavailable",
});
expect(grantFindManyMock).not.toHaveBeenCalled();
expect(pluginUpdateMock).not.toHaveBeenCalled();
});
test("revokes selected grants without disabling an already disabled plugin", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
grantDeleteManyMock.mockResolvedValue({ count: 1 });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["log"],
actor,
})
).resolves.toEqual({ revokedCapabilities: ["log"], autoDisabled: false });
expect(pluginUpdateMock).not.toHaveBeenCalled();
expect(grantDeleteManyMock).toHaveBeenCalledWith({
where: {
tenantId: "t-1",
pluginId: manifest.id,
capabilityName: { in: ["log"] },
},
});
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.revoked",
metadata: { pluginId: manifest.id, revokedCapabilities: ["log"], autoDisabled: false },
})
);
});
test("auto-disables enabled plugins before revoking grants", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
pluginUpdateMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
grantDeleteManyMock.mockResolvedValue({ count: 1 });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["http.outbound"],
actor,
})
).resolves.toEqual({ revokedCapabilities: ["http.outbound"], autoDisabled: true });
expect(pluginUpdateMock).toHaveBeenCalledWith({
where: { id: "row-1" },
data: { enabled: false },
});
expect(grantDeleteManyMock).toHaveBeenCalledOnce();
expect(transactionMock).toHaveBeenCalledOnce();
expect(auditOutboxMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "plugin.capabilities.revoked",
metadata: { pluginId: manifest.id, revokedCapabilities: ["http.outbound"], autoDisabled: true },
})
);
});
test("rejects revoke requests for capabilities not declared in the manifest", async () => {
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
await expect(
revokePluginCapabilities({
tenantId: "t-1",
pluginId: manifest.id,
capabilityNames: ["db.write"],
actor,
})
).rejects.toMatchObject({ code: "capability_not_in_manifest" });
expect(pluginUpdateMock).not.toHaveBeenCalled();
expect(grantDeleteManyMock).not.toHaveBeenCalled();
});
});