Files
calendar/apps/web/app/api/mintlify-chat/__tests__/route.test.ts
T
a7d87fc5be fix: Proxy Mintlify traffic through a Next.js API route (#25320)
* hotfix

* type fix and test fix

* update env example

* improvements

* more fix

* tada

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-11-21 18:43:09 +00:00

471 lines
15 KiB
TypeScript

import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as mintlifyChatValidation from "@calcom/lib/server/mintlifyChatValidation";
import { POST as topicPOST } from "../topic/route";
import { POST as messagePOST } from "../message/route";
// Mock fetch
global.fetch = vi.fn();
// Polyfill Response.json for test environment
if (!Response.json) {
Response.json = function (data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
};
}
describe("Mintlify Chat Proxy Endpoints", () => {
beforeEach(() => {
vi.clearAllMocks();
// Set environment variables
process.env.MINTLIFY_CHAT_API_KEY = "test-api-key";
process.env.NEXT_PUBLIC_CHAT_API_URL = "https://api.mintlify.com";
});
afterEach(() => {
delete process.env.MINTLIFY_CHAT_API_KEY;
delete process.env.NEXT_PUBLIC_CHAT_API_URL;
});
describe("POST /api/mintlify-chat/topic", () => {
it("should create a topic successfully", async () => {
const mockTopicId = "test-topic-123";
// Mock Mintlify API response
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: mockTopicId }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.topicId).toBe(mockTopicId);
// Verify fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
"https://api.mintlify.com/topic",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
},
}
);
});
it("should handle missing API key", async () => {
delete process.env.MINTLIFY_CHAT_API_KEY;
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
// Generic error message - doesn't leak internal config details
expect(data.error).toBe("Failed to create topic. Please try again later.");
});
it("should handle Mintlify API errors", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Failed to create topic");
});
it("should never expose API key in response", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: "test-topic-123" }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const responseText = await response.text();
expect(responseText).not.toContain("test-api-key");
expect(responseText).not.toContain("Bearer");
});
});
describe("POST /api/mintlify-chat/message", () => {
it("should send a message and stream response", async () => {
const mockMessage = "What is Cal.com?";
const mockTopicId = "test-topic-123";
const mockResponse = "Cal.com is a scheduling platform.";
// Create a mock ReadableStream
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(mockResponse));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers({
"content-type": "text/plain",
"x-mintlify-base-url": "https://docs.cal.com",
}),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: mockMessage,
topicId: mockTopicId,
}),
}) as NextRequest;
const response = await messagePOST(request);
expect(response.status).toBe(200);
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
// Verify fetch was called with sanitized input
expect(global.fetch).toHaveBeenCalledWith(
"https://api.mintlify.com/message",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
},
body: JSON.stringify({
message: mockMessage,
topicId: mockTopicId,
}),
}
);
});
it("should reject invalid JSON payload", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: "invalid json",
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid JSON payload");
});
it("should reject malformed message payload", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "",
topicId: "test-topic",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid message");
});
it("should reject oversized messages", async () => {
const largeMessage = "a".repeat(10001); // Exceeds MAX_MESSAGE_LENGTH
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: largeMessage,
topicId: "test-topic",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("exceeds maximum length");
});
it("should reject path traversal in topicId", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
topicId: "../../../etc/passwd",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Path traversal");
});
it("should reject control characters in message", async () => {
const maliciousMessage = "test\x00message\x01with\x02control\x03chars";
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("sanitized response"));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: maliciousMessage,
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Should either sanitize (200) or reject with validation error (400)
expect([200, 400]).toContain(response.status);
if (response.status === 200) {
// If sanitized, verify control chars were removed from the proxied request
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const bodyArg = fetchCall?.[1]?.body;
if (typeof bodyArg === "string") {
const parsedBody = JSON.parse(bodyArg);
expect(parsedBody.message).not.toContain("\x00");
expect(parsedBody.message).not.toContain("\x01");
}
}
});
it("should reject invalid topicId format", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
topicId: "invalid topic id with spaces!@#$",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid topicId");
});
it("should sanitize response headers", async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("response"));
controller.close();
},
});
const mockHeaders = new Headers({
"content-type": "text/plain",
"x-mintlify-base-url": "https://docs.cal.com",
"x-custom-dangerous-header": "should-not-appear",
"authorization": "Bearer secret-key",
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: mockHeaders,
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Verify only safe headers are included
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
expect(response.headers.get("x-custom-dangerous-header")).toBeNull();
expect(response.headers.get("authorization")).toBeNull();
});
it("should never expose API key in any response", async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("response text"));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Verify no API key in response headers
expect(response.headers.get("authorization")).toBeNull();
expect(response.headers.get("Authorization")).toBeNull();
// Read response text (for non-stream responses in test env)
const responseText = await response.text();
expect(responseText).not.toContain("test-api-key");
expect(responseText).not.toContain("Bearer");
});
it("should handle missing message field", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid message");
});
it("should handle missing topicId field", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid topicId");
});
});
describe("Security Tests", () => {
it("should never leak API key through error messages", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Upstream error with API key: test-api-key")
);
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
// Verify generic error message doesn't contain API key
expect(JSON.stringify(data)).not.toContain("test-api-key");
expect(data.error).toBe("Failed to create topic. Please try again later.");
});
it("should validate environment variables on every request", async () => {
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateMintlifyConfig");
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: "test" }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
await topicPOST(request);
expect(validateSpy).toHaveBeenCalled();
});
it("should validate message payload on every request", async () => {
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateChatMessage");
const mockStream = new ReadableStream({
start(controller) {
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
await messagePOST(request);
expect(validateSpy).toHaveBeenCalled();
});
});
});