httpapi-bridge.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { Flag } from "@opencode-ai/core/flag/flag"
  3. import { Instance } from "../../src/project/instance"
  4. import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
  5. import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
  6. import { Server } from "../../src/server/server"
  7. import * as Log from "@opencode-ai/core/util/log"
  8. import { OpenApi } from "effect/unstable/httpapi"
  9. import { resetDatabase } from "../fixture/db"
  10. import { tmpdir } from "../fixture/fixture"
  11. void Log.init({ print: false })
  12. const original = {
  13. OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
  14. OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
  15. OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
  16. }
  17. const methods = ["get", "post", "put", "delete", "patch"] as const
  18. function app(input?: { password?: string; username?: string }) {
  19. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
  20. Flag.OPENCODE_SERVER_PASSWORD = input?.password
  21. Flag.OPENCODE_SERVER_USERNAME = input?.username
  22. return Server.Default().app
  23. }
  24. function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
  25. return Object.entries(spec.paths)
  26. .flatMap(([path, item]) =>
  27. methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
  28. )
  29. .sort()
  30. }
  31. function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
  32. return Object.fromEntries(
  33. Object.entries(spec.paths).flatMap(([path, item]) =>
  34. methods
  35. .filter((method) => item[method])
  36. .map((method) => [
  37. `${method.toUpperCase()} ${path}`,
  38. (item[method]?.parameters ?? [])
  39. .map(parameterKey)
  40. .filter((param) => param !== undefined)
  41. .sort(),
  42. ]),
  43. ),
  44. )
  45. }
  46. function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
  47. return Object.fromEntries(
  48. Object.entries(spec.paths).flatMap(([path, item]) =>
  49. methods
  50. .filter((method) => item[method])
  51. .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]),
  52. ),
  53. )
  54. }
  55. type Operation = {
  56. parameters?: unknown[]
  57. requestBody?: unknown
  58. }
  59. type RequestBody = {
  60. content?: Record<string, { schema?: { $ref?: string; type?: string } }>
  61. required?: boolean
  62. }
  63. function parameterKey(param: unknown) {
  64. if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
  65. if (typeof param.in !== "string" || typeof param.name !== "string") return
  66. return `${param.in}:${param.name}:${"required" in param && param.required === true}`
  67. }
  68. function requestBodyKey(body: unknown) {
  69. if (!body || typeof body !== "object" || !("content" in body)) return ""
  70. const requestBody = body as RequestBody
  71. return JSON.stringify({
  72. required: requestBody.required === true,
  73. content: Object.entries(requestBody.content ?? {})
  74. .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"])
  75. .sort(),
  76. })
  77. }
  78. function authorization(username: string, password: string) {
  79. return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
  80. }
  81. function fileUrl(input?: { directory?: string; token?: string }) {
  82. const url = new URL(`http://localhost${FilePaths.content}`)
  83. url.searchParams.set("path", "hello.txt")
  84. if (input?.directory) url.searchParams.set("directory", input.directory)
  85. if (input?.token) url.searchParams.set("auth_token", input.token)
  86. return url
  87. }
  88. afterEach(async () => {
  89. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
  90. Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
  91. Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
  92. await Instance.disposeAll()
  93. await resetDatabase()
  94. })
  95. describe("HttpApi server", () => {
  96. test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
  97. const honoRoutes = openApiRouteKeys(await Server.openapi())
  98. const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
  99. expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
  100. expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
  101. })
  102. test("matches generated OpenAPI route parameters", async () => {
  103. const hono = openApiParameters(await Server.openapi())
  104. const effect = openApiParameters(OpenApi.fromApi(PublicApi))
  105. expect(
  106. Object.keys(hono)
  107. .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route]))
  108. .map((route) => ({ route, hono: hono[route], effect: effect[route] })),
  109. ).toEqual([])
  110. })
  111. test("matches generated OpenAPI request body shape", async () => {
  112. const hono = openApiRequestBodies(await Server.openapi())
  113. const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi))
  114. expect(
  115. Object.keys(hono)
  116. .filter((route) => hono[route] !== effect[route])
  117. .map((route) => ({ route, hono: hono[route], effect: effect[route] })),
  118. ).toEqual([])
  119. })
  120. test("allows requests when auth is disabled", async () => {
  121. await using tmp = await tmpdir({ git: true })
  122. await Bun.write(`${tmp.path}/hello.txt`, "hello")
  123. const response = await app().request(fileUrl(), {
  124. headers: {
  125. "x-opencode-directory": tmp.path,
  126. },
  127. })
  128. expect(response.status).toBe(200)
  129. expect(await response.json()).toMatchObject({ content: "hello" })
  130. })
  131. test("provides instance context to bridged handlers", async () => {
  132. await using tmp = await tmpdir({ git: true })
  133. const response = await app().request("/project/current", {
  134. headers: {
  135. "x-opencode-directory": tmp.path,
  136. },
  137. })
  138. expect(response.status).toBe(200)
  139. expect(await response.json()).toMatchObject({ worktree: tmp.path })
  140. })
  141. test("requires credentials when auth is enabled", async () => {
  142. await using tmp = await tmpdir({ git: true })
  143. await Bun.write(`${tmp.path}/hello.txt`, "hello")
  144. const [missing, bad, good] = await Promise.all([
  145. app({ password: "secret" }).request(fileUrl(), {
  146. headers: { "x-opencode-directory": tmp.path },
  147. }),
  148. app({ password: "secret" }).request(fileUrl(), {
  149. headers: {
  150. authorization: authorization("opencode", "wrong"),
  151. "x-opencode-directory": tmp.path,
  152. },
  153. }),
  154. app({ password: "secret" }).request(fileUrl(), {
  155. headers: {
  156. authorization: authorization("opencode", "secret"),
  157. "x-opencode-directory": tmp.path,
  158. },
  159. }),
  160. ])
  161. expect(missing.status).toBe(401)
  162. expect(bad.status).toBe(401)
  163. expect(good.status).toBe(200)
  164. })
  165. test("accepts auth_token query credentials", async () => {
  166. await using tmp = await tmpdir({ git: true })
  167. await Bun.write(`${tmp.path}/hello.txt`, "hello")
  168. const response = await app({ password: "secret" }).request(
  169. fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }),
  170. {
  171. headers: {
  172. "x-opencode-directory": tmp.path,
  173. },
  174. },
  175. )
  176. expect(response.status).toBe(200)
  177. })
  178. test("selects instance from query before directory header", async () => {
  179. await using header = await tmpdir({ git: true })
  180. await using query = await tmpdir({ git: true })
  181. await Bun.write(`${header.path}/hello.txt`, "header")
  182. await Bun.write(`${query.path}/hello.txt`, "query")
  183. const response = await app().request(fileUrl({ directory: query.path }), {
  184. headers: {
  185. "x-opencode-directory": header.path,
  186. },
  187. })
  188. expect(response.status).toBe(200)
  189. expect(await response.json()).toMatchObject({ content: "query" })
  190. })
  191. })