From 75308ea47d12c5705f31e42e6d534d0ef3e96f58 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 14:05:46 -0400 Subject: [PATCH] test(server): add HttpApi auth exercise mode (#26386) --- .../test/server/httpapi-exercise/backend.ts | 58 +++++++++++++++---- .../test/server/httpapi-exercise/dsl.ts | 26 +++++++++ .../test/server/httpapi-exercise/index.ts | 3 +- .../test/server/httpapi-exercise/routing.ts | 3 +- .../test/server/httpapi-exercise/runner.ts | 24 +++++++- .../test/server/httpapi-exercise/types.ts | 5 +- 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index fb2ed1d8a3..c393383e03 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -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) { +type CallOptions = { + auth?: { + password?: string + username?: string + } +} + +export function call( + backend: Backend, + scenario: ActiveScenario, + ctx: SeededContext, + 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> = {} +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> = {} + +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) { }) } +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 { const text = mode === "stream" ? await captureStream(response) : await response.text() return { diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts index 5d8a3cacad..326207049f 100644 --- a/packages/opencode/test/server/httpapi-exercise/dsl.ts +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -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 { 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 { 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 { 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, } } } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 67100f35e0..32d9af464b 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -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( diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index 1d33b03621..39bda11209 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -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"), diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 2eb38b190b..18ef991807 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -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(scenario: ActiveScenario, use: (ctx: SeededContext 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* () { diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index befbd6aedb..c725739b4e 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -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; llm?: boolean } export type OpenApiSpec = { paths?: Record>> } export type JsonObject = Record @@ -79,6 +80,7 @@ export type ActiveScenario = { capture: CaptureMode mutates: boolean reset: boolean + auth: AuthPolicy } export type BuilderState = { @@ -91,6 +93,7 @@ export type BuilderState = { capture: CaptureMode mutates: boolean reset: boolean + auth: AuthPolicy } export type TodoScenario = {