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 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 {

View File

@@ -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,
}
}
}

View File

@@ -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(

View File

@@ -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"),

View File

@@ -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* () {

View File

@@ -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 = {