httpapi-instance.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  2. import { Flag } from "@opencode-ai/core/flag/flag"
  3. import { describe, expect } from "bun:test"
  4. import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
  5. import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
  6. import * as Socket from "effect/unstable/socket/Socket"
  7. import { WorkspaceID } from "../../src/control-plane/schema"
  8. import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
  9. import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
  10. import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
  11. import { PermissionID } from "../../src/permission/schema"
  12. import { QuestionID } from "../../src/question/schema"
  13. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  14. import { HEADER as FenceHeader } from "../../src/server/shared/fence"
  15. import { resetDatabase } from "../fixture/db"
  16. import { tmpdirScoped } from "../fixture/fixture"
  17. import { testEffect } from "../lib/effect"
  18. // Flip the experimental workspaces flag so SyncEvent.run actually writes to
  19. // EventSequenceTable (the source of truth the fence middleware reads). Reset
  20. // the database around the test so per-instance state does not leak between
  21. // runs. resetDatabase() already calls disposeAllInstances(), so we don't
  22. // repeat it.
  23. const testStateLayer = Layer.effectDiscard(
  24. Effect.gen(function* () {
  25. const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  26. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  27. yield* Effect.promise(() => resetDatabase())
  28. yield* Effect.addFinalizer(() =>
  29. Effect.promise(async () => {
  30. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  31. await resetDatabase()
  32. }),
  33. )
  34. }),
  35. )
  36. // Mount the production HttpApi route tree on a real Node HTTP server bound to
  37. // 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
  38. // keeps the test wired directly through the same route layer production uses.
  39. const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
  40. HttpApiApp.routes,
  41. { disableListenLog: true, disableLogger: true },
  42. )
  43. const httpApiServerLayer = servedRoutes.pipe(
  44. Layer.provide(Socket.layerWebSocketConstructorGlobal),
  45. Layer.provideMerge(NodeHttpServer.layerTest),
  46. Layer.provideMerge(NodeServices.layer),
  47. )
  48. const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
  49. const handlerContext = Context.empty() as Context.Context<unknown>
  50. const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
  51. describe("instance HttpApi", () => {
  52. it.live("serves the OpenAPI document", () =>
  53. Effect.gen(function* () {
  54. const response = yield* HttpClient.get("/doc")
  55. expect(response.status).toBe(200)
  56. expect(response.headers["content-type"]).toContain("application/json")
  57. expect(yield* response.json).toMatchObject({
  58. openapi: expect.any(String),
  59. info: expect.any(Object),
  60. paths: expect.objectContaining({
  61. "/global/health": expect.any(Object),
  62. "/session": expect.any(Object),
  63. }),
  64. })
  65. }),
  66. )
  67. it.live("emits a sync fence header for fixed-workspace mutations", () =>
  68. Effect.gen(function* () {
  69. const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
  70. Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
  71. yield* Effect.addFinalizer(() =>
  72. Effect.sync(() => {
  73. Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
  74. }),
  75. )
  76. const dir = yield* tmpdirScoped({ git: true })
  77. const response = yield* HttpClientRequest.post(SessionPaths.create).pipe(
  78. directoryHeader(dir),
  79. HttpClientRequest.bodyJson({ title: "fenced" }),
  80. Effect.flatMap(HttpClient.execute),
  81. )
  82. expect(response.status).toBe(200)
  83. expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({})
  84. }),
  85. )
  86. it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
  87. Effect.gen(function* () {
  88. const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
  89. Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
  90. yield* Effect.addFinalizer(() =>
  91. Effect.sync(() => {
  92. Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
  93. }),
  94. )
  95. const dir = yield* tmpdirScoped({ git: true })
  96. const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute)
  97. const log = yield* HttpClientRequest.post(ControlPaths.log).pipe(
  98. directoryHeader(dir),
  99. HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }),
  100. Effect.flatMap(HttpClient.execute),
  101. )
  102. expect(read.status).toBe(200)
  103. expect(read.headers[FenceHeader]).toBeUndefined()
  104. expect(log.status).toBe(200)
  105. expect(log.headers[FenceHeader]).toBeUndefined()
  106. }),
  107. )
  108. it.live("rejects malformed permission and question request ids", () =>
  109. Effect.gen(function* () {
  110. const dir = yield* tmpdirScoped({ git: true })
  111. const request = (path: string, init?: RequestInit) =>
  112. Effect.promise(() =>
  113. HttpApiApp.webHandler().handler(
  114. new Request(`http://localhost${path}`, {
  115. ...init,
  116. headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
  117. }),
  118. handlerContext,
  119. ),
  120. )
  121. const [permission, questionReply, questionReject] = yield* Effect.all(
  122. [
  123. request("/permission/invalid-permission-id/reply", {
  124. method: "POST",
  125. body: JSON.stringify({ reply: "once" }),
  126. }),
  127. request("/question/invalid-question-id/reply", {
  128. method: "POST",
  129. body: JSON.stringify({ answers: [["Yes"]] }),
  130. }),
  131. request("/question/invalid-question-id/reject", { method: "POST" }),
  132. ],
  133. { concurrency: "unbounded" },
  134. )
  135. expect(permission.status).toBe(400)
  136. expect(questionReply.status).toBe(400)
  137. expect(questionReject.status).toBe(400)
  138. }),
  139. )
  140. it.live("returns typed not found bodies for missing permission and question requests", () =>
  141. Effect.gen(function* () {
  142. const dir = yield* tmpdirScoped({ git: true })
  143. const request = (path: string, init?: RequestInit) =>
  144. Effect.promise(() =>
  145. HttpApiApp.webHandler().handler(
  146. new Request(`http://localhost${path}`, {
  147. ...init,
  148. headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
  149. }),
  150. handlerContext,
  151. ),
  152. )
  153. const permissionID = PermissionID.ascending()
  154. const questionReplyID = QuestionID.ascending()
  155. const questionRejectID = QuestionID.ascending()
  156. const [permission, questionReply, questionReject] = yield* Effect.all(
  157. [
  158. request(`/permission/${permissionID}/reply`, {
  159. method: "POST",
  160. body: JSON.stringify({ reply: "once" }),
  161. }),
  162. request(`/question/${questionReplyID}/reply`, {
  163. method: "POST",
  164. body: JSON.stringify({ answers: [["Yes"]] }),
  165. }),
  166. request(`/question/${questionRejectID}/reject`, { method: "POST" }),
  167. ],
  168. { concurrency: "unbounded" },
  169. )
  170. expect(permission.status).toBe(404)
  171. expect(yield* Effect.promise(() => permission.json())).toEqual({
  172. _tag: "PermissionNotFoundError",
  173. requestID: permissionID,
  174. message: `Permission request not found: ${permissionID}`,
  175. })
  176. expect(questionReply.status).toBe(404)
  177. expect(yield* Effect.promise(() => questionReply.json())).toEqual({
  178. _tag: "QuestionNotFoundError",
  179. requestID: questionReplyID,
  180. message: `Question request not found: ${questionReplyID}`,
  181. })
  182. expect(questionReject.status).toBe(404)
  183. expect(yield* Effect.promise(() => questionReject.json())).toEqual({
  184. _tag: "QuestionNotFoundError",
  185. requestID: questionRejectID,
  186. message: `Question request not found: ${questionRejectID}`,
  187. })
  188. }),
  189. )
  190. it.live("serves path and VCS read endpoints", () =>
  191. Effect.gen(function* () {
  192. const dir = yield* tmpdirScoped({ git: true })
  193. const fs = yield* FileSystem.FileSystem
  194. const path = yield* Path.Path
  195. yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
  196. const [paths, vcs, diff] = yield* Effect.all(
  197. [
  198. HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
  199. HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
  200. HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
  201. HttpClientRequest.setUrlParam("mode", "git"),
  202. directoryHeader(dir),
  203. HttpClient.execute,
  204. ),
  205. ],
  206. { concurrency: "unbounded" },
  207. )
  208. expect(paths.status).toBe(200)
  209. expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
  210. expect(vcs.status).toBe(200)
  211. expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
  212. expect(diff.status).toBe(200)
  213. expect(yield* diff.json).toContainEqual(
  214. expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
  215. )
  216. }),
  217. )
  218. })