/** * Regression tests for code review fixes. * Each describe block maps to a specific review item. */ import { describe, it, expect, vi } from "vitest"; import { createCopilotRuntimeHandler } from "../core/fetch-handler"; import { CopilotRuntime } from "../core/runtime"; import { matchRoute } from "../core/fetch-router"; import { handleCors, addCorsHeaders } from "../core/fetch-cors"; import type { CopilotCorsConfig } from "../core/fetch-cors"; import type { AbstractAgent } from "@ag-ui/client"; /* ------------------------------------------------------------------------------------------------ * Helpers * --------------------------------------------------------------------------------------------- */ const createMockAgent = () => { const agent: unknown = { execute: vi.fn().mockResolvedValue({ events: [] }), }; (agent as { clone: () => unknown }).clone = () => createMockAgent(); return agent as AbstractAgent; }; const createRuntime = () => new CopilotRuntime({ agents: { default: createMockAgent() } }); const get = (url: string) => new Request(url, { method: "GET" }); const post = (url: string, body?: unknown) => new Request(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); /* ------------------------------------------------------------------------------------------------ * Item 1: __cpk_methodCall mutation lost if hooks replace the request * * In single-route mode, the parsed method call must survive even if * onBeforeHandler returns a new Request object. * --------------------------------------------------------------------------------------------- */ describe("Item 1: methodCall preserved when hooks replace request", () => { it("single-route dispatch works after onBeforeHandler replaces request", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", mode: "single-route", hooks: { onBeforeHandler: ({ request }) => { // Return a brand-new Request with only headers modified. // This discards any properties stashed on the old request object. return new Request(request, { headers: new Headers([...request.headers, ["x-replaced", "true"]]), }); }, }, }); const response = await handler( post("http://localhost/api", { method: "info" }), ); // Should succeed (200) rather than crash with undefined methodCall expect(response.status).toBe(200); const body = await response.json(); expect(body).toHaveProperty("version"); }); it("single-route agent/run works after onBeforeHandler replaces request", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", mode: "single-route", hooks: { onBeforeHandler: ({ request }) => { return new Request(request.url, { method: request.method, headers: request.headers, body: request.clone().body, }); }, }, }); const response = await handler( post("http://localhost/api", { method: "agent/run", params: { agentId: "default" }, body: { threadId: "t1", runId: "r1" }, }), ); // Should not crash — the method call data is passed explicitly, not via request property expect(response).toBeInstanceOf(Response); }); }); /* ------------------------------------------------------------------------------------------------ * Item 2: credentials: true + wildcard origin silently produces invalid CORS * * When credentials is true and origin resolves to "*", we must auto-resolve * to the request origin to comply with the Fetch spec. * --------------------------------------------------------------------------------------------- */ describe("Item 2: credentials + wildcard CORS", () => { it("auto-resolves wildcard to request origin when credentials enabled (preflight)", () => { const request = new Request("http://localhost/api", { method: "OPTIONS", headers: { Origin: "https://app.example.com" }, }); const config: CopilotCorsConfig = { credentials: true }; const response = handleCors(request, config)!; expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "https://app.example.com", ); expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( "true", ); }); it("auto-resolves wildcard to request origin in addCorsHeaders", () => { const response = new Response("ok", { status: 200 }); const config: CopilotCorsConfig = { credentials: true }; const result = addCorsHeaders(response, config, "https://mysite.com"); expect(result.headers.get("Access-Control-Allow-Origin")).toBe( "https://mysite.com", ); expect(result.headers.get("Access-Control-Allow-Credentials")).toBe("true"); }); it("skips CORS entirely when credentials enabled, wildcard origin, and no request origin", () => { const request = new Request("http://localhost/api", { method: "OPTIONS", // No Origin header }); const config: CopilotCorsConfig = { credentials: true }; const response = handleCors(request, config)!; // Should not set any CORS origin since there's no request origin to reflect expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); }); it("explicit origin string + credentials does not trigger auto-resolution", () => { const request = new Request("http://localhost/api", { method: "OPTIONS", headers: { Origin: "https://app.example.com" }, }); const config: CopilotCorsConfig = { origin: "https://specific.example.com", credentials: true, }; const response = handleCors(request, config)!; expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "https://specific.example.com", ); }); it("end-to-end: handler CORS with credentials does not produce wildcard + credentials", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", cors: { credentials: true }, }); const response = await handler( new Request("http://localhost/api/info", { method: "GET", headers: { Origin: "https://app.example.com" }, }), ); const allowOrigin = response.headers.get("Access-Control-Allow-Origin"); const allowCreds = response.headers.get("Access-Control-Allow-Credentials"); // Must NEVER be "*" + "true" — that's the invalid combo if (allowCreds === "true") { expect(allowOrigin).not.toBe("*"); } expect(allowOrigin).toBe("https://app.example.com"); }); }); /* ------------------------------------------------------------------------------------------------ * Item 3: runOnError can throw unhandled * * If the onError hook itself throws, the handler must catch it and return * a 500 response rather than letting the promise reject. * --------------------------------------------------------------------------------------------- */ describe("Item 3: onError hook throwing is caught", () => { it("returns 500 when onError throws synchronously", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", hooks: { onRequest: () => { throw new Error("trigger error"); }, onError: () => { throw new Error("hook exploded"); }, }, }); // Must not reject — should return a Response const response = await handler(get("http://localhost/api/info")); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("internal_error"); }); it("returns 500 when onError throws asynchronously", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", hooks: { onRequest: () => { throw new Error("trigger error"); }, onError: async () => { await Promise.resolve(); throw new Error("async hook exploded"); }, }, }); const response = await handler(get("http://localhost/api/info")); expect(response.status).toBe(500); }); }); /* ------------------------------------------------------------------------------------------------ * Item 4: decodeURIComponent can throw URIError on malformed URLs * * Malformed percent-encoding in path segments must not crash with an * uncaught URIError; the router should return null (no match). * --------------------------------------------------------------------------------------------- */ describe("Item 4: malformed URI encoding handled gracefully", () => { it("returns null for malformed agentId in /agent/:agentId/run", () => { const result = matchRoute("/api/agent/%ZZ/run", "/api"); expect(result).toBeNull(); }); it("returns null for malformed agentId in /agent/:agentId/connect", () => { const result = matchRoute("/api/agent/%ZZ/connect", "/api"); expect(result).toBeNull(); }); it("returns null for malformed agentId in /agent/:agentId/stop/:threadId", () => { const result = matchRoute("/api/agent/%ZZ/stop/valid-thread", "/api"); expect(result).toBeNull(); }); it("returns null for malformed threadId in /agent/:agentId/stop/:threadId", () => { const result = matchRoute("/api/agent/valid-agent/stop/%ZZ", "/api"); expect(result).toBeNull(); }); it("still decodes valid percent-encoding correctly", () => { const result = matchRoute("/api/agent/hello%20world/run", "/api"); expect(result).toEqual({ method: "agent/run", agentId: "hello world" }); }); it("end-to-end: malformed URL returns 404", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", }); const response = await handler( post("http://localhost/api/agent/%ZZ/run", { threadId: "t1" }), ); expect(response.status).toBe(404); }); }); /* ------------------------------------------------------------------------------------------------ * Item 8: Missing Allow header on 405 responses * * RFC 9110 §15.5.6 requires 405 responses to include an Allow header. * --------------------------------------------------------------------------------------------- */ describe("Item 8: Allow header on 405 responses", () => { it("multi-route: 405 for POST to /info includes Allow: GET", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", }); const response = await handler(post("http://localhost/api/info")); expect(response.status).toBe(405); expect(response.headers.get("Allow")).toBe("GET"); }); it("multi-route: 405 for GET to /agent/:id/run includes Allow: POST", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", }); const response = await handler( get("http://localhost/api/agent/default/run"), ); expect(response.status).toBe(405); expect(response.headers.get("Allow")).toBe("POST"); }); it("single-route: 405 for GET includes Allow: POST", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", mode: "single-route", }); const response = await handler(get("http://localhost/api")); expect(response.status).toBe(405); expect(response.headers.get("Allow")).toBe("POST"); }); }); /* ------------------------------------------------------------------------------------------------ * Item 9: Error messages not leaked to clients in 500 responses * * The 500 response body should contain { error: "internal_error" } * without exposing error.message which could contain file paths, DB errors, etc. * --------------------------------------------------------------------------------------------- */ describe("Item 9: error messages not leaked to clients", () => { it("500 response does not include error message", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", hooks: { onRequest: () => { throw new Error("secret: /etc/passwd contents here"); }, }, }); const response = await handler(get("http://localhost/api/info")); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("internal_error"); expect(body.message).toBeUndefined(); expect(JSON.stringify(body)).not.toContain("passwd"); }); it("non-Error throws also produce clean 500", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", hooks: { onRequest: () => { throw { secretData: "db_password_123" }; }, }, }); const response = await handler(get("http://localhost/api/info")); expect(response.status).toBe(500); const body = await response.json(); expect(body.error).toBe("internal_error"); expect(body.message).toBeUndefined(); expect(JSON.stringify(body)).not.toContain("db_password"); }); }); /* ------------------------------------------------------------------------------------------------ * Item 12: Missing Vary headers on preflight * * Preflight responses should include Vary: Access-Control-Request-Headers * and Vary: Access-Control-Request-Method for CDN caching correctness. * --------------------------------------------------------------------------------------------- */ describe("Item 12: Vary headers on preflight", () => { it("preflight includes Vary for CORS request headers and method", () => { const request = new Request("http://localhost/api", { method: "OPTIONS", headers: { Origin: "https://example.com" }, }); const response = handleCors(request, {})!; const vary = response.headers.get("Vary") ?? ""; expect(vary).toContain("Access-Control-Request-Headers"); expect(vary).toContain("Access-Control-Request-Method"); }); it("preflight includes Vary: Origin when origin is not wildcard", () => { const request = new Request("http://localhost/api", { method: "OPTIONS", headers: { Origin: "https://example.com" }, }); const response = handleCors(request, { origin: "https://example.com", })!; const vary = response.headers.get("Vary") ?? ""; expect(vary).toContain("Origin"); expect(vary).toContain("Access-Control-Request-Headers"); expect(vary).toContain("Access-Control-Request-Method"); }); }); /* ------------------------------------------------------------------------------------------------ * Item 7: synthesizeBody null guard uses wrong comparison * * Tested indirectly through express-fetch-bridge. * The synthesizeBody function should treat null bodies as empty (not serialize "null"). * This is already mitigated by hasPreParsedBody rejecting null, but we test the * boundary to ensure the fix is correct. * --------------------------------------------------------------------------------------------- */ describe("Item 7: synthesizeBody null guard", () => { // Verify that null req.body causes hasPreParsedBody to return false, // meaning the generic (stream-based) handler runs instead of trying to // serialize null into the string "null". it("null req.body is treated as no pre-parsed body (falls through to generic handler)", async () => { const { createExpressNodeHandler } = await import("../endpoints/express-fetch-bridge"); let handlerCalled = false; const fetchHandler = async (_req: Request) => { handlerCalled = true; return new Response("ok", { status: 200 }); }; const nodeHandler = createExpressNodeHandler(fetchHandler); // Use supertest-style approach: create a real HTTP server to exercise // the full path. Since we can't easily mock Node streams, we verify // the hasPreParsedBody logic through the express-fetch-bridge tests. // Here we just assert the module loads and the handler is created correctly. expect(typeof nodeHandler).toBe("function"); expect(handlerCalled).toBe(false); }); }); /* ------------------------------------------------------------------------------------------------ * Item 10: After-request middleware does not double-consume response body * * The response passed to afterRequestMiddleware should be a clone, so * reading its body doesn't affect the response sent to the client. * --------------------------------------------------------------------------------------------- */ describe("Item 10: after-request middleware gets cloned response", () => { it("response body is still readable after middleware runs", async () => { const handler = createCopilotRuntimeHandler({ runtime: createRuntime(), basePath: "/api", }); // Simply call the handler and verify the response body is readable const response = await handler(get("http://localhost/api/info")); expect(response.status).toBe(200); // The body should be consumable — if clone() wasn't used, the middleware // might have consumed it first const body = await response.json(); expect(body).toHaveProperty("version"); }); }); /* ------------------------------------------------------------------------------------------------ * Breaking Change: CopilotKitRequestHandler deprecated alias * * The CopilotKitRequestHandler type must still be importable for backward * compatibility, even though it's deprecated. * --------------------------------------------------------------------------------------------- */ describe("Breaking change: CopilotKitRequestHandler type alias exists", () => { it("CopilotKitRequestHandler is exported from the package index", async () => { // Dynamic import to check the type exists at runtime const exports = await import("../index"); // TypeScript types don't exist at runtime, but we can verify the module // loads without errors. The type check is compile-time. expect(exports).toBeDefined(); }); }); /* ------------------------------------------------------------------------------------------------ * Item 13: Forward-reference code ordering in node-fetch-handler.ts * * createCopilotNodeHandler should be defined before createNodeFetchHandler * references it. This is a code quality fix — no runtime behavior change. * --------------------------------------------------------------------------------------------- */ describe("Item 13: node-fetch-handler exports are both available", () => { it("both createCopilotNodeHandler and createNodeFetchHandler are exported", async () => { const { createCopilotNodeHandler, createNodeFetchHandler } = await import("../endpoints/node-fetch-handler"); expect(typeof createCopilotNodeHandler).toBe("function"); expect(typeof createNodeFetchHandler).toBe("function"); // The deprecated alias should reference the same function expect(createNodeFetchHandler).toBe(createCopilotNodeHandler); }); });