httpapi-public-openapi.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import { describe, expect, test } from "bun:test"
  2. import { OpenApi } from "effect/unstable/httpapi"
  3. import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
  4. type Method = "get" | "post" | "put" | "delete" | "patch"
  5. type OpenApiSchema = {
  6. readonly $ref?: string
  7. readonly anyOf?: ReadonlyArray<OpenApiSchema>
  8. readonly type?: string
  9. readonly enum?: readonly unknown[]
  10. readonly properties?: Record<string, OpenApiSchema>
  11. readonly required?: readonly string[]
  12. readonly contentSchema?: OpenApiSchema
  13. readonly contentMediaType?: string
  14. }
  15. type OpenApiResponse = {
  16. readonly description?: string
  17. readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
  18. }
  19. type OpenApiOperation = {
  20. readonly parameters?: ReadonlyArray<{
  21. readonly name: string
  22. readonly in: string
  23. readonly required?: boolean
  24. readonly schema?: { readonly type?: string }
  25. }>
  26. readonly responses?: Record<string, OpenApiResponse>
  27. readonly requestBody?: { readonly required?: boolean }
  28. readonly security?: unknown
  29. }
  30. type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
  31. type OpenApiSpec = {
  32. readonly paths: Record<string, OpenApiPathItem>
  33. readonly components: { readonly schemas: Record<string, OpenApiSchema> }
  34. }
  35. const methods = ["get", "post", "put", "delete", "patch"] as const
  36. const allowedV2BuiltInEndpointErrors: string[] = []
  37. function v2Operations(spec: OpenApiSpec) {
  38. return Object.entries(spec.paths).flatMap(([path, item]) =>
  39. path.startsWith("/api/")
  40. ? methods.flatMap((method) => {
  41. const operation = item[method]
  42. return operation ? [{ method, path, operation }] : []
  43. })
  44. : [],
  45. )
  46. }
  47. function responseRef(response: OpenApiResponse | undefined) {
  48. return response?.content?.["application/json"]?.schema?.$ref
  49. }
  50. function componentName(ref: string) {
  51. return ref.replace("#/components/schemas/", "")
  52. }
  53. function componentNames(response: OpenApiResponse | undefined) {
  54. const schema = response?.content?.["application/json"]?.schema
  55. if (!schema) return []
  56. return [
  57. ...new Set([schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))),
  58. ]
  59. }
  60. function isBuiltInEndpointError(name: string) {
  61. return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
  62. }
  63. describe("PublicApi OpenAPI v2 errors", () => {
  64. test("includes plugin-facing core schemas", () => {
  65. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  66. expect(Object.keys(spec.components.schemas)).toEqual(
  67. expect.arrayContaining([
  68. "CredentialValue",
  69. "IntegrationInputs",
  70. "IntegrationMethod",
  71. "IntegrationRef",
  72. "SkillV2Source",
  73. ]),
  74. )
  75. })
  76. test("documents nested legacy global sync events", () => {
  77. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  78. const schema = spec.components.schemas.SyncEventSessionCreated
  79. expect(schema?.required).toEqual(["type", "id", "syncEvent"])
  80. expect(schema?.properties?.type?.enum).toEqual(["sync"])
  81. expect(schema?.properties?.syncEvent).toMatchObject({
  82. required: ["type", "id", "seq", "aggregateID", "data"],
  83. properties: {
  84. type: { enum: ["session.created.1"] },
  85. id: { type: "string" },
  86. seq: { type: "number" },
  87. aggregateID: { type: "string" },
  88. },
  89. })
  90. })
  91. test("names the v2 event union without the SSE string wrapper collision", () => {
  92. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  93. expect(spec.components.schemas.V2Event1).toBeUndefined()
  94. expect(spec.components.schemas.V2Event?.anyOf?.length).toBeGreaterThan(0)
  95. expect(spec.components.schemas.V2EventStream).toMatchObject({
  96. type: "string",
  97. contentMediaType: "application/json",
  98. contentSchema: { $ref: "#/components/schemas/V2Event" },
  99. })
  100. expect(spec.paths["/api/event"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema).toEqual({
  101. $ref: "#/components/schemas/V2Event",
  102. })
  103. })
  104. test("preserves /api auth responses", () => {
  105. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  106. for (const route of v2Operations(spec)) {
  107. expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
  108. expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
  109. }
  110. })
  111. test("documents references separately from filesystem routes", () => {
  112. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  113. for (const path of ["/api/fs/read/*", "/api/fs/list"]) {
  114. expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
  115. }
  116. expect(spec.paths["/api/reference"]?.get).toBeDefined()
  117. })
  118. test("preserves required request bodies for v2 mutations", () => {
  119. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  120. for (const path of [
  121. "/api/session/{sessionID}/prompt",
  122. "/api/session/{sessionID}/permission/{requestID}/reply",
  123. "/api/session/{sessionID}/question/{requestID}/reply",
  124. ]) {
  125. expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
  126. }
  127. })
  128. test("documents integration discovery and connection routes", () => {
  129. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  130. for (const [method, path] of [
  131. ["get", "/api/integration"],
  132. ["get", "/api/integration/{integrationID}"],
  133. ["post", "/api/integration/{integrationID}/connect/key"],
  134. ["post", "/api/integration/{integrationID}/connect/oauth"],
  135. ["get", "/api/integration/attempt/{attemptID}"],
  136. ["post", "/api/integration/attempt/{attemptID}/complete"],
  137. ["delete", "/api/integration/attempt/{attemptID}"],
  138. ["delete", "/api/credential/{credentialID}"],
  139. ["patch", "/api/credential/{credentialID}"],
  140. ] as const) {
  141. expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined()
  142. }
  143. for (const path of [
  144. "/api/integration/{integrationID}/connect/key",
  145. "/api/integration/{integrationID}/connect/oauth",
  146. "/api/integration/attempt/{attemptID}/complete",
  147. ]) {
  148. expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
  149. }
  150. })
  151. test("does not rewrite /api endpoint errors to legacy error components", () => {
  152. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  153. const refs = v2Operations(spec)
  154. .flatMap((route) =>
  155. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  156. const ref = responseRef(response)
  157. return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
  158. }),
  159. )
  160. .filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
  161. expect(refs).toEqual([])
  162. })
  163. test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
  164. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  165. const builtInEndpointErrors = v2Operations(spec)
  166. .flatMap((route) =>
  167. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  168. if (status === "401") return []
  169. const ref = responseRef(response)
  170. if (!ref) return []
  171. const name = componentName(ref)
  172. return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
  173. }),
  174. )
  175. .sort()
  176. expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
  177. })
  178. test("documents v2 provider and model catalog errors", () => {
  179. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  180. expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
  181. "ServiceUnavailableError",
  182. )
  183. expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
  184. "ServiceUnavailableError",
  185. )
  186. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
  187. "ProviderNotFoundError",
  188. )
  189. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
  190. "ServiceUnavailableError",
  191. )
  192. })
  193. test("documents v2 session not-found errors", () => {
  194. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  195. for (const route of [
  196. ["post", "/api/session/{sessionID}/prompt"],
  197. ["post", "/api/session/{sessionID}/compact"],
  198. ["post", "/api/session/{sessionID}/wait"],
  199. ["get", "/api/session/{sessionID}/context"],
  200. ["get", "/api/session/{sessionID}/message"],
  201. ] as const) {
  202. expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toContain("SessionNotFoundError")
  203. }
  204. })
  205. test("documents v2 unfinished session mutation errors", () => {
  206. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  207. for (const route of [
  208. ["post", "/api/session/{sessionID}/compact"],
  209. ["post", "/api/session/{sessionID}/wait"],
  210. ] as const) {
  211. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
  212. "ServiceUnavailableError",
  213. )
  214. }
  215. })
  216. test("documents v2 session read data errors", () => {
  217. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  218. for (const route of [
  219. ["get", "/api/session/{sessionID}/context"],
  220. ["get", "/api/session/{sessionID}/message"],
  221. ] as const) {
  222. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
  223. /^UnknownError\d*$/,
  224. )
  225. }
  226. })
  227. test("documents session busy errors", () => {
  228. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  229. for (const route of [
  230. ["post", "/session/{sessionID}/shell"],
  231. ["post", "/session/{sessionID}/revert"],
  232. ["post", "/session/{sessionID}/unrevert"],
  233. ["delete", "/session/{sessionID}/message/{messageID}"],
  234. ] as const) {
  235. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
  236. "SessionBusyError",
  237. )
  238. }
  239. })
  240. test("documents permission and question not-found errors", () => {
  241. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  242. expect(
  243. componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
  244. ).toBe("PermissionNotFoundError")
  245. for (const route of [
  246. ["post", "/question/{requestID}/reply"],
  247. ["post", "/question/{requestID}/reject"],
  248. ] as const) {
  249. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  250. "QuestionNotFoundError",
  251. )
  252. }
  253. for (const route of [
  254. ["post", "/api/session/{sessionID}/question/{requestID}/reply"],
  255. ["post", "/api/session/{sessionID}/question/{requestID}/reject"],
  256. ] as const) {
  257. expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
  258. "QuestionNotFoundError",
  259. "SessionNotFoundError",
  260. ])
  261. }
  262. })
  263. test("documents MCP server not-found errors", () => {
  264. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  265. for (const route of [
  266. ["post", "/mcp/{name}/auth"],
  267. ["post", "/mcp/{name}/auth/authenticate"],
  268. ["post", "/mcp/{name}/auth/callback"],
  269. ["delete", "/mcp/{name}/auth"],
  270. ["post", "/mcp/{name}/connect"],
  271. ["post", "/mcp/{name}/disconnect"],
  272. ] as const) {
  273. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  274. "McpServerNotFoundError",
  275. )
  276. }
  277. })
  278. test("documents PTY resource and ticket errors", () => {
  279. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  280. for (const route of [
  281. ["get", "/pty/{ptyID}"],
  282. ["put", "/pty/{ptyID}"],
  283. ["delete", "/pty/{ptyID}"],
  284. ["post", "/pty/{ptyID}/connect-token"],
  285. ] as const) {
  286. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  287. "PtyNotFoundError",
  288. )
  289. }
  290. expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
  291. "PtyForbiddenError",
  292. )
  293. expect(
  294. spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
  295. ?.filter((parameter) => parameter.in === "query")
  296. .map((parameter) => parameter.name),
  297. ).toEqual(["directory", "workspace", "cursor", "ticket"])
  298. })
  299. test("documents project not-found errors", () => {
  300. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  301. expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
  302. "ProjectNotFoundError",
  303. )
  304. })
  305. })