mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 23:52:06 +00:00
test(server): add HttpApi auth exercise mode (#26386)
This commit is contained in:
@@ -5,22 +5,46 @@ import { parse } from "./assertions"
|
||||
import { runtime, type Runtime } from "./runtime"
|
||||
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
||||
|
||||
export function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext<unknown>) {
|
||||
type CallOptions = {
|
||||
auth?: {
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function call(
|
||||
backend: Backend,
|
||||
scenario: ActiveScenario,
|
||||
ctx: SeededContext<unknown>,
|
||||
options: CallOptions = {},
|
||||
) {
|
||||
return Effect.promise(async () =>
|
||||
capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture),
|
||||
capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
|
||||
)
|
||||
}
|
||||
|
||||
const appCache: Partial<Record<Backend, BackendApp>> = {}
|
||||
export function callAuthProbe(backend: Backend, scenario: ActiveScenario) {
|
||||
return Effect.promise(async () =>
|
||||
capture(
|
||||
await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)),
|
||||
scenario.capture,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function app(modules: Runtime, backend: Backend) {
|
||||
const appCache: Partial<Record<string, BackendApp>> = {}
|
||||
|
||||
function app(modules: Runtime, backend: Backend, options: CallOptions) {
|
||||
const username = options.auth?.username
|
||||
const password = options.auth?.password
|
||||
const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}`
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = undefined
|
||||
if (appCache[backend]) return appCache[backend]
|
||||
Flag.OPENCODE_SERVER_PASSWORD = password
|
||||
Flag.OPENCODE_SERVER_USERNAME = username
|
||||
if (appCache[cacheKey]) return appCache[cacheKey]
|
||||
if (backend === "legacy") {
|
||||
const legacy = modules.Server.Legacy().app
|
||||
return (appCache.legacy = {
|
||||
return (appCache[cacheKey] = {
|
||||
request: (input, init) => legacy.request(input, init),
|
||||
})
|
||||
}
|
||||
@@ -29,13 +53,13 @@ function app(modules: Runtime, backend: Backend) {
|
||||
modules.ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }),
|
||||
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return (appCache.effect = {
|
||||
return (appCache[cacheKey] = {
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return handler(
|
||||
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
|
||||
@@ -54,6 +78,20 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
function toAuthProbeRequest(scenario: ActiveScenario) {
|
||||
return new Request(new URL(authProbePath(scenario.path), "http://localhost"), {
|
||||
method: scenario.method,
|
||||
headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" },
|
||||
body: scenario.method === "GET" ? undefined : JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
function authProbePath(path: string) {
|
||||
return path
|
||||
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
|
||||
.replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
|
||||
}
|
||||
|
||||
async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
|
||||
const text = mode === "stream" ? await captureStream(response) : await response.text()
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Effect } from "effect"
|
||||
import { looksJson } from "./assertions"
|
||||
import type {
|
||||
ActiveScenario,
|
||||
AuthPolicy,
|
||||
BuilderState,
|
||||
CallResult,
|
||||
Comparison,
|
||||
@@ -21,11 +22,13 @@ class ScenarioBuilder<S = undefined> {
|
||||
path,
|
||||
name,
|
||||
project: { git: true },
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
|
||||
seed: () => Effect.succeed(undefined as S),
|
||||
request: (ctx) => ({ path, headers: ctx.headers() }),
|
||||
capture: "full",
|
||||
mutates: false,
|
||||
reset: true,
|
||||
auth: "protected",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,26 @@ class ScenarioBuilder<S = undefined> {
|
||||
return this.clone({ capture: "stream" })
|
||||
}
|
||||
|
||||
protected() {
|
||||
return this.auth("protected")
|
||||
}
|
||||
|
||||
public() {
|
||||
return this.auth("public")
|
||||
}
|
||||
|
||||
publicBypass() {
|
||||
return this.auth("public-bypass")
|
||||
}
|
||||
|
||||
ticketBypass() {
|
||||
return this.auth("ticket-bypass")
|
||||
}
|
||||
|
||||
private auth(auth: AuthPolicy) {
|
||||
return this.clone({ auth })
|
||||
}
|
||||
|
||||
/** Assert a non-JSON or shape-only response. */
|
||||
ok(status = 200, compare: Comparison = "status") {
|
||||
return this.done(compare, (_ctx, result) =>
|
||||
@@ -128,12 +151,15 @@ class ScenarioBuilder<S = undefined> {
|
||||
name: state.name,
|
||||
project: state.project,
|
||||
seed: state.seed,
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
|
||||
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
|
||||
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
|
||||
compare,
|
||||
capture: state.capture,
|
||||
mutates: state.mutates,
|
||||
reset: state.reset,
|
||||
auth: state.auth,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { TestLLMServer } from "../../lib/llm-server"
|
||||
import path from "path"
|
||||
import { array, boolean, check, isRecord, message, object, stable } from "./assertions"
|
||||
import { controlledPtyInput, http, pending, route } from "./dsl"
|
||||
import { controlledPtyInput, http, route } from "./dsl"
|
||||
import {
|
||||
cleanupExercisePaths,
|
||||
exerciseConfigDirectory,
|
||||
@@ -1192,6 +1192,7 @@ const main = Effect.gen(function* () {
|
||||
return yield* Effect.fail(new Error("one or more scenarios are skipped"))
|
||||
if (options.failOnMissing && missing.length > 0)
|
||||
return yield* Effect.fail(new Error("one or more routes have no scenario"))
|
||||
return undefined
|
||||
})
|
||||
|
||||
Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then(
|
||||
|
||||
@@ -19,7 +19,8 @@ export function coverageResult(scenario: Scenario): Result {
|
||||
|
||||
export function parseOptions(args: string[]): Options {
|
||||
const mode = option(args, "--mode") ?? "effect"
|
||||
if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`)
|
||||
if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth")
|
||||
throw new Error(`invalid --mode ${mode}`)
|
||||
return {
|
||||
mode,
|
||||
include: option(args, "--include"),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ModelID, ProviderID } from "../../../src/provider/schema"
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { stable } from "./assertions"
|
||||
import { call } from "./backend"
|
||||
import { call, callAuthProbe } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type {
|
||||
@@ -32,6 +32,8 @@ export function runScenario(options: Options) {
|
||||
}
|
||||
|
||||
function runActive(options: Options, scenario: ActiveScenario) {
|
||||
if (options.mode === "auth") return runAuth(scenario)
|
||||
|
||||
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
|
||||
return Effect.gen(function* () {
|
||||
const effect = yield* runBackend("effect", scenario)
|
||||
@@ -53,6 +55,21 @@ function runActive(options: Options, scenario: ActiveScenario) {
|
||||
)
|
||||
}
|
||||
|
||||
function runAuth(scenario: ActiveScenario) {
|
||||
return Effect.gen(function* () {
|
||||
const effect = yield* callAuthProbe("effect", scenario)
|
||||
const legacy = yield* callAuthProbe("legacy", scenario)
|
||||
if (scenario.auth === "protected") {
|
||||
if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`)
|
||||
if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.status === 401) throw new Error("effect auth expected public access, got 401")
|
||||
if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401")
|
||||
})
|
||||
}
|
||||
|
||||
function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) {
|
||||
return withContext(scenario, (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -73,7 +90,10 @@ function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<un
|
||||
: undefined
|
||||
return { dir, llm }
|
||||
}),
|
||||
(ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore),
|
||||
(ctx) =>
|
||||
Effect.promise(async () => {
|
||||
await ctx.dir?.[Symbol.asyncDispose]()
|
||||
}).pipe(Effect.ignore),
|
||||
).pipe(
|
||||
Effect.flatMap((context) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -10,10 +10,11 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
|
||||
|
||||
export type Method = (typeof Methods)[number]
|
||||
export type OpenApiMethod = (typeof OpenApiMethods)[number]
|
||||
export type Mode = "effect" | "parity" | "coverage"
|
||||
export type Mode = "effect" | "parity" | "coverage" | "auth"
|
||||
export type Backend = "effect" | "legacy"
|
||||
export type Comparison = "none" | "status" | "json"
|
||||
export type CaptureMode = "full" | "stream"
|
||||
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"
|
||||
export type ProjectOptions = { git?: boolean; config?: Partial<Config.Info>; llm?: boolean }
|
||||
export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
|
||||
export type JsonObject = Record<string, unknown>
|
||||
@@ -79,6 +80,7 @@ export type ActiveScenario = {
|
||||
capture: CaptureMode
|
||||
mutates: boolean
|
||||
reset: boolean
|
||||
auth: AuthPolicy
|
||||
}
|
||||
|
||||
export type BuilderState<S> = {
|
||||
@@ -91,6 +93,7 @@ export type BuilderState<S> = {
|
||||
capture: CaptureMode
|
||||
mutates: boolean
|
||||
reset: boolean
|
||||
auth: AuthPolicy
|
||||
}
|
||||
|
||||
export type TodoScenario = {
|
||||
|
||||
Reference in New Issue
Block a user