| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- import { afterEach, describe, expect, test } from "bun:test"
- import { Flag } from "@opencode-ai/core/flag/flag"
- import { Instance } from "../../src/project/instance"
- import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
- import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
- import { Server } from "../../src/server/server"
- import * as Log from "@opencode-ai/core/util/log"
- import { OpenApi } from "effect/unstable/httpapi"
- import { resetDatabase } from "../fixture/db"
- import { tmpdir } from "../fixture/fixture"
- void Log.init({ print: false })
- const original = {
- OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
- OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
- OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
- }
- const methods = ["get", "post", "put", "delete", "patch"] as const
- function app(input?: { password?: string; username?: string }) {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
- Flag.OPENCODE_SERVER_PASSWORD = input?.password
- Flag.OPENCODE_SERVER_USERNAME = input?.username
- return Server.Default().app
- }
- 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: { 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}`, requestBodyKey(item[method]?.requestBody)]),
- ),
- )
- }
- type Operation = {
- parameters?: unknown[]
- requestBody?: unknown
- }
- type RequestBody = {
- content?: Record<string, { schema?: { $ref?: string; type?: string } }>
- required?: boolean
- }
- function parameterKey(param: unknown) {
- if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
- if (typeof param.in !== "string" || typeof param.name !== "string") return
- return `${param.in}:${param.name}:${"required" in param && param.required === true}`
- }
- function requestBodyKey(body: unknown) {
- if (!body || typeof body !== "object" || !("content" in body)) return ""
- const requestBody = body as RequestBody
- return JSON.stringify({
- required: requestBody.required === true,
- content: Object.entries(requestBody.content ?? {})
- .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"])
- .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.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
- Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
- Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
- await Instance.disposeAll()
- await resetDatabase()
- })
- describe("HttpApi server", () => {
- test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
- const honoRoutes = openApiRouteKeys(await Server.openapi())
- const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
- expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
- expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
- })
- test("matches generated OpenAPI route parameters", async () => {
- const hono = openApiParameters(await Server.openapi())
- const effect = openApiParameters(OpenApi.fromApi(PublicApi))
- 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.openapi())
- const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi))
- expect(
- Object.keys(hono)
- .filter((route) => hono[route] !== effect[route])
- .map((route) => ({ route, hono: hono[route], effect: effect[route] })),
- ).toEqual([])
- })
- 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-opencode-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-opencode-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-opencode-directory": tmp.path },
- }),
- app({ password: "secret" }).request(fileUrl(), {
- headers: {
- authorization: authorization("opencode", "wrong"),
- "x-opencode-directory": tmp.path,
- },
- }),
- app({ password: "secret" }).request(fileUrl(), {
- headers: {
- authorization: authorization("opencode", "secret"),
- "x-opencode-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("opencode:secret").toString("base64") }),
- {
- headers: {
- "x-opencode-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-opencode-directory": header.path,
- },
- })
- expect(response.status).toBe(200)
- expect(await response.json()).toMatchObject({ content: "query" })
- })
- })
|