test(server): add HttpApi auth exercise mode (#26386)

This commit is contained in:
Kit Langton
2026-05-08 14:05:46 -04:00
committed by GitHub
parent daa3116f4b
commit 75308ea47d
6 changed files with 104 additions and 15 deletions

View File

@@ -5,22 +5,46 @@ import { parse } from "./assertions"
import { runtime, type Runtime } from "./runtime" import { runtime, type Runtime } from "./runtime"
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" 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 () => 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_EXPERIMENTAL_HTTPAPI = backend === "effect"
Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_PASSWORD = password
Flag.OPENCODE_SERVER_USERNAME = undefined Flag.OPENCODE_SERVER_USERNAME = username
if (appCache[backend]) return appCache[backend] if (appCache[cacheKey]) return appCache[cacheKey]
if (backend === "legacy") { if (backend === "legacy") {
const legacy = modules.Server.Legacy().app const legacy = modules.Server.Legacy().app
return (appCache.legacy = { return (appCache[cacheKey] = {
request: (input, init) => legacy.request(input, init), request: (input, init) => legacy.request(input, init),
}) })
} }
@@ -29,13 +53,13 @@ function app(modules: Runtime, backend: Backend) {
modules.ExperimentalHttpApiServer.routes.pipe( modules.ExperimentalHttpApiServer.routes.pipe(
Layer.provide( Layer.provide(
ConfigProvider.layer( ConfigProvider.layer(
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }),
), ),
), ),
), ),
{ disableLogger: true }, { disableLogger: true },
).handler ).handler
return (appCache.effect = { return (appCache[cacheKey] = {
request(input: string | URL | Request, init?: RequestInit) { request(input: string | URL | Request, init?: RequestInit) {
return handler( return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), 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> { async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
const text = mode === "stream" ? await captureStream(response) : await response.text() const text = mode === "stream" ? await captureStream(response) : await response.text()
return { return {

View File

@@ -2,6 +2,7 @@ import { Effect } from "effect"
import { looksJson } from "./assertions" import { looksJson } from "./assertions"
import type { import type {
ActiveScenario, ActiveScenario,
AuthPolicy,
BuilderState, BuilderState,
CallResult, CallResult,
Comparison, Comparison,
@@ -21,11 +22,13 @@ class ScenarioBuilder<S = undefined> {
path, path,
name, name,
project: { git: true }, 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), seed: () => Effect.succeed(undefined as S),
request: (ctx) => ({ path, headers: ctx.headers() }), request: (ctx) => ({ path, headers: ctx.headers() }),
capture: "full", capture: "full",
mutates: false, mutates: false,
reset: true, reset: true,
auth: "protected",
} }
} }
@@ -57,6 +60,26 @@ class ScenarioBuilder<S = undefined> {
return this.clone({ capture: "stream" }) 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. */ /** Assert a non-JSON or shape-only response. */
ok(status = 200, compare: Comparison = "status") { ok(status = 200, compare: Comparison = "status") {
return this.done(compare, (_ctx, result) => return this.done(compare, (_ctx, result) =>
@@ -128,12 +151,15 @@ class ScenarioBuilder<S = undefined> {
name: state.name, name: state.name,
project: state.project, project: state.project,
seed: state.seed, 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 }), 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), expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
compare, compare,
capture: state.capture, capture: state.capture,
mutates: state.mutates, mutates: state.mutates,
reset: state.reset, reset: state.reset,
auth: state.auth,
} }
} }
} }

View File

@@ -23,7 +23,7 @@ import { OpenApi } from "effect/unstable/httpapi"
import { TestLLMServer } from "../../lib/llm-server" import { TestLLMServer } from "../../lib/llm-server"
import path from "path" import path from "path"
import { array, boolean, check, isRecord, message, object, stable } from "./assertions" import { array, boolean, check, isRecord, message, object, stable } from "./assertions"
import { controlledPtyInput, http, pending, route } from "./dsl" import { controlledPtyInput, http, route } from "./dsl"
import { import {
cleanupExercisePaths, cleanupExercisePaths,
exerciseConfigDirectory, exerciseConfigDirectory,
@@ -1192,6 +1192,7 @@ const main = Effect.gen(function* () {
return yield* Effect.fail(new Error("one or more scenarios are skipped")) return yield* Effect.fail(new Error("one or more scenarios are skipped"))
if (options.failOnMissing && missing.length > 0) if (options.failOnMissing && missing.length > 0)
return yield* Effect.fail(new Error("one or more routes have no scenario")) 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( Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then(

View File

@@ -19,7 +19,8 @@ export function coverageResult(scenario: Scenario): Result {
export function parseOptions(args: string[]): Options { export function parseOptions(args: string[]): Options {
const mode = option(args, "--mode") ?? "effect" 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 { return {
mode, mode,
include: option(args, "--include"), include: option(args, "--include"),

View File

@@ -6,7 +6,7 @@ import { ModelID, ProviderID } from "../../../src/provider/schema"
import type { MessageV2 } from "../../../src/session/message-v2" import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema" import { MessageID, PartID } from "../../../src/session/schema"
import { stable } from "./assertions" import { stable } from "./assertions"
import { call } from "./backend" import { call, callAuthProbe } from "./backend"
import { original } from "./environment" import { original } from "./environment"
import { runtime } from "./runtime" import { runtime } from "./runtime"
import type { import type {
@@ -32,6 +32,8 @@ export function runScenario(options: Options) {
} }
function runActive(options: Options, scenario: ActiveScenario) { function runActive(options: Options, scenario: ActiveScenario) {
if (options.mode === "auth") return runAuth(scenario)
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
return Effect.gen(function* () { return Effect.gen(function* () {
const effect = yield* runBackend("effect", scenario) 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) { function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) {
return withContext(scenario, (ctx) => return withContext(scenario, (ctx) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -73,7 +90,10 @@ function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<un
: undefined : undefined
return { dir, llm } 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( ).pipe(
Effect.flatMap((context) => Effect.flatMap((context) =>
Effect.gen(function* () { Effect.gen(function* () {

View File

@@ -10,10 +10,11 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
export type Method = (typeof Methods)[number] export type Method = (typeof Methods)[number]
export type OpenApiMethod = (typeof OpenApiMethods)[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 Backend = "effect" | "legacy"
export type Comparison = "none" | "status" | "json" export type Comparison = "none" | "status" | "json"
export type CaptureMode = "full" | "stream" 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 ProjectOptions = { git?: boolean; config?: Partial<Config.Info>; llm?: boolean }
export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> } export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
export type JsonObject = Record<string, unknown> export type JsonObject = Record<string, unknown>
@@ -79,6 +80,7 @@ export type ActiveScenario = {
capture: CaptureMode capture: CaptureMode
mutates: boolean mutates: boolean
reset: boolean reset: boolean
auth: AuthPolicy
} }
export type BuilderState<S> = { export type BuilderState<S> = {
@@ -91,6 +93,7 @@ export type BuilderState<S> = {
capture: CaptureMode capture: CaptureMode
mutates: boolean mutates: boolean
reset: boolean reset: boolean
auth: AuthPolicy
} }
export type TodoScenario = { export type TodoScenario = {