소스 검색

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

Kit Langton 3 달 전
부모
커밋
75308ea47d

+ 48 - 10
packages/opencode/test/server/httpapi-exercise/backend.ts

@@ -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 () =>
+    capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
+  )
+}
+
+export function callAuthProbe(backend: Backend, scenario: ActiveScenario) {
   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, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)),
+      scenario.capture,
+    ),
   )
   )
 }
 }
 
 
-const appCache: Partial<Record<Backend, BackendApp>> = {}
+const appCache: Partial<Record<string, BackendApp>> = {}
 
 
-function app(modules: Runtime, backend: Backend) {
+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_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") {
   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 {

+ 26 - 0
packages/opencode/test/server/httpapi-exercise/dsl.ts

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

+ 2 - 1
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 { 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(

+ 2 - 1
packages/opencode/test/server/httpapi-exercise/routing.ts

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

+ 22 - 2
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 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* () {

+ 4 - 1
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 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 = {