httpapi-instance.test.ts 10 KB

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