545 lines
19 KiB
TypeScript
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();
|
|
});
|
|
});
|