fix(httpapi): expose v2 request errors (#28495)
This commit is contained in:
@@ -4,7 +4,12 @@ import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import {
|
||||
Authorization,
|
||||
authorizationLayer,
|
||||
V2Authorization,
|
||||
v2AuthorizationLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const Api = HttpApi.make("test-authorization").add(
|
||||
@@ -21,17 +26,36 @@ const Api = HttpApi.make("test-authorization").add(
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const V2Api = HttpApi.make("test-v2-authorization").add(
|
||||
HttpApiGroup.make("test.v2")
|
||||
.add(
|
||||
HttpApiEndpoint.get("probe", "/api/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
)
|
||||
.middleware(V2Authorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
|
||||
handlers
|
||||
.handle("probe", () => Effect.succeed("ok"))
|
||||
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
)
|
||||
|
||||
const v2Handlers = HttpApiBuilder.group(V2Api, "test.v2", (handlers) =>
|
||||
handlers.handle("probe", () => Effect.succeed("ok")),
|
||||
)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const v2ApiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers), Layer.provide(v2AuthorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
|
||||
@@ -39,6 +63,7 @@ const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret")
|
||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
|
||||
|
||||
@@ -135,4 +160,15 @@ describe("HttpApi authorization middleware", () => {
|
||||
expect(response.status).toBe(401)
|
||||
}),
|
||||
)
|
||||
|
||||
itV2Secret.live("returns bodyful v2 unauthorized errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/api/probe")
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers["www-authenticate"] ?? "").toContain("Basic")
|
||||
expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -17,10 +17,7 @@ type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
|
||||
const allowedV2BuiltInEndpointErrors = [
|
||||
"GET /api/session 400 effect_HttpApiError_BadRequest",
|
||||
"GET /api/session/{sessionID}/message 400 effect_HttpApiError_BadRequest",
|
||||
]
|
||||
const allowedV2BuiltInEndpointErrors: string[] = []
|
||||
|
||||
function v2Operations(spec: OpenApiSpec) {
|
||||
return Object.entries(spec.paths).flatMap(([path, item]) =>
|
||||
|
||||
@@ -105,6 +105,24 @@ describe("schema-rejection wire shape", () => {
|
||||
{ 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* Effect.promise(async () =>
|
||||
Server.Default().app.request("/api/session?limit=0", {
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
)
|
||||
const parsed = JSON.parse(yield* Effect.promise(async () => res.text()))
|
||||
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
|
||||
|
||||
@@ -104,7 +104,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
(info) => Workspace.Service.use((svc) => svc.remove(info.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
Effect.sync(() => {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
@@ -115,7 +115,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
variant: ModelV2.VariantID.make("default"),
|
||||
},
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
time: { created: DateTime.makeUnsafe(time) },
|
||||
content: [],
|
||||
})
|
||||
Database.use((db) =>
|
||||
@@ -126,9 +126,9 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
time_created: 1,
|
||||
time_created: time,
|
||||
data: {
|
||||
time: { created: 1 },
|
||||
time: { created: time },
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
content: message.content,
|
||||
@@ -333,6 +333,73 @@ describe("session HttpApi", () => {
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns v2 public request errors for cursor and workspace query failures",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "v2 cursor" })
|
||||
yield* insertLegacyAssistantMessage(session.id, 1)
|
||||
yield* insertLegacyAssistantMessage(session.id, 2)
|
||||
|
||||
const sessionPage = yield* request(`/api/session?limit=1`, { headers })
|
||||
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
|
||||
expect(sessionCursor).toBeTruthy()
|
||||
|
||||
const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers })
|
||||
expect(cursorWithFilter.status).toBe(400)
|
||||
expect(yield* responseJson(cursorWithFilter)).toMatchObject({
|
||||
_tag: "InvalidCursorError",
|
||||
message: "Cursor cannot be combined with order or filters",
|
||||
})
|
||||
|
||||
const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
|
||||
expect(invalidSessionCursor.status).toBe(400)
|
||||
expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
|
||||
_tag: "InvalidCursorError",
|
||||
message: "Invalid cursor",
|
||||
})
|
||||
|
||||
const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, { headers })
|
||||
expect(mismatchedRouting.status).toBe(400)
|
||||
expect(yield* responseJson(mismatchedRouting)).toMatchObject({
|
||||
_tag: "InvalidCursorError",
|
||||
message: "Cursor does not match requested directory or workspace",
|
||||
})
|
||||
|
||||
const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
|
||||
expect(invalidWorkspace.status).toBe(400)
|
||||
expect(yield* responseJson(invalidWorkspace)).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
message: "Invalid workspace query parameter",
|
||||
field: "workspace",
|
||||
})
|
||||
|
||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
||||
const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next
|
||||
expect(messageCursor).toBeTruthy()
|
||||
|
||||
const messageCursorWithOrder = yield* request(
|
||||
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
|
||||
{ headers },
|
||||
)
|
||||
expect(messageCursorWithOrder.status).toBe(400)
|
||||
expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({
|
||||
_tag: "InvalidCursorError",
|
||||
message: "Cursor cannot be combined with order",
|
||||
})
|
||||
|
||||
const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers })
|
||||
expect(invalidMessageCursor.status).toBe(400)
|
||||
expect(yield* responseJson(invalidMessageCursor)).toMatchObject({
|
||||
_tag: "InvalidCursorError",
|
||||
message: "Invalid cursor",
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"serves sessions with migrated summary diffs missing file details",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user