Skip to content

Commit 75308ea

Browse files
authored
test(server): add HttpApi auth exercise mode (#26386)
1 parent daa3116 commit 75308ea

6 files changed

Lines changed: 104 additions & 15 deletions

File tree

packages/opencode/test/server/httpapi-exercise/backend.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,46 @@ import { parse } from "./assertions"
55
import { runtime, type Runtime } from "./runtime"
66
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
77

8-
export function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext<unknown>) {
8+
type CallOptions = {
9+
auth?: {
10+
password?: string
11+
username?: string
12+
}
13+
}
14+
15+
export function call(
16+
backend: Backend,
17+
scenario: ActiveScenario,
18+
ctx: SeededContext<unknown>,
19+
options: CallOptions = {},
20+
) {
21+
return Effect.promise(async () =>
22+
capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
23+
)
24+
}
25+
26+
export function callAuthProbe(backend: Backend, scenario: ActiveScenario) {
927
return Effect.promise(async () =>
10-
capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture),
28+
capture(
29+
await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)),
30+
scenario.capture,
31+
),
1132
)
1233
}
1334

14-
const appCache: Partial<Record<Backend, BackendApp>> = {}
35+
const appCache: Partial<Record<string, BackendApp>> = {}
1536

16-
function app(modules: Runtime, backend: Backend) {
37+
function app(modules: Runtime, backend: Backend, options: CallOptions) {
38+
const username = options.auth?.username
39+
const password = options.auth?.password
40+
const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}`
1741
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect"
18-
Flag.OPENCODE_SERVER_PASSWORD = undefined
19-
Flag.OPENCODE_SERVER_USERNAME = undefined
20-
if (appCache[backend]) return appCache[backend]
42+
Flag.OPENCODE_SERVER_PASSWORD = password
43+
Flag.OPENCODE_SERVER_USERNAME = username
44+
if (appCache[cacheKey]) return appCache[cacheKey]
2145
if (backend === "legacy") {
2246
const legacy = modules.Server.Legacy().app
23-
return (appCache.legacy = {
47+
return (appCache[cacheKey] = {
2448
request: (input, init) => legacy.request(input, init),
2549
})
2650
}
@@ -29,13 +53,13 @@ function app(modules: Runtime, backend: Backend) {
2953
modules.ExperimentalHttpApiServer.routes.pipe(
3054
Layer.provide(
3155
ConfigProvider.layer(
32-
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }),
56+
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }),
3357
),
3458
),
3559
),
3660
{ disableLogger: true },
3761
).handler
38-
return (appCache.effect = {
62+
return (appCache[cacheKey] = {
3963
request(input: string | URL | Request, init?: RequestInit) {
4064
return handler(
4165
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
@@ -54,6 +78,20 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
5478
})
5579
}
5680

81+
function toAuthProbeRequest(scenario: ActiveScenario) {
82+
return new Request(new URL(authProbePath(scenario.path), "http://localhost"), {
83+
method: scenario.method,
84+
headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" },
85+
body: scenario.method === "GET" ? undefined : JSON.stringify({}),
86+
})
87+
}
88+
89+
function authProbePath(path: string) {
90+
return path
91+
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
92+
.replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
93+
}
94+
5795
async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
5896
const text = mode === "stream" ? await captureStream(response) : await response.text()
5997
return {

packages/opencode/test/server/httpapi-exercise/dsl.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Effect } from "effect"
22
import { looksJson } from "./assertions"
33
import type {
44
ActiveScenario,
5+
AuthPolicy,
56
BuilderState,
67
CallResult,
78
Comparison,
@@ -21,11 +22,13 @@ class ScenarioBuilder<S = undefined> {
2122
path,
2223
name,
2324
project: { git: true },
25+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
2426
seed: () => Effect.succeed(undefined as S),
2527
request: (ctx) => ({ path, headers: ctx.headers() }),
2628
capture: "full",
2729
mutates: false,
2830
reset: true,
31+
auth: "protected",
2932
}
3033
}
3134

@@ -57,6 +60,26 @@ class ScenarioBuilder<S = undefined> {
5760
return this.clone({ capture: "stream" })
5861
}
5962

63+
protected() {
64+
return this.auth("protected")
65+
}
66+
67+
public() {
68+
return this.auth("public")
69+
}
70+
71+
publicBypass() {
72+
return this.auth("public-bypass")
73+
}
74+
75+
ticketBypass() {
76+
return this.auth("ticket-bypass")
77+
}
78+
79+
private auth(auth: AuthPolicy) {
80+
return this.clone({ auth })
81+
}
82+
6083
/** Assert a non-JSON or shape-only response. */
6184
ok(status = 200, compare: Comparison = "status") {
6285
return this.done(compare, (_ctx, result) =>
@@ -128,12 +151,15 @@ class ScenarioBuilder<S = undefined> {
128151
name: state.name,
129152
project: state.project,
130153
seed: state.seed,
154+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
131155
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
156+
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
132157
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
133158
compare,
134159
capture: state.capture,
135160
mutates: state.mutates,
136161
reset: state.reset,
162+
auth: state.auth,
137163
}
138164
}
139165
}

packages/opencode/test/server/httpapi-exercise/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { OpenApi } from "effect/unstable/httpapi"
2323
import { TestLLMServer } from "../../lib/llm-server"
2424
import path from "path"
2525
import { array, boolean, check, isRecord, message, object, stable } from "./assertions"
26-
import { controlledPtyInput, http, pending, route } from "./dsl"
26+
import { controlledPtyInput, http, route } from "./dsl"
2727
import {
2828
cleanupExercisePaths,
2929
exerciseConfigDirectory,
@@ -1192,6 +1192,7 @@ const main = Effect.gen(function* () {
11921192
return yield* Effect.fail(new Error("one or more scenarios are skipped"))
11931193
if (options.failOnMissing && missing.length > 0)
11941194
return yield* Effect.fail(new Error("one or more routes have no scenario"))
1195+
return undefined
11951196
})
11961197

11971198
Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then(

packages/opencode/test/server/httpapi-exercise/routing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export function coverageResult(scenario: Scenario): Result {
1919

2020
export function parseOptions(args: string[]): Options {
2121
const mode = option(args, "--mode") ?? "effect"
22-
if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`)
22+
if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth")
23+
throw new Error(`invalid --mode ${mode}`)
2324
return {
2425
mode,
2526
include: option(args, "--include"),

packages/opencode/test/server/httpapi-exercise/runner.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ModelID, ProviderID } from "../../../src/provider/schema"
66
import type { MessageV2 } from "../../../src/session/message-v2"
77
import { MessageID, PartID } from "../../../src/session/schema"
88
import { stable } from "./assertions"
9-
import { call } from "./backend"
9+
import { call, callAuthProbe } from "./backend"
1010
import { original } from "./environment"
1111
import { runtime } from "./runtime"
1212
import type {
@@ -32,6 +32,8 @@ export function runScenario(options: Options) {
3232
}
3333

3434
function runActive(options: Options, scenario: ActiveScenario) {
35+
if (options.mode === "auth") return runAuth(scenario)
36+
3537
if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") {
3638
return Effect.gen(function* () {
3739
const effect = yield* runBackend("effect", scenario)
@@ -53,6 +55,21 @@ function runActive(options: Options, scenario: ActiveScenario) {
5355
)
5456
}
5557

58+
function runAuth(scenario: ActiveScenario) {
59+
return Effect.gen(function* () {
60+
const effect = yield* callAuthProbe("effect", scenario)
61+
const legacy = yield* callAuthProbe("legacy", scenario)
62+
if (scenario.auth === "protected") {
63+
if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`)
64+
if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`)
65+
return
66+
}
67+
68+
if (effect.status === 401) throw new Error("effect auth expected public access, got 401")
69+
if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401")
70+
})
71+
}
72+
5673
function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) {
5774
return withContext(scenario, (ctx) =>
5875
Effect.gen(function* () {
@@ -73,7 +90,10 @@ function withContext<A, E>(scenario: ActiveScenario, use: (ctx: SeededContext<un
7390
: undefined
7491
return { dir, llm }
7592
}),
76-
(ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore),
93+
(ctx) =>
94+
Effect.promise(async () => {
95+
await ctx.dir?.[Symbol.asyncDispose]()
96+
}).pipe(Effect.ignore),
7797
).pipe(
7898
Effect.flatMap((context) =>
7999
Effect.gen(function* () {

packages/opencode/test/server/httpapi-exercise/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
1010

1111
export type Method = (typeof Methods)[number]
1212
export type OpenApiMethod = (typeof OpenApiMethods)[number]
13-
export type Mode = "effect" | "parity" | "coverage"
13+
export type Mode = "effect" | "parity" | "coverage" | "auth"
1414
export type Backend = "effect" | "legacy"
1515
export type Comparison = "none" | "status" | "json"
1616
export type CaptureMode = "full" | "stream"
17+
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"
1718
export type ProjectOptions = { git?: boolean; config?: Partial<Config.Info>; llm?: boolean }
1819
export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
1920
export type JsonObject = Record<string, unknown>
@@ -79,6 +80,7 @@ export type ActiveScenario = {
7980
capture: CaptureMode
8081
mutates: boolean
8182
reset: boolean
83+
auth: AuthPolicy
8284
}
8385

8486
export type BuilderState<S> = {
@@ -91,6 +93,7 @@ export type BuilderState<S> = {
9193
capture: CaptureMode
9294
mutates: boolean
9395
reset: boolean
96+
auth: AuthPolicy
9497
}
9598

9699
export type TodoScenario = {

0 commit comments

Comments
 (0)