Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
644 lines
21 KiB
TypeScript
644 lines
21 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { allowlistDrift, McpConnector } from "./mcp-connector.js";
|
|
import { type McpOAuthBroker, StoredMcpOAuthProvider } from "./mcp-oauth.js";
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
const SERVER = {
|
|
id: "server-1",
|
|
slug: "demo",
|
|
transport: "streamable_http",
|
|
endpoint: "https://mcp.example.test/mcp",
|
|
secretId: null,
|
|
args: [],
|
|
revision: 1,
|
|
};
|
|
|
|
const ASSIGNMENT = {
|
|
botId: "bot-1",
|
|
serverId: "server-1",
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
allowAllTools: true,
|
|
allowedTools: [],
|
|
server: SERVER,
|
|
};
|
|
|
|
const TEST_NETWORK = {
|
|
// Read the global per call so a fetch stubbed after construction still wins.
|
|
fetch: (input: string | URL | Request, init?: RequestInit) => globalThis.fetch(input, init),
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 }],
|
|
};
|
|
|
|
function logicalHref(input: string | URL | Request, init?: RequestInit): string {
|
|
const url = new URL(
|
|
typeof input === "string" || input instanceof URL ? String(input) : input.url,
|
|
);
|
|
const host = new Headers(input instanceof Request ? input.headers : init?.headers).get("host");
|
|
if (host) url.host = host;
|
|
return url.href;
|
|
}
|
|
|
|
function mcpFetch(
|
|
state: {
|
|
failNext: boolean;
|
|
initializations: number;
|
|
headers?: Record<string, string>[];
|
|
tools?: Array<{ name: string; description?: string; inputSchema: Record<string, unknown> }>;
|
|
calls?: string[];
|
|
},
|
|
expectedUrl = "https://mcp.example.test/mcp",
|
|
) {
|
|
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
const request = input instanceof Request ? input : new Request(input, init);
|
|
state.headers?.push(Object.fromEntries(request.headers.entries()));
|
|
if (logicalHref(input, init) !== expectedUrl)
|
|
throw new Error(`Unexpected request: ${logicalHref(input, init)}`);
|
|
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
if (state.failNext) return new Response("boom", { status: 500 });
|
|
const message = JSON.parse(await request.text()) as {
|
|
id?: number;
|
|
method?: string;
|
|
params?: { name?: string };
|
|
};
|
|
if (message.method === "initialize") {
|
|
state.initializations += 1;
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "test", version: "1" },
|
|
},
|
|
});
|
|
}
|
|
if (message.method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
tools: state.tools ?? [{ name: "echo", inputSchema: { type: "object" } }],
|
|
},
|
|
});
|
|
}
|
|
if (message.method === "tools/call") {
|
|
if (message.params?.name) state.calls?.push(message.params.name);
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: { content: [{ type: "text", text: "ok" }] },
|
|
});
|
|
}
|
|
return new Response(null, { status: 202 });
|
|
});
|
|
}
|
|
|
|
describe("MCP connector session cache", () => {
|
|
it("keeps large MCP schemas out of the initial runtime tool catalog", async () => {
|
|
const state = {
|
|
failNext: false,
|
|
initializations: 0,
|
|
tools: Array.from({ length: 30 }, (_, index) => ({
|
|
name: `tool_${String(index).padStart(2, "0")}`,
|
|
description: `Tool ${index}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "string", description: `schema-marker-${index}` } },
|
|
required: ["value"],
|
|
},
|
|
})),
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const prisma = {
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never, {
|
|
network: TEST_NETWORK,
|
|
});
|
|
const context = {
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
|
|
const tools = await connector.discoverTools(context);
|
|
|
|
expect(tools).toHaveLength(3);
|
|
expect(tools.map((tool) => tool.name)).toEqual([
|
|
"connectors_search_tools",
|
|
"connectors_load_tool",
|
|
"connectors_execute_tool",
|
|
]);
|
|
// Anthropic rejects Claude Code OAuth requests carrying any `mcp_`-prefixed tool name.
|
|
expect(tools.every((tool) => !tool.name.startsWith("mcp_"))).toBe(true);
|
|
expect(JSON.stringify(tools)).not.toContain("schema-marker");
|
|
await connector.close();
|
|
});
|
|
|
|
it("exposes 20 MCP tools directly and switches at 21", async () => {
|
|
const context = {
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
for (const count of [20, 21]) {
|
|
const state = {
|
|
failNext: false,
|
|
initializations: 0,
|
|
tools: Array.from({ length: count }, (_, index) => ({
|
|
name: `tool_${index}`,
|
|
inputSchema: { type: "object" },
|
|
})),
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const connector = new McpConnector(
|
|
{
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
} as never,
|
|
{} as never,
|
|
{ network: TEST_NETWORK },
|
|
);
|
|
const tools = await connector.discoverTools(context);
|
|
if (count === 20) {
|
|
expect(tools).toHaveLength(20);
|
|
expect(tools[0]?.name).toMatch(/^mcp__demo__/);
|
|
} else {
|
|
expect(tools.map((tool) => tool.name)).toEqual([
|
|
"connectors_search_tools",
|
|
"connectors_load_tool",
|
|
"connectors_execute_tool",
|
|
]);
|
|
}
|
|
await connector.close();
|
|
}
|
|
});
|
|
|
|
it("returns no tools when the MCP catalog is empty", async () => {
|
|
const connector = new McpConnector(
|
|
{ botMcpServer: { findMany: vi.fn().mockResolvedValue([]) } } as never,
|
|
{} as never,
|
|
);
|
|
await expect(
|
|
connector.discoverTools({
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never),
|
|
).resolves.toEqual([]);
|
|
await connector.close();
|
|
});
|
|
|
|
it("searches, loads, validates, authorizes, and executes one large-catalog tool", async () => {
|
|
const state = {
|
|
failNext: false,
|
|
initializations: 0,
|
|
calls: [] as string[],
|
|
tools: Array.from({ length: 30 }, (_, index) => ({
|
|
name: `tool_${String(index).padStart(2, "0")}`,
|
|
description: index === 29 ? "Find the final report" : `Utility ${index}`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "string" } },
|
|
required: ["value"],
|
|
additionalProperties: false,
|
|
},
|
|
})),
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const prisma = {
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never, {
|
|
network: TEST_NETWORK,
|
|
});
|
|
const context = {
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
const [search, load, execute] = await connector.discoverTools(context);
|
|
const collect = async (call: Parameters<McpConnector["execute"]>[0]) => {
|
|
const events: unknown[] = [];
|
|
for await (const event of connector.execute(call as never, context)) events.push(event);
|
|
return events;
|
|
};
|
|
|
|
const searched = await collect({
|
|
tool: search!.name,
|
|
args: { query: "final report", limit: 2 },
|
|
executionId: "search",
|
|
route: search!.route,
|
|
});
|
|
expect(searched).toEqual([
|
|
{
|
|
type: "result",
|
|
data: {
|
|
tools: [
|
|
{
|
|
id: "server-1:tool_29",
|
|
name: "mcp__demo__tool_29",
|
|
description: "Find the final report",
|
|
readOnly: false,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
expect(JSON.stringify(searched)).not.toContain("inputSchema");
|
|
|
|
const indexed = await collect({
|
|
tool: search!.name,
|
|
args: {},
|
|
executionId: "index",
|
|
route: search!.route,
|
|
});
|
|
expect(indexed).toEqual([
|
|
{
|
|
type: "result",
|
|
data: {
|
|
index: [
|
|
{
|
|
group: "demo",
|
|
names: Array.from(
|
|
{ length: 30 },
|
|
(_, index) => `tool_${String(index).padStart(2, "0")}`,
|
|
),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
expect(JSON.stringify(indexed)).not.toContain("inputSchema");
|
|
expect(search!.description).toContain("demo:");
|
|
expect(search!.description).toContain("tool_00");
|
|
|
|
const expanded = await collect({
|
|
tool: search!.name,
|
|
args: { group: "demo" },
|
|
executionId: "group",
|
|
route: search!.route,
|
|
});
|
|
expect(expanded).toEqual([
|
|
{
|
|
type: "result",
|
|
data: {
|
|
group: "demo",
|
|
names: Array.from({ length: 30 }, (_, index) => `tool_${String(index).padStart(2, "0")}`),
|
|
},
|
|
},
|
|
]);
|
|
|
|
const loaded = await collect({
|
|
tool: load!.name,
|
|
args: { id: "server-1:tool_29" },
|
|
executionId: "load",
|
|
route: load!.route,
|
|
});
|
|
expect(loaded).toEqual([
|
|
expect.objectContaining({
|
|
type: "result",
|
|
data: expect.objectContaining({
|
|
id: "server-1:tool_29",
|
|
inputSchema: expect.objectContaining({ required: ["value"] }),
|
|
}),
|
|
}),
|
|
]);
|
|
|
|
await expect(
|
|
connector.resolveCall(
|
|
{
|
|
tool: execute!.name,
|
|
args: { id: "mcp__demo__missing", arguments: { value: "x" } },
|
|
executionId: "unknown",
|
|
route: execute!.route,
|
|
},
|
|
context,
|
|
),
|
|
).rejects.toThrow("unknown or not authorized");
|
|
await expect(
|
|
connector.resolveCall(
|
|
{
|
|
tool: execute!.name,
|
|
args: { id: "server-1:tool_29", arguments: {} },
|
|
executionId: "invalid",
|
|
route: execute!.route,
|
|
},
|
|
context,
|
|
),
|
|
).rejects.toThrow("arguments are invalid");
|
|
|
|
const resolved = await connector.resolveCall(
|
|
{
|
|
tool: execute!.name,
|
|
args: { id: "server-1:tool_29", arguments: { value: "ok" } },
|
|
executionId: "execute",
|
|
route: execute!.route,
|
|
},
|
|
context,
|
|
);
|
|
expect(resolved).toMatchObject({
|
|
tool: { name: "mcp__demo__tool_29" },
|
|
call: {
|
|
tool: "mcp__demo__tool_29",
|
|
args: { value: "ok" },
|
|
route: { connectorId: "mcp", resourceId: "server-1", toolName: "tool_29" },
|
|
},
|
|
});
|
|
expect(await collect(resolved!.call)).toMatchObject([{ type: "result" }]);
|
|
expect(state.calls).toEqual(["tool_29"]);
|
|
await connector.close();
|
|
});
|
|
|
|
it("executes authoritative MCP tools whose names match catalog controls", async () => {
|
|
const state = {
|
|
failNext: false,
|
|
initializations: 0,
|
|
calls: [] as string[],
|
|
tools: ["__catalog_search", "__catalog_load", "__catalog_execute"].map((name) => ({
|
|
name,
|
|
inputSchema: { type: "object" },
|
|
})),
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const prisma = {
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never, {
|
|
network: TEST_NETWORK,
|
|
});
|
|
const context = {
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
|
|
for (const tool of await connector.discoverTools(context)) {
|
|
const call = { tool: tool.name, args: {}, executionId: tool.name, route: tool.route };
|
|
await expect(connector.resolveCall(call, context)).resolves.toBeUndefined();
|
|
const events = [];
|
|
for await (const event of connector.execute(call, context)) events.push(event);
|
|
expect(events).toMatchObject([{ type: "result" }]);
|
|
}
|
|
expect(state.calls).toEqual(["__catalog_search", "__catalog_load", "__catalog_execute"]);
|
|
await connector.close();
|
|
});
|
|
|
|
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
|
"blocks OAuth rediscovery to HTTP %s after invalid_client from a public server",
|
|
async (host) => {
|
|
const requests: Array<{ method: string; url: string }> = [];
|
|
const metadataUrl = `http://${host}:8123/private-probe`;
|
|
const provider = new StoredMcpOAuthProvider(
|
|
"server-1",
|
|
{
|
|
oauth: {
|
|
redirectUri: "https://app.example.test/mcp/oauth/callback",
|
|
tokens: {
|
|
access_token: "fake-stale-access",
|
|
refresh_token: "fake-refresh",
|
|
token_type: "bearer",
|
|
},
|
|
clientInformation: { client_id: "fake-client" },
|
|
discoveryState: {
|
|
authorizationServerUrl: "https://auth.example.test",
|
|
resourceMetadata: {
|
|
resource: SERVER.endpoint,
|
|
authorization_servers: ["https://auth.example.test"],
|
|
},
|
|
authorizationServerMetadata: {
|
|
issuer: "https://auth.example.test",
|
|
authorization_endpoint: "https://auth.example.test/authorize",
|
|
token_endpoint: "https://auth.example.test/token",
|
|
response_types_supported: ["code"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async () => undefined,
|
|
);
|
|
const invalidate = vi.spyOn(provider, "invalidateCredentials");
|
|
const connector = new McpConnector(
|
|
{ botMcpServer: { findMany: vi.fn().mockResolvedValue([ASSIGNMENT]) } } as never,
|
|
{} as never,
|
|
{
|
|
network: {
|
|
resolveHostname: async () => [{ address: "203.0.113.10", family: 4 }],
|
|
fetch: vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
const request = new Request(input, init);
|
|
const url = logicalHref(input, init);
|
|
requests.push({ method: request.method, url });
|
|
if (url === SERVER.endpoint) {
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"` },
|
|
});
|
|
}
|
|
if (url === "https://auth.example.test/token") {
|
|
return Response.json({ error: "invalid_client" }, { status: 400 });
|
|
}
|
|
return new Response(null, { status: 404 });
|
|
}),
|
|
},
|
|
},
|
|
{ providerFor: vi.fn().mockResolvedValue(provider) } as unknown as McpOAuthBroker,
|
|
);
|
|
|
|
try {
|
|
await expect(
|
|
connector.discoverTools({
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never),
|
|
).resolves.toEqual([]);
|
|
|
|
expect(requests.slice(0, 2).map((request) => `${request.method} ${request.url}`)).toEqual([
|
|
`POST ${SERVER.endpoint}`,
|
|
"POST https://auth.example.test/token",
|
|
]);
|
|
expect(invalidate).toHaveBeenCalledWith("all");
|
|
expect(requests.some((request) => request.url === metadataUrl)).toBe(false);
|
|
// The endpoint-origin compatibility retry remains allowed, through remote policy.
|
|
expect(
|
|
requests.some((request) => request.url === "https://mcp.example.test/private-probe"),
|
|
).toBe(true);
|
|
expect(requests.every((request) => new URL(request.url).protocol === "https:")).toBe(true);
|
|
} finally {
|
|
await connector.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("connects to an explicitly configured localhost HTTP server", async () => {
|
|
const state = { failNext: false, initializations: 0 };
|
|
const localAssignment = {
|
|
...ASSIGNMENT,
|
|
server: { ...SERVER, endpoint: "http://localhost:8123/api/mcp" },
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state, "http://localhost:8123/api/mcp"));
|
|
const prisma = {
|
|
botMcpServer: { findMany: vi.fn().mockResolvedValue([localAssignment]) },
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never);
|
|
|
|
const tools = await connector.discoverTools({
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never);
|
|
|
|
expect(tools.map((tool) => tool.name)).toEqual(["mcp__demo__echo"]);
|
|
await connector.close();
|
|
});
|
|
|
|
it("sends stored credentials to an explicitly configured localhost HTTP server", async () => {
|
|
const state = { failNext: false, initializations: 0, headers: [] as Record<string, string>[] };
|
|
const localAssignment = {
|
|
...ASSIGNMENT,
|
|
server: { ...SERVER, endpoint: "http://localhost:8123/api/mcp", secretId: "secret-1" },
|
|
};
|
|
vi.stubGlobal("fetch", mcpFetch(state, "http://localhost:8123/api/mcp"));
|
|
const prisma = {
|
|
botMcpServer: { findMany: vi.fn().mockResolvedValue([localAssignment]) },
|
|
secret: { findFirst: vi.fn().mockResolvedValue({ id: "secret-1", ciphertext: "encrypted" }) },
|
|
};
|
|
const connector = new McpConnector(
|
|
prisma as never,
|
|
{
|
|
load: vi
|
|
.fn()
|
|
.mockReturnValue(
|
|
JSON.stringify({ secret: "local-token", headers: { "X-Api-Key": "local-key" } }),
|
|
),
|
|
} as never,
|
|
);
|
|
|
|
await connector.discoverTools({
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never);
|
|
|
|
expect(state.headers[0]?.authorization).toBe("Bearer local-token");
|
|
expect(state.headers[0]?.["x-api-key"]).toBe("local-key");
|
|
await connector.close();
|
|
});
|
|
|
|
it("evicts a session after a failed call so the next call reconnects instead of reusing a dead session", async () => {
|
|
const state = { failNext: false, initializations: 0 };
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const prisma = {
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never, {
|
|
network: TEST_NETWORK,
|
|
});
|
|
const context = {
|
|
spaceId: "w1",
|
|
userId: "u1",
|
|
botId: "bot-1",
|
|
signal: new AbortController().signal,
|
|
} as never;
|
|
const call = {
|
|
tool: "mcp__demo__echo",
|
|
args: {},
|
|
route: { connectorId: "mcp", resourceId: "server-1", toolName: "echo" },
|
|
} as never;
|
|
|
|
const tools = await connector.discoverTools(context);
|
|
expect(tools.map((tool) => tool.name)).toEqual(["mcp__demo__echo"]);
|
|
expect(state.initializations).toBe(1);
|
|
|
|
state.failNext = true;
|
|
const failed: unknown[] = [];
|
|
for await (const event of connector.execute(call, context)) failed.push(event);
|
|
expect(failed).toMatchObject([{ type: "error" }]);
|
|
|
|
state.failNext = false;
|
|
const events: unknown[] = [];
|
|
for await (const event of connector.execute(call, context)) events.push(event);
|
|
expect(events).toMatchObject([{ type: "result" }]);
|
|
expect(state.initializations).toBe(2);
|
|
|
|
await connector.close();
|
|
});
|
|
|
|
it("does not reuse one session across workspaces", async () => {
|
|
const state = { failNext: false, initializations: 0 };
|
|
vi.stubGlobal("fetch", mcpFetch(state));
|
|
const prisma = {
|
|
botMcpServer: {
|
|
findMany: vi.fn().mockResolvedValue([ASSIGNMENT]),
|
|
findFirst: vi.fn().mockResolvedValue(ASSIGNMENT),
|
|
},
|
|
};
|
|
const connector = new McpConnector(prisma as never, {} as never, {
|
|
network: TEST_NETWORK,
|
|
});
|
|
const contextFor = (spaceId: string, userId: string) =>
|
|
({ spaceId, userId, botId: "bot-1", signal: new AbortController().signal }) as never;
|
|
|
|
await connector.discoverTools(contextFor("w1", "u1"));
|
|
expect(state.initializations).toBe(1);
|
|
|
|
await connector.discoverTools(contextFor("w1", "u2"));
|
|
expect(state.initializations).toBe(2);
|
|
|
|
await connector.discoverTools(contextFor("w2", "u1"));
|
|
expect(state.initializations).toBe(3);
|
|
|
|
await connector.discoverTools(contextFor("w1", "u1"));
|
|
expect(state.initializations).toBe(3);
|
|
|
|
await connector.close();
|
|
});
|
|
});
|
|
|
|
describe("allowlistDrift", () => {
|
|
it("names the allowed tools the server no longer offers", () => {
|
|
const offered = [{ name: "echo" }, { name: "upper" }];
|
|
expect(allowlistDrift(["echo", "vanished_tool"], offered)).toEqual({
|
|
missing: ["vanished_tool"],
|
|
offered: 2,
|
|
stringAllowedCount: 2,
|
|
});
|
|
expect(allowlistDrift(["echo"], offered).missing).toEqual([]);
|
|
// allowedTools is a Json column, so it can hold anything: it must not throw.
|
|
expect(allowlistDrift(null, offered)).toEqual({
|
|
missing: [],
|
|
offered: 2,
|
|
stringAllowedCount: 0,
|
|
});
|
|
// Non-string JSON values are ignored for both missing and the warning ratio.
|
|
expect(allowlistDrift([42, "vanished_tool"], offered)).toEqual({
|
|
missing: ["vanished_tool"],
|
|
offered: 2,
|
|
stringAllowedCount: 1,
|
|
});
|
|
});
|
|
});
|