/* HTTP API integration tests: routes, tool invocation, CORS, OpenAPI, and validation. */ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { $ } from "bun"; import { cliValidateProgram } from "../../core/validate.ts"; import { generateOpenApi } from "../../http/openapi.ts"; import { API_CORS_HEADERS } from "../../http/result.ts"; import { handleApiRequest } from "../../http/server.ts"; import { Cli, CliContext, type CliContext as CliContextType, CliOptionKind, cliErrWithHelp, wantsExplicitJson, } from "../../index.ts"; import { LogEmitter } from "../../log/emitter.ts"; import { createServerRuntime } from "../../server/context.ts"; import { resolveHttpServeConfig } from "../../server/overrides.ts"; import { nestedMcpFixture, testProgram } from "../fixtures.ts"; /** Program with HTTP API enabled and handlers that return values. */ function nestedApiFixture() { return testProgram({ ...nestedMcpFixture, httpServer: { enabled: true }, commands: [ { key: "stat", description: "File metadata.", commands: [ { key: "owner", description: "Ownership helpers.", commands: [ { key: "lookup", description: "Resolve owner info.", options: [ { name: "json", description: "Emit handler output as JSON.", kind: CliOptionKind.Presence, }, { name: "user-name", description: "User to look up.", kind: CliOptionKind.String, shortName: "u", }, ], positionals: [ { name: "path", description: "File or directory.", kind: CliOptionKind.String, }, ], handler: (ctx: CliContextType) => { const user = ctx.stringOpt("user-name") ?? "unknown"; const path = ctx.positional("path") ?? ""; if (wantsExplicitJson(ctx, ctx.hasFlag("json"))) { return { user, path }; } return `lookup user=${user} path=${path}`; }, }, ], }, ], }, { key: "read", description: "Print the first line of each file.", positionals: [ { name: "files", description: "Paths to read.", kind: CliOptionKind.String, argMax: 0, }, ], handler: () => ({ lines: [] }), }, { key: "pdf", description: "Return a minimal PDF.", http: { successContentType: "application/pdf" }, handler: (ctx: CliContextType) => { ctx.respond({ body: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]), contentType: "application/pdf", }); }, }, { key: "html", description: "Return HTML.", http: { successContentType: "text/html; charset=utf-8" }, handler: (ctx: CliContextType) => { ctx.respond({ body: "hi", contentType: "text/html; charset=utf-8", }); }, }, { key: "silent", description: "Returns nothing.", handler: () => {}, }, ], }); } /** Sends one HTTP request through the in-process API handler. */ async function apiRequest( program: ReturnType, request: Request, opts?: { withServer?: boolean }, ) { const cli = new Cli(program); if (opts?.withServer) { const resolved = resolveHttpServeConfig(program); cli.server = { runtime: createServerRuntime(program, "http"), emitter: new LogEmitter({ program, resolved: { ...resolved.log, access: false }, }), http: resolved, }; } return handleApiRequest(cli, request, cli.server?.http); } describe("httpServer validation", () => { test("rejects empty httpServer", () => { const root = testProgram({ key: "app", description: "", httpServer: {} as { enabled: boolean }, handler: () => {}, }); expect(() => cliValidateProgram(root)).toThrow(/httpServer requires enabled: true/); }); test("rejects top-level command name http when httpServer enabled", () => { const root = testProgram({ key: "app", description: "", httpServer: { enabled: true }, commands: [{ key: "http", description: "user", handler: () => {} }], }); expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: http/); }); test("allows top-level command name http without httpServer", () => { const root = testProgram({ key: "app", description: "", commands: [{ key: "http", description: "user", handler: () => {} }], }); expect(() => cliValidateProgram(root)).not.toThrow(); }); test("rejects httpServer on non-root node", () => { const root = { key: "app", version: "0.0.0", description: "", commands: [ { key: "x", description: "cmd", httpServer: { enabled: true }, handler: () => {}, }, ], } as unknown as import("../../core/types.ts").CliProgram; expect(() => cliValidateProgram(root)).toThrow(/httpServer is only supported on the program root/); }); test("rejects reserved top-level command when pathPrefix is empty", () => { const root = testProgram({ key: "app", description: "", httpServer: { enabled: true }, commands: [{ key: "health", description: "user", handler: () => {} }], }); expect(() => cliValidateProgram(root)).toThrow(/Reserved HTTP command name/); }); test("rejects invalid pathPrefix", () => { const root = testProgram({ key: "app", description: "", httpServer: { enabled: true, pathPrefix: "api" }, handler: () => {}, }); expect(() => cliValidateProgram(root)).toThrow(/pathPrefix must start with \//); }); }); describe("HTTP API routes", () => { const program = nestedApiFixture(); cliValidateProgram(program); test("GET /health/liveness returns ok", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness")); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); test("GET /health/readiness returns ok when healthy", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/health/readiness"), { withServer: true }); expect(res.status).toBe(200); const body = (await res.json()) as { ok: boolean; checks: Record }; expect(body.ok).toBe(true); expect(body.checks.config_file.ok).toBe(true); expect(body.checks.config_required.ok).toBe(true); }); test("GET /health/readiness returns 503 when custom readiness fails", async () => { const failProgram = testProgram({ key: "app", description: "Test", version: "1.0.0", httpServer: { enabled: true }, readiness: () => false, handler: () => ({ ok: true }), }); cliValidateProgram(failProgram); const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/readiness"), { withServer: true }); expect(res.status).toBe(503); const body = (await res.json()) as { ok: boolean; checks: { custom: { ok: boolean } } }; expect(body.ok).toBe(false); expect(body.checks.custom.ok).toBe(false); }); test("POST /api returns 500 for non-Error throw", async () => { const throwProgram = testProgram({ key: "app", description: "Test", httpServer: { enabled: true }, commands: [ { key: "boom", description: "Throws non-Error", handler: () => { throw "unexpected"; }, }, ], }); cliValidateProgram(throwProgram); const res = await apiRequest(throwProgram, new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" })); expect(res.status).toBe(500); }); test("POST /api obscures unexpected errors when configured", async () => { const throwProgram = testProgram({ key: "app", description: "Test", httpServer: { enabled: true, errors: { obscureUnexpected: true } }, commands: [ { key: "boom", description: "Throws non-Error", handler: () => { throw "secret"; }, }, ], }); cliValidateProgram(throwProgram); const cli = new Cli(throwProgram); const resolved = resolveHttpServeConfig(throwProgram); cli.server = { runtime: createServerRuntime(throwProgram, "http"), emitter: new LogEmitter({ program: throwProgram, resolved: { ...resolved.log, access: false } }), http: resolved, }; const res = await handleApiRequest( cli, new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" }), resolved, ); expect(res.status).toBe(500); const body = (await res.json()) as { error: string }; expect(body.error).toBe("An unexpected error occurred."); }); test("GET /health/liveness includes CORS headers", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness")); expect(res.status).toBe(200); expect(res.headers.get("access-control-allow-origin")).toBe("*"); expect(await res.json()).toEqual({ ok: true }); }); test("OPTIONS returns 204 with CORS headers", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "OPTIONS" })); expect(res.status).toBe(204); expect(res.headers.get("access-control-allow-origin")).toBe("*"); expect(res.headers.get("access-control-allow-methods")).toContain("POST"); }); test("POST /api/... returns raw JSON body with 201", async () => { const readme = join(import.meta.dir, "..", "..", "..", "README.md"); const res = await apiRequest( program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ "user-name": "alice", path: readme, json: true }), }), ); expect(res.status).toBe(201); expect(res.headers.get("content-type")).toContain("application/json"); expect(await res.json()).toEqual({ user: "alice", path: readme }); }); test("POST /api/... returns JSON body by default with 201", async () => { const readme = join(import.meta.dir, "..", "..", "..", "README.md"); const res = await apiRequest( program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ "user-name": "alice", path: readme }), }), ); expect(res.status).toBe(201); expect(res.headers.get("content-type")).toContain("application/json"); expect(await res.json()).toEqual({ user: "alice", path: readme }); }); test("POST /tools returns 404 (legacy path removed)", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/tools", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }), ); expect(res.status).toBe(404); }); test("POST /api/pdf returns PDF bytes with 201", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/pdf", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }), ); expect(res.status).toBe(201); expect(res.headers.get("content-type")).toBe("application/pdf"); const bytes = new Uint8Array(await res.arrayBuffer()); expect(String.fromCharCode(...bytes.slice(0, 4))).toBe("%PDF"); }); test("POST /api/html returns HTML with 201", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/html", { method: "POST", body: "{}", }), ); expect(res.status).toBe(201); expect(res.headers.get("content-type")).toContain("text/html"); expect(await res.text()).toContain(""); }); test("POST /api/silent returns 500 when handler has no response", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/silent", { method: "POST", body: "{}", }), ); expect(res.status).toBe(500); const body = (await res.json()) as { error: string }; expect(body.error).toContain("ctx.respond()"); }); test("POST /api returns 404 for unknown route", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/missing_tool", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }), ); expect(res.status).toBe(404); }); test("POST /api returns 400 for bad args", async () => { const res = await apiRequest( program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ "user-name": "alice" }), }), ); expect(res.status).toBe(400); const body = (await res.json()) as { error: string }; expect(body.error).toContain("Missing argument: path"); expect(body).not.toHaveProperty("stderr"); expect(body.error).not.toContain("\u001B["); }); test("POST /api/... returns plain JSON validation errors", async () => { const failProgram = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true }, commands: [ { key: "fail", description: "Fails with cliErrWithHelp.", handler: (ctx: CliContextType) => { cliErrWithHelp(ctx, "bad input"); }, }, ], }); cliValidateProgram(failProgram); const res = await apiRequest( failProgram, new Request("http://127.0.0.1/fail", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }), ); expect(res.status).toBe(400); const body = (await res.json()) as Record; expect(body).toEqual({ error: "bad input" }); }); /** Tests that POST endpoints accept YAML request bodies. */ test("POST accepts YAML request body", async () => { const yamlProgram = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true }, commands: [ { key: "create", kind: "document", description: "Create resource", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], }, handler: (ctx: CliContextType) => { return { created: ctx.inputsAs<{ name: string }>().name }; }, }, ], }); cliValidateProgram(yamlProgram); const res = await apiRequest( yamlProgram, new Request("http://127.0.0.1/create", { method: "POST", headers: { "content-type": "application/yaml" }, body: "name: test-resource", }), ); expect(res.status).toBe(201); const body = (await res.json()) as Record; expect(body).toEqual({ created: "test-resource" }); }); test("GET /openapi.json lists REST paths", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/openapi.json")); expect(res.status).toBe(200); const doc = (await res.json()) as { openapi: string; paths: Record }; expect(doc.openapi).toBe("3.1.0"); expect(doc.paths["/stat/owner/lookup"]).toBeDefined(); expect(doc.paths["/health/liveness"]).toBeDefined(); expect(doc.paths["/health/readiness"]).toBeDefined(); expect(doc.paths["/health"]).toBeUndefined(); }); test("GET /swagger returns Swagger UI HTML", async () => { const res = await apiRequest(program, new Request("http://127.0.0.1/swagger")); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/html"); const html = await res.text(); expect(html).toContain("swagger-ui-dist"); expect(html).toContain('url: "/openapi.json"'); expect(html).toContain('dom_id: "#swagger-ui"'); }); }); test("generateOpenApi includes health probe paths", () => { const program = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true }, handler: () => ({ ok: true }), }); cliValidateProgram(program); const doc = generateOpenApi(program) as { tags: { name: string }[]; paths: Record< string, { get: { tags: string[]; summary: string; responses: Record }> }>; }; } >; }; expect(doc.tags.some((t) => t.name === "health")).toBe(true); expect(doc.paths["/health"]).toBeUndefined(); expect(doc.paths["/health/liveness"]?.get.tags).toContain("health"); expect(doc.paths["/health/liveness"]?.get.summary).toBe("Liveness probe"); expect(doc.paths["/health/readiness"]?.get.summary).toBe("Readiness probe"); expect(doc.paths["/health/liveness"]?.get.responses["200"]).toBeDefined(); expect(doc.paths["/health/readiness"]?.get.responses["200"]).toBeDefined(); expect(doc.paths["/health/readiness"]?.get.responses["503"]).toBeDefined(); const readySchema = doc.paths["/health/readiness"]?.get.responses["200"].content["application/json; charset=utf-8"] .schema as { properties?: { checks?: unknown } }; expect(readySchema.properties?.checks).toBeDefined(); }); test("generateOpenApi omits health paths when httpServer disabled", () => { const program = testProgram({ key: "app", description: "Test app", handler: () => ({ ok: true }), }); cliValidateProgram(program); const doc = generateOpenApi(program) as { paths: Record }; expect(doc.paths["/health"]).toBeUndefined(); }); test("generateOpenApi groups routes by top-level command tag", () => { const program = nestedApiFixture(); cliValidateProgram(program); const doc = generateOpenApi(program) as { tags: { name: string; description?: string }[]; paths: Record; }; const tagNames = doc.tags.map((t) => t.name); expect(tagNames).toContain("health"); expect(tagNames).toContain("stat"); expect(tagNames).toContain("pdf"); expect(doc.tags.find((t) => t.name === "stat")?.description).toBe("File metadata."); expect(doc.paths["/stat/owner/lookup"]?.post?.tags).toEqual(["stat"]); expect(doc.paths["/pdf"]?.post?.tags).toEqual(["pdf"]); expect(doc.paths["/read"]?.post?.tags).toEqual(["read"]); }); test("generateOpenApi honors httpServer.pathPrefix", () => { const program = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true, pathPrefix: "/api" }, commands: [{ key: "echo", description: "Echo.", handler: () => ({ ok: true }) }], }); cliValidateProgram(program); const doc = generateOpenApi(program) as { paths: Record }; expect(doc.paths["/api/echo"]).toBeDefined(); expect(doc.paths["/echo"]).toBeUndefined(); }); test("generateOpenApi maps binary content types", () => { const program = nestedApiFixture(); const doc = generateOpenApi(program) as { paths: Record } } } }>; }; const pdf = doc.paths["/pdf"]?.post.responses["201"].content["application/pdf"] as { schema: { format: string }; }; expect(pdf.schema.format).toBe("binary"); }); test("generateOpenApi dereferences nested inputSchema definitions", () => { const program = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true }, commands: [ { key: "render", description: "Render a document.", inputSchema: { type: "object", properties: { invoice: { $ref: "#/definitions/InvoiceData" }, }, definitions: { InvoiceData: { type: "object", properties: { id: { type: "string" }, }, required: ["id"], }, }, }, handler: () => ({ ok: true }), }, ], }); cliValidateProgram(program); const doc = generateOpenApi(program) as { paths: Record< string, { post: { requestBody: { content: Record } } }>; }; }; } >; }; const schema = doc.paths["/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema; expect(schema.properties.invoice).toEqual({ type: "object", properties: { id: { type: "string" } }, required: ["id"], }); }); test("generateOpenApi generates requestBody for kind: json leaves", () => { const program = testProgram({ key: "app", description: "Test app", httpServer: { enabled: true }, commands: [ { key: "render-invoice", description: "Render an invoice.", kind: "json", inputSchema: { type: "object", properties: { id: { type: "string" }, }, required: ["id"], }, handler: () => ({ ok: true }), }, ], }); cliValidateProgram(program); const doc = generateOpenApi(program) as { paths: Record< string, { post: { requestBody: { required: boolean; content: Record }>; }; }; } >; }; const op = doc.paths["/render-invoice"]?.post; expect(op).toBeDefined(); expect(op.requestBody).toBeDefined(); expect(op.requestBody.required).toBe(true); expect(op.requestBody.content["application/json; charset=utf-8"].schema).toEqual({ type: "object", properties: { id: { type: "string" } }, required: ["id"], }); }); test("ctx.respond throws when called twice", () => { const program = testProgram({ key: "app", description: "", handler: () => {}, }); const context = new CliContext("app", [], [], {}, program, "http"); context.respond({ body: { ok: true } }); expect(() => context.respond({ body: { ok: true } })).toThrow(/already called/); }); test("API_CORS_HEADERS are wide open", () => { expect(API_CORS_HEADERS["access-control-allow-origin"]).toBe("*"); }); test("ctx.invocation is http via Cli.invoke", async () => { let seen = ""; const root = testProgram({ key: "app", description: "", handler: (ctx: CliContextType) => { seen = ctx.invocation; return { invocation: ctx.invocation }; }, }); cliValidateProgram(root); const result = await new Cli(root).invoke([], { invocation: "http" }); expect(result.kind).toBe("ok"); expect(seen).toBe("http"); expect(result.response?.body).toEqual({ invocation: "http" }); }); test("minimal.ts http without opt-in fails", async () => { const { stderr, exitCode } = await $`bun run examples/minimal.ts http`.nothrow().quiet(); expect(exitCode).toBe(1); expect(stderr.toString()).toContain("HTTP API is not available"); });