| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- import { afterEach, describe, expect } from "bun:test"
- import { Effect, Schema } from "effect"
- import { OpenApi } from "effect/unstable/httpapi"
- import { Flag } from "@kirincode-ai/core/flag/flag"
- import { Server } from "../../src/server/server"
- import { SessionID } from "../../src/session/schema"
- import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
- import {
- FilePaths,
- FileQuery,
- FindFileQuery,
- FindTextQuery,
- } from "../../src/server/routes/instance/httpapi/groups/file"
- import {
- ExperimentalPaths,
- SessionListQuery as ExperimentalSessionListQuery,
- ToolListQuery,
- } from "../../src/server/routes/instance/httpapi/groups/experimental"
- import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
- import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
- import {
- ListQuery as SessionListQuery,
- MessagesQuery,
- SessionPaths,
- } from "../../src/server/routes/instance/httpapi/groups/session"
- import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
- import { SessionMessagesQuery } from "@kirincode-ai/protocol/groups/message"
- import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
- import { resetDatabase } from "../fixture/db"
- import { disposeAllInstances, tmpdir } from "../fixture/fixture"
- import { it } from "../lib/effect"
- const originalWorkspaces = Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES
- type Method = "get" | "post" | "put" | "delete" | "patch"
- type QuerySchema = { readonly fields: Record<string, unknown> }
- type OpenApiSchema = {
- readonly anyOf?: readonly OpenApiSchema[]
- readonly enum?: readonly string[]
- readonly maximum?: number
- readonly minimum?: number
- readonly pattern?: string
- readonly type?: string
- }
- type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
- type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
- const openApiDriftRoutes = [
- { method: "get", path: SessionPaths.list, query: SessionListQuery },
- { method: "get", path: SessionPaths.messages, query: MessagesQuery },
- { method: "get", path: FilePaths.findFile, query: FindFileQuery },
- { method: "get", path: FilePaths.findText, query: FindTextQuery },
- { method: "get", path: FilePaths.list, query: FileQuery },
- { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
- { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
- { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
- { method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
- ] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
- const numericSdkQueryParams = [
- { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
- { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
- { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
- { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
- { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
- { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
- {
- method: "get",
- path: SessionPaths.messages,
- name: "limit",
- schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
- },
- { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
- ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
- const booleanSdkQueryParams = [
- { method: "get", path: ExperimentalPaths.session, name: "roots" },
- { method: "get", path: ExperimentalPaths.session, name: "archived" },
- { method: "get", path: SessionPaths.list, name: "roots" },
- ] satisfies Array<{ method: Method; path: string; name: string }>
- const queryParamPatterns = [
- { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" },
- ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
- const pathParamPatterns = [
- { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
- { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
- { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
- { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
- { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
- { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
- { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
- { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" },
- ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
- function app() {
- return Server.Default().app
- }
- function request(url: string, init?: RequestInit) {
- return Effect.promise(async () => app().request(url, init))
- }
- function withTmp<A, E, R>(
- options: Parameters<typeof tmpdir>[0],
- fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
- ) {
- return Effect.acquireRelease(
- Effect.promise(() => tmpdir(options)),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(Effect.flatMap(fn))
- }
- function openApiPath(path: string) {
- return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
- }
- function queryParameters(operation: OpenApiOperation | undefined) {
- return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
- }
- function queryParameter(operation: OpenApiOperation | undefined, name: string) {
- return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
- }
- function pathParameter(operation: OpenApiOperation | undefined, name: string) {
- return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
- }
- function assertAdvertisedQueryParamsAreRuntimeFields(input: {
- readonly method: Method
- readonly operation: OpenApiOperation | undefined
- readonly path: string
- readonly query: QuerySchema
- }) {
- const runtimeFields = new Set(Object.keys(input.query.fields))
- const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
- expect(
- advertisedOnly,
- `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
- ).toEqual([])
- }
- afterEach(async () => {
- Flag.KIRINCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
- await disposeAllInstances()
- await resetDatabase()
- })
- // Regression for the "OpenAPI advertises ?directory&workspace, runtime
- // rejects them" drift class. Each affected route must accept both params
- // without 400.
- describe("httpapi query schema drift", () => {
- const routingParams = (dir: string) =>
- `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
- const expectNotSchemaRejection = (status: number, url: string) => {
- expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
- }
- it.effect(
- "boolean query schema accepts only true and false strings",
- Effect.sync(() => {
- const decode = Schema.decodeUnknownSync(QueryBoolean)
- const encode = Schema.encodeUnknownSync(QueryBoolean)
- expect(decode("true")).toBe(true)
- expect(decode("false")).toBe(false)
- expect(encode(true)).toBe("true")
- expect(encode(false)).toBe("false")
- for (const input of ["1", "yes", "True", "", true, false]) {
- expect(() => decode(input)).toThrow()
- }
- }),
- )
- it.effect(
- "OpenAPI query params are declared by runtime query schemas",
- Effect.sync(() => {
- const spec = OpenApi.fromApi(PublicApi)
- for (const route of openApiDriftRoutes) {
- assertAdvertisedQueryParamsAreRuntimeFields({
- ...route,
- operation: spec.paths[openApiPath(route.path)]?.[route.method],
- })
- }
- }),
- )
- it.effect(
- "OpenAPI query and path schemas preserve compatibility metadata",
- Effect.sync(() => {
- const spec = OpenApi.fromApi(PublicApi)
- for (const expected of numericSdkQueryParams) {
- expect(
- queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
- `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
- ).toEqual(expected.schema)
- }
- for (const expected of booleanSdkQueryParams) {
- expect(
- queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
- `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
- ).toEqual(QueryBooleanOpenApi)
- }
- for (const expected of queryParamPatterns) {
- expect(
- queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
- `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
- ).toEqual({ type: "string", pattern: expected.pattern })
- }
- for (const expected of pathParamPatterns) {
- expect(
- pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
- `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
- ).toEqual({ type: "string", pattern: expected.pattern })
- }
- }),
- )
- it.effect(
- "drift assertion catches spec-only workspace query params",
- Effect.sync(() => {
- expect(() =>
- assertAdvertisedQueryParamsAreRuntimeFields({
- method: "get",
- operation: {
- parameters: [
- { name: "directory", in: "query" },
- { name: "workspace", in: "query" },
- ],
- },
- path: "/fixture",
- query: Schema.Struct({}),
- }),
- ).toThrow("advertises query params not accepted by runtime schema")
- }),
- )
- it.live(
- "session list accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/session?${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "session messages accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "file find/file accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/find/file?query=foo&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "file find/text accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/find?pattern=foo&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "file read accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/file?path=foo&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "experimental session list accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/experimental/session?${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "experimental tool list accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- it.live(
- "vcs diff accepts directory and workspace",
- withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
- Effect.gen(function* () {
- const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
- const response = yield* request(url)
- expectNotSchemaRejection(response.status, url)
- }),
- ),
- )
- })
|