httpapi-parity.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Flag } from "@opencode-ai/core/flag/flag"
  4. import * as Log from "@opencode-ai/core/util/log"
  5. import { WithInstance } from "../../src/project/with-instance"
  6. import { Server } from "../../src/server/server"
  7. import { Session } from "@/session/session"
  8. import { MessageID } from "../../src/session/schema"
  9. import { ModelID, ProviderID } from "../../src/provider/schema"
  10. import { resetDatabase } from "../fixture/db"
  11. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  12. void Log.init({ print: false })
  13. const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
  14. afterEach(async () => {
  15. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
  16. await disposeAllInstances()
  17. await resetDatabase()
  18. })
  19. function app(experimental: boolean) {
  20. Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
  21. return experimental ? Server.Default().app : Server.Legacy().app
  22. }
  23. function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
  24. return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
  25. }
  26. function createSessionWithMessages(directory: string, count: number) {
  27. return WithInstance.provide({
  28. directory,
  29. fn: async () => {
  30. const session = await runSession(Session.Service.use((svc) => svc.create({})))
  31. for (let i = 0; i < count; i++) {
  32. await runSession(
  33. Effect.gen(function* () {
  34. const svc = yield* Session.Service
  35. yield* svc.updateMessage({
  36. id: MessageID.ascending(),
  37. role: "user",
  38. sessionID: session.id,
  39. agent: "build",
  40. model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
  41. time: { created: Date.now() },
  42. })
  43. }),
  44. )
  45. }
  46. return session.id
  47. },
  48. })
  49. }
  50. // ──────────────────────────────────────────────────────────────────────────────
  51. // Reproducer 1: Link header should reflect the request's actual Host header,
  52. // not "localhost". HttpApi uses `new URL(request.url, "http://localhost")`
  53. // which embeds localhost because request.url is path-only. Fix: use
  54. // `HttpServerRequest.toURL(request)` which honors the Host header.
  55. // ──────────────────────────────────────────────────────────────────────────────
  56. describe("Link header host", () => {
  57. test("HttpApi pagination Link header echoes request host", async () => {
  58. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  59. const sessionID = await createSessionWithMessages(tmp.path, 3)
  60. const response = await app(true).request(`/session/${sessionID}/message?limit=2`, {
  61. headers: {
  62. host: "opencode.test:4096",
  63. "x-opencode-directory": tmp.path,
  64. },
  65. })
  66. expect(response.status).toBe(200)
  67. const link = response.headers.get("link")
  68. expect(link).not.toBeNull()
  69. // Link should contain the request's Host, not "localhost".
  70. expect(link).toContain("opencode.test")
  71. expect(link).not.toContain("localhost")
  72. })
  73. })
  74. // ──────────────────────────────────────────────────────────────────────────────
  75. // Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
  76. // The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
  77. // `NotFoundError` from the service surfaces as a defect → 500. Hono's
  78. // equivalent maps to 404 via `errors.notFound`.
  79. //
  80. // Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
  81. // fork, abort, init, deleteMessage, command, shell, revert, unrevert.
  82. //
  83. // FIXME: unskip when mapNotFound coverage is added (next PR).
  84. // ──────────────────────────────────────────────────────────────────────────────
  85. describe("404 mapping for missing session", () => {
  86. test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
  87. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  88. const response = await app(true).request("/session/ses_does_not_exist/todo", {
  89. headers: { "x-opencode-directory": tmp.path },
  90. })
  91. expect(response.status).toBe(404)
  92. })
  93. })
  94. // ──────────────────────────────────────────────────────────────────────────────
  95. // Reproducer 3: 404 response body shape should match Hono's NamedError
  96. // envelope `{ name, data: { message } }`. HttpApi returns the typed-error
  97. // shape `{ _tag }` instead. SDK consumers reading `error.data.message`
  98. // see undefined.
  99. //
  100. // FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
  101. // ──────────────────────────────────────────────────────────────────────────────
  102. describe("Error JSON shape parity", () => {
  103. test.todo("HttpApi 404 body matches NamedError shape", async () => {
  104. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  105. const response = await app(true).request("/session/ses_does_not_exist", {
  106. headers: { "x-opencode-directory": tmp.path },
  107. })
  108. expect(response.status).toBe(404)
  109. const body = (await response.json()) as { name?: string; data?: { message?: string } }
  110. expect(body.name).toBe("NotFoundError")
  111. expect(typeof body.data?.message).toBe("string")
  112. })
  113. })