Files
Kilo-Org_kilocode/packages/opencode/test/server/httpapi-bridge.test.ts
T
Imanol Maiztegui 32777136cd fix(opencode): resolve race conditions and update tests for kilo compatibility
Remove eager publish call in indexing initialization that caused
premature event emission. Add foreign key constraint tolerance in
next-message projector to handle writes racing against deleted
sessions. Fix test environment isolation for gateway headers and
permission tests, update auth test assertions to use "kilo" username,
and remove stale indexing route from httpapi-bridge expectations.
2026-05-20 12:05:59 +02:00

444 lines
16 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = {
KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI,
KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD,
KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME,
}
const methods = ["get", "post", "put", "delete", "patch"] as const
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
function effectOpenApi() {
return (effectSpec ??= OpenApi.fromApi(PublicApi))
}
function app(input?: { password?: string; username?: string }) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_SERVER_PASSWORD = input?.password
Flag.KILO_SERVER_USERNAME = input?.username
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
return Object.entries(spec.paths)
.flatMap(([path, item]) =>
methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
)
.sort()
}
function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) =>
methods
.filter((method) => item[method])
.map((method) => [
`${method.toUpperCase()} ${path}`,
(item[method]?.parameters ?? [])
.map(parameterKey)
.filter((param) => param !== undefined)
.sort(),
]),
),
)
}
function openApiRequestBodies(spec: OpenApiSpec) {
return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) =>
methods
.filter((method) => item[method])
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]),
),
)
}
type OpenApiSpec = {
components?: {
schemas?: Record<string, unknown>
}
paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
}
type OpenApiSchema = {
$ref?: string
allOf?: unknown[]
anyOf?: unknown[]
oneOf?: unknown[]
properties?: Record<string, unknown>
type?: string | string[]
}
type Operation = {
parameters?: unknown[]
responses?: unknown
requestBody?: unknown
}
type RequestBody = {
content?: Record<string, { schema?: OpenApiSchema }>
required?: boolean
}
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
"schema" in param ? param.schema : undefined,
)}`
}
function stableSchema(input: unknown): string {
return JSON.stringify(sortSchema(input))
}
function sortSchema(input: unknown): unknown {
if (Array.isArray(input)) return input.map(sortSchema)
if (!input || typeof input !== "object") return input
return Object.fromEntries(
Object.entries(input)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, sortSchema(value)]),
)
}
function parameterSchema(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
name: string
}): unknown {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
return param.schema
}
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
.sort(([left], [right]) => left.localeCompare(right)),
})
}
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
const resolved = (
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
) as OpenApiSchema | undefined
if (resolved?.properties) return "object"
if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object"
return resolved?.type ?? schema.type ?? "inline"
}
function responseContentTypes(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
status: string
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
if (!content || typeof content !== "object") {
return []
}
return Object.keys(content).sort()
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function fileUrl(input?: { directory?: string; token?: string }) {
const url = new URL(`http://localhost${FilePaths.content}`)
url.searchParams.set("path", "hello.txt")
if (input?.directory) url.searchParams.set("directory", input.directory)
if (input?.token) url.searchParams.set("auth_token", input.token)
return url
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi server", () => {
test("keeps Effect HttpApi behind the feature flag", () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = false
expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" })
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" })
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
"GET /api/session",
"GET /api/session/{sessionID}/context",
"GET /api/session/{sessionID}/message",
"POST /api/session/{sessionID}/compact",
"POST /api/session/{sessionID}/prompt",
"POST /api/session/{sessionID}/wait",
])
})
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapiHono())
const effect = openApiParameters(effectOpenApi())
expect(
Object.keys(hono)
.filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
).toEqual([])
})
test("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapiHono())
const effect = openApiRequestBodies(effectOpenApi())
expect(
Object.keys(hono)
.filter((route) => hono[route] !== effect[route])
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
).toEqual([])
})
test("matches SDK-affecting query parameter schemas", async () => {
const effect = effectOpenApi()
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
})
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
type: "number",
})
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
type: "integer",
minimum: 1,
maximum: 200,
})
expect(
parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }),
).toEqual({
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
})
})
test("matches SDK-affecting request schema details", () => {
const effect = effectOpenApi()
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
const sessionUpdateSchema =
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
? sessionUpdate.content?.["application/json"]?.schema
: undefined
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
const time = sessionUpdateProperties?.time
expect(time?.properties?.archived).toEqual({ type: "number" })
})
test("documents event routes as server-sent events", () => {
const effect = effectOpenApi()
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
})
test("allows requests when auth is disabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const response = await app().request(fileUrl(), {
headers: {
"x-kilo-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "hello" })
})
test("provides instance context to bridged handlers", async () => {
await using tmp = await tmpdir({ git: true })
const response = await app().request("/project/current", {
headers: {
"x-kilo-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ worktree: tmp.path })
})
test("requires credentials when auth is enabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const [missing, bad, good] = await Promise.all([
app({ password: "secret" }).request(fileUrl(), {
headers: { "x-kilo-directory": tmp.path },
}),
app({ password: "secret" }).request(fileUrl(), {
headers: {
authorization: authorization("kilo", "wrong"), // kilocode_change - match Hono username default
"x-kilo-directory": tmp.path,
},
}),
app({ password: "secret" }).request(fileUrl(), {
headers: {
authorization: authorization("kilo", "secret"), // kilocode_change - match Hono username default
"x-kilo-directory": tmp.path,
},
}),
])
expect(missing.status).toBe(401)
expect(bad.status).toBe(401)
expect(good.status).toBe(200)
})
test("accepts auth_token query credentials", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const response = await app({ password: "secret" }).request(
fileUrl({ token: Buffer.from("kilo:secret").toString("base64") }), // kilocode_change - match Hono username default
{
headers: {
"x-kilo-directory": tmp.path,
},
},
)
expect(response.status).toBe(200)
})
test("selects instance from query before directory header", async () => {
await using header = await tmpdir({ git: true })
await using query = await tmpdir({ git: true })
await Bun.write(`${header.path}/hello.txt`, "header")
await Bun.write(`${query.path}/hello.txt`, "query")
const response = await app().request(fileUrl({ directory: query.path }), {
headers: {
"x-kilo-directory": header.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "query" })
})
test("serves global health from Effect HttpApi", async () => {
const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ healthy: true })
})
test("serves global event stream from Effect HttpApi", async () => {
const response = await app().request(GlobalPaths.event)
if (!response.body) throw new Error("missing event stream body")
const reader = response.body.getReader()
const chunk = await reader.read()
await reader.cancel()
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
})
test("serves control log from Effect HttpApi", async () => {
const response = await app().request(ControlPaths.log, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
})
test("validates control auth without falling through to 404", async () => {
const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "api" }),
})
expect(response.status).toBe(400)
})
test("validates global upgrade without invoking installers", async () => {
const response = await app().request(GlobalPaths.upgrade, {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
})
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ success: false })
})
})