fetch_openapi_spec.test.ts•11.1 kB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetch_openapi_spec } from "#tools/fetch_openapi_spec";
import * as utils from "#lib/utils";
import { logger } from "#lib/index";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type {
ServerRequest,
ServerNotification,
} from "@modelcontextprotocol/sdk/types.js";
import { resetAllMocks } from "../../test-utils";
vi.mock("#lib/utils", async () => {
const actual = await vi.importActual("#lib/utils");
return {
...actual,
parseFetchTarget: vi.fn(),
fetchContent: vi.fn(),
checkDomainAccess: vi.fn(),
};
});
describe("fetch_openapi_spec", () => {
const mockExtra = {} as RequestHandlerExtra<
ServerRequest,
ServerNotification
>;
const mockAllowedDomains = new Set(["example.com", "api.example.org"]);
const originalEnv = process.env;
beforeEach(() => {
resetAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it("should handle a single URL in object format", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://example.com/openapi.json"),
hostname: "example.com",
};
const mockApiSpec = JSON.stringify({
openapi: "3.0.0",
info: { title: "Test API" },
});
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.fetchContent).mockResolvedValueOnce(mockApiSpec);
// Mock the logger
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
const result = await fetch_openapi_spec(
{ url: "https://example.com/openapi.json" },
mockExtra,
mockAllowedDomains
);
expect(utils.parseFetchTarget).toHaveBeenCalledWith(
"https://example.com/openapi.json"
);
// Verify debug logs were called with expected messages
const debugCalls = debugSpy.mock.calls.flat();
expect(
debugCalls.some(
(call) =>
typeof call === "string" &&
call.includes("Processing fetch_openapi_spec request with input:")
)
).toBe(true);
expect(utils.checkDomainAccess).toHaveBeenCalledWith(
mockTargetInfo,
mockAllowedDomains
);
expect(utils.fetchContent).toHaveBeenCalledWith(mockTargetInfo);
expect(result).toEqual({
content: [{ type: "text", text: mockApiSpec }],
});
debugSpy.mockRestore();
});
it("should handle an array of URLs", async () => {
// Setup mocks for first URL
const mockTargetInfo1 = {
type: "remote" as const,
url: new URL("https://example.com/openapi.json"),
hostname: "example.com",
};
// Setup mocks for second URL
const mockTargetInfo2 = {
type: "remote" as const,
url: new URL("https://api.example.org/openapi.json"),
hostname: "api.example.org",
};
const mockApiSpec1 = JSON.stringify({
openapi: "3.0.0",
info: { title: "API 1" },
});
const mockApiSpec2 = JSON.stringify({
openapi: "3.0.0",
info: { title: "API 2" },
});
vi.mocked(utils.parseFetchTarget)
.mockResolvedValueOnce(mockTargetInfo1)
.mockResolvedValueOnce(mockTargetInfo2);
vi.mocked(utils.checkDomainAccess)
.mockImplementationOnce(() => Promise.resolve())
.mockImplementationOnce(() => Promise.resolve());
vi.mocked(utils.fetchContent)
.mockResolvedValueOnce(mockApiSpec1)
.mockResolvedValueOnce(mockApiSpec2);
// Mock the logger
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
// Call function with array input
const result = await fetch_openapi_spec(
[
"https://example.com/openapi.json",
"https://api.example.org/openapi.json",
],
mockExtra,
mockAllowedDomains
);
// Verify results
expect(utils.parseFetchTarget).toHaveBeenCalledTimes(2);
expect(utils.fetchContent).toHaveBeenCalledTimes(2);
expect(result).toEqual({
content: [
{ type: "text", text: mockApiSpec1 },
{ type: "text", text: mockApiSpec2 },
],
});
// Verify debug logs were called with expected messages
const debugCalls = debugSpy.mock.calls.flat();
expect(
debugCalls.some(
(call) =>
typeof call === "string" &&
call.includes("Processing fetch_openapi_spec request with input:")
)
).toBe(true);
debugSpy.mockRestore();
});
it("should log when fetching OpenAPI spec", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://example.com/openapi.json"),
hostname: "example.com",
};
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.fetchContent).mockResolvedValueOnce(
JSON.stringify({ openapi: "3.0.0", info: { title: "Test API" } })
);
// Mock the logger
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
await fetch_openapi_spec(
{ url: "https://example.com/openapi.json" },
mockExtra,
mockAllowedDomains
);
// Verify debug logs were called with expected messages
const debugCalls = debugSpy.mock.calls.flat();
expect(
debugCalls.some(
(call) =>
typeof call === "string" &&
call.includes("Processing fetch_openapi_spec request with input:")
)
).toBe(true);
debugSpy.mockRestore();
});
it("should throw error for unsupported targets", async () => {
const unsupportedTarget = {
type: "unsupported" as const,
reason: "Invalid protocol",
originalInput: "ftp://example.com/openapi.json",
};
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(unsupportedTarget);
const error = await fetch_openapi_spec(
{ url: "ftp://example.com/openapi.json" },
mockExtra,
mockAllowedDomains
).catch((err) => err);
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(
"Failed to process OpenAPI spec request for ftp://example.com/openapi.json"
);
expect(error.message).toContain("Unsupported URL format: Invalid protocol");
});
it("should handle domain access check failures", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://untrusted.example/openapi.json"),
hostname: "untrusted.example",
};
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.checkDomainAccess).mockImplementationOnce(() => {
throw new Error(
"Access denied: Domain 'untrusted.example' is not allowed"
);
});
await expect(
fetch_openapi_spec(
{ url: "https://untrusted.example/openapi.json" },
mockExtra,
mockAllowedDomains
)
).rejects.toThrow(
"Failed to process OpenAPI spec request for https://untrusted.example/openapi.json"
);
});
it("should handle content fetch failures", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://example.com/not-found.json"),
hostname: "example.com",
};
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.fetchContent).mockRejectedValueOnce(new Error("Not Found"));
await expect(
fetch_openapi_spec(
{ url: "https://example.com/not-found.json" },
mockExtra,
mockAllowedDomains
)
).rejects.toThrow(
"Failed to process OpenAPI spec request for https://example.com/not-found.json"
);
});
it("should handle failed content fetch with generic error message", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://example.com/error.json"),
hostname: "example.com",
};
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.fetchContent).mockRejectedValueOnce(new Error());
await expect(
fetch_openapi_spec(
{ url: "https://example.com/error.json" },
mockExtra,
mockAllowedDomains
)
).rejects.toThrow(
"Failed to process OpenAPI spec request for https://example.com/error.json"
);
});
it("should handle string URL input format", async () => {
const mockTargetInfo = {
type: "remote" as const,
url: new URL("https://example.com/openapi.json"),
hostname: "example.com",
};
const mockApiSpec = JSON.stringify({
openapi: "3.0.0",
info: { title: "Test API" },
});
vi.mocked(utils.parseFetchTarget).mockResolvedValueOnce(mockTargetInfo);
vi.mocked(utils.fetchContent).mockResolvedValueOnce(mockApiSpec);
const result = await fetch_openapi_spec(
"https://example.com/openapi.json",
mockExtra,
mockAllowedDomains
);
expect(utils.parseFetchTarget).toHaveBeenCalledWith(
"https://example.com/openapi.json"
);
expect(result).toEqual({
content: [{ type: "text", text: mockApiSpec }],
});
});
it("should handle array of string URLs input format", async () => {
const mockTargetInfo1 = {
type: "remote" as const,
url: new URL("https://example.com/openapi.json"),
hostname: "example.com",
};
const mockTargetInfo2 = {
type: "remote" as const,
url: new URL("https://api.example.org/openapi.json"),
hostname: "api.example.org",
};
const mockApiSpec1 = JSON.stringify({
openapi: "3.0.0",
info: { title: "API 1" },
});
const mockApiSpec2 = JSON.stringify({
openapi: "3.0.0",
info: { title: "API 2" },
});
vi.mocked(utils.parseFetchTarget)
.mockResolvedValueOnce(mockTargetInfo1)
.mockResolvedValueOnce(mockTargetInfo2);
vi.mocked(utils.fetchContent)
.mockResolvedValueOnce(mockApiSpec1)
.mockResolvedValueOnce(mockApiSpec2);
const result = await fetch_openapi_spec(
[
"https://example.com/openapi.json",
"https://api.example.org/openapi.json",
],
mockExtra,
mockAllowedDomains
);
expect(utils.parseFetchTarget).toHaveBeenCalledTimes(2);
expect(utils.fetchContent).toHaveBeenCalledTimes(2);
expect(result).toEqual({
content: [
{ type: "text", text: mockApiSpec1 },
{ type: "text", text: mockApiSpec2 },
],
});
});
it("should handle error in URL processing", async () => {
const error = new Error("Invalid URL");
vi.mocked(utils.parseFetchTarget).mockRejectedValueOnce(error);
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const errorResult = await fetch_openapi_spec(
{ url: "invalid-url" },
mockExtra,
mockAllowedDomains
).catch((err) => err);
expect(errorResult).toBeInstanceOf(Error);
expect(errorResult.message).toContain(
"Failed to process OpenAPI spec request for invalid-url"
);
errorSpy.mockRestore();
});
});