| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- import { afterEach, describe, expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Effect, Layer } from "effect"
- import { HttpClientResponse } from "effect/unstable/http"
- import { eq } from "drizzle-orm"
- import { Database } from "@kirincode-ai/core/database/database"
- import { Session } from "@/session/session"
- import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
- import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
- import { MessageID, PartID } from "../../src/session/schema"
- import { PartTable } from "@kirincode-ai/core/session/sql"
- import { resetDatabase } from "../fixture/db"
- import { disposeAllInstances, TestInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
- const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer))
- const text = (response: HttpClientResponse.HttpClientResponse) => response.text
- afterEach(async () => {
- await disposeAllInstances()
- await resetDatabase()
- })
- const seedCorruptStepFinishPart = Effect.gen(function* () {
- const session = yield* Session.Service
- const info = yield* session.create({})
- const message = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID: info.id,
- agent: "build",
- model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
- time: { created: Date.now() },
- })
- const partID = PartID.ascending()
- yield* session.updatePart({
- id: partID,
- sessionID: info.id,
- messageID: message.id,
- type: "step-finish",
- reason: "stop",
- cost: 0,
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- })
- // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
- // that broke the user's session in the OMO/Windows bug.
- const { db } = yield* Database.Service
- yield* db
- .update(PartTable)
- .set({
- data: {
- type: "step-finish",
- reason: "stop",
- cost: 0,
- tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
- } as never, // drizzle's .set() can't narrow the discriminated union
- })
- .where(eq(PartTable.id, partID))
- .run()
- .pipe(Effect.orDie)
- return info.id
- })
- describe("schema-rejection wire shape", () => {
- it.instance(
- "Payload schema rejection returns NamedError-shaped JSON, not empty",
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ aggregate: -1 }),
- })
- const body = yield* text(res)
- expect(res.status).toBe(400)
- expect(res.headers["content-type"] ?? "").toContain("application/json")
- const parsed = JSON.parse(body)
- expect(parsed).toMatchObject({
- name: "BadRequest",
- data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
- })
- expect(parsed.data.message).toEqual(expect.any(String))
- expect(parsed.data.message.length).toBeGreaterThan(0)
- }),
- { git: true, config: { formatter: false, lsp: false } },
- )
- it.instance(
- "Query schema rejection returns NamedError-shaped JSON",
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- // /find/file?limit=999999 violates the limit constraint check.
- const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
- const res = yield* requestInDirectory(url, test.directory)
- const body = yield* text(res)
- expect(res.status).toBe(400)
- const parsed = JSON.parse(body)
- expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
- }),
- { git: true, config: { formatter: false, lsp: false } },
- )
- it.instance(
- "v2 query schema rejection returns InvalidRequestError JSON",
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
- const parsed = JSON.parse(yield* text(res))
- expect(res.status).toBe(400)
- expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
- expect(parsed.message).toEqual(expect.any(String))
- }),
- { git: true, config: { formatter: false, lsp: false } },
- )
- it.instance(
- "rejected request body never echoes back unbounded — message is capped",
- // Defense against DoS-amplification + secret-echo: Effect's Issue formatter
- // dumps the rejected `actual` verbatim. A multi-MB invalid array would
- // become a multi-MB 400 response and log line. Cap kicks in around 1KB.
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const huge = "X".repeat(50_000)
- const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ aggregate: huge }),
- })
- const body = yield* text(res)
- expect(res.status).toBe(400)
- // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
- expect(body.length).toBeLessThan(2 * 1024)
- const parsed = JSON.parse(body)
- expect(parsed.data.message).not.toContain(huge)
- }),
- { git: true, config: { formatter: false, lsp: false } },
- )
- it.instance(
- "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const sessionID = yield* seedCorruptStepFinishPart
- const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
- const res = yield* requestInDirectory(url, test.directory)
- const body = yield* text(res)
- expect(res.status).toBe(400)
- expect(res.headers["content-type"] ?? "").toContain("application/json")
- const parsed = JSON.parse(body)
- expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
- // Field path in data.message — what made this PR worth shipping.
- expect(parsed.data.message).toMatch(/output/)
- }),
- { config: { formatter: false, lsp: false } },
- )
- })
|