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

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