httpapi-schema-error-body.test.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { HttpClientResponse } from "effect/unstable/http"
  4. import { eq } from "drizzle-orm"
  5. import { Database } from "@opencode-ai/core/database/database"
  6. import { Session } from "@/session/session"
  7. import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
  8. import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
  9. import { MessageID, PartID } from "../../src/session/schema"
  10. import { PartTable } from "@opencode-ai/core/session/sql"
  11. import { resetDatabase } from "../fixture/db"
  12. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  13. import { testEffect } from "../lib/effect"
  14. import { ProviderV2 } from "@opencode-ai/core/provider"
  15. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  16. const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
  17. const text = (response: HttpClientResponse.HttpClientResponse) => response.text
  18. afterEach(async () => {
  19. await disposeAllInstances()
  20. await resetDatabase()
  21. })
  22. const seedCorruptStepFinishPart = Effect.gen(function* () {
  23. const session = yield* Session.Service
  24. const info = yield* session.create({})
  25. const message = yield* session.updateMessage({
  26. id: MessageID.ascending(),
  27. role: "user",
  28. sessionID: info.id,
  29. agent: "build",
  30. model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
  31. time: { created: Date.now() },
  32. })
  33. const partID = PartID.ascending()
  34. yield* session.updatePart({
  35. id: partID,
  36. sessionID: info.id,
  37. messageID: message.id,
  38. type: "step-finish",
  39. reason: "stop",
  40. cost: 0,
  41. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  42. })
  43. // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
  44. // that broke the user's session in the OMO/Windows bug.
  45. const { db } = yield* Database.Service
  46. yield* db
  47. .update(PartTable)
  48. .set({
  49. data: {
  50. type: "step-finish",
  51. reason: "stop",
  52. cost: 0,
  53. tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
  54. } as never, // drizzle's .set() can't narrow the discriminated union
  55. })
  56. .where(eq(PartTable.id, partID))
  57. .run()
  58. .pipe(Effect.orDie)
  59. return info.id
  60. })
  61. describe("schema-rejection wire shape", () => {
  62. it.instance(
  63. "Payload schema rejection returns NamedError-shaped JSON, not empty",
  64. () =>
  65. Effect.gen(function* () {
  66. const test = yield* TestInstance
  67. const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
  68. method: "POST",
  69. headers: { "content-type": "application/json" },
  70. body: JSON.stringify({ aggregate: -1 }),
  71. })
  72. const body = yield* text(res)
  73. expect(res.status).toBe(400)
  74. expect(res.headers["content-type"] ?? "").toContain("application/json")
  75. const parsed = JSON.parse(body)
  76. expect(parsed).toMatchObject({
  77. name: "BadRequest",
  78. data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
  79. })
  80. expect(parsed.data.message).toEqual(expect.any(String))
  81. expect(parsed.data.message.length).toBeGreaterThan(0)
  82. }),
  83. { git: true, config: { formatter: false, lsp: false } },
  84. )
  85. it.instance(
  86. "Query schema rejection returns NamedError-shaped JSON",
  87. () =>
  88. Effect.gen(function* () {
  89. const test = yield* TestInstance
  90. // /find/file?limit=999999 violates the limit constraint check.
  91. const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
  92. const res = yield* requestInDirectory(url, test.directory)
  93. const body = yield* text(res)
  94. expect(res.status).toBe(400)
  95. const parsed = JSON.parse(body)
  96. expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
  97. }),
  98. { git: true, config: { formatter: false, lsp: false } },
  99. )
  100. it.instance(
  101. "v2 query schema rejection returns InvalidRequestError JSON",
  102. () =>
  103. Effect.gen(function* () {
  104. const test = yield* TestInstance
  105. const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
  106. const parsed = JSON.parse(yield* text(res))
  107. expect(res.status).toBe(400)
  108. expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
  109. expect(parsed.message).toEqual(expect.any(String))
  110. }),
  111. { git: true, config: { formatter: false, lsp: false } },
  112. )
  113. it.instance(
  114. "rejected request body never echoes back unbounded — message is capped",
  115. // Defense against DoS-amplification + secret-echo: Effect's Issue formatter
  116. // dumps the rejected `actual` verbatim. A multi-MB invalid array would
  117. // become a multi-MB 400 response and log line. Cap kicks in around 1KB.
  118. () =>
  119. Effect.gen(function* () {
  120. const test = yield* TestInstance
  121. const huge = "X".repeat(50_000)
  122. const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
  123. method: "POST",
  124. headers: { "content-type": "application/json" },
  125. body: JSON.stringify({ aggregate: huge }),
  126. })
  127. const body = yield* text(res)
  128. expect(res.status).toBe(400)
  129. // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
  130. expect(body.length).toBeLessThan(2 * 1024)
  131. const parsed = JSON.parse(body)
  132. expect(parsed.data.message).not.toContain(huge)
  133. }),
  134. { git: true, config: { formatter: false, lsp: false } },
  135. )
  136. it.instance(
  137. "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
  138. () =>
  139. Effect.gen(function* () {
  140. const test = yield* TestInstance
  141. const sessionID = yield* seedCorruptStepFinishPart
  142. const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
  143. const res = yield* requestInDirectory(url, test.directory)
  144. const body = yield* text(res)
  145. expect(res.status).toBe(400)
  146. expect(res.headers["content-type"] ?? "").toContain("application/json")
  147. const parsed = JSON.parse(body)
  148. expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
  149. // Field path in data.message — what made this PR worth shipping.
  150. expect(parsed.data.message).toMatch(/output/)
  151. }),
  152. { config: { formatter: false, lsp: false } },
  153. )
  154. })