diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index df535dcfa..51405d21b 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -24,21 +24,14 @@ import { resolve } from "node:path"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Context, Data, Effect, Exit, Layer, Option, Schema, Scope } from "effect"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { unstable_dev, type Unstable_DevWorker } from "wrangler"; +import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing"; import { makeTestBearer } from "./test-bearer"; @@ -64,22 +57,13 @@ const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) => h.handle("approveThing", () => Effect.succeed(ApprovedResponse.make({ approved: true }))), ); -const UpstreamApiLive = HttpApiBuilder.layer(UpstreamApi).pipe(Layer.provide(ApproveHandlers)); - -const UpstreamServeLayer = HttpRouter.serve(UpstreamApiLive).pipe( - Layer.provide(UpstreamApiLive), - Layer.provideMerge(HttpRouter.layer), - Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port: 0, host: "127.0.0.1" })), -); - // --------------------------------------------------------------------------- // Services // --------------------------------------------------------------------------- -class Upstream extends Context.Service< - Upstream, - { readonly specJson: string; readonly url: string } ->()("MiniflareE2E/Upstream") {} +class Upstream extends Context.Service()( + "MiniflareE2E/Upstream", +) {} class Worker extends Context.Service< Worker, @@ -116,23 +100,18 @@ class MiniflareE2ETestError extends Data.TaggedError("MiniflareE2ETestError")<{ const UpstreamLive = Layer.effect( Upstream, - Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const addr = server.address; - if (!Predicate.isTagged("TcpAddress")(addr)) { - return yield* new MiniflareE2ETestError({ - message: "upstream server bound to non-TCP address", - cause: addr, - }); - } - const url = `http://127.0.0.1:${addr.port}`; - const specJson = JSON.stringify({ - ...OpenApi.fromApi(UpstreamApi), - servers: [{ url }], - }); - return { specJson, url }; - }), -).pipe(Layer.provide(UpstreamServeLayer)); + Effect.acquireRelease( + Effect.gen(function* () { + const scope = yield* Scope.make(); + const server = yield* serveOpenApiHttpApiTestServer({ + api: UpstreamApi, + handlersLayer: ApproveHandlers, + }).pipe(Scope.provide(scope)); + return { server, scope }; + }), + ({ scope }) => Scope.close(scope, Exit.void), + ).pipe(Effect.map(({ server }) => ({ specJson: server.specJson }))), +); // --------------------------------------------------------------------------- // Telemetry receiver — a node HTTP server on a random port that speaks diff --git a/apps/cloud/src/services/mcp-oauth.node.test.ts b/apps/cloud/src/services/mcp-oauth.node.test.ts index 0a1c72054..14fd67468 100644 --- a/apps/cloud/src/services/mcp-oauth.node.test.ts +++ b/apps/cloud/src/services/mcp-oauth.node.test.ts @@ -2,15 +2,15 @@ // Cloud API × MCP OAuth — real HTTP end-to-end // --------------------------------------------------------------------------- // -// Drives the ProtectedCloudApi through the node-pool harness against a -// real in-process OAuth + MCP server (Node `http.createServer` bound to -// a random port). Every layer between the test and the plugin is real: +// Drives the ProtectedCloudApi through the node-pool harness against the shared +// real in-process OAuth test server. Every layer between the test and the +// plugin is real: // // test → HttpApiClient → in-process webHandler → ProtectedCloudApi // → Core OAuthHandlers → executor.oauth.start / complete // → MCP SDK `auth()` -// → fake OAuth server (DCR, /authorize → 302, /token, AS metadata, -// protected resource metadata) +// → OAuthTestServer (DCR, /authorize → login, /token, AS metadata, +// protected resource metadata, MCP protected resource) // // Two scenarios: // @@ -22,240 +22,20 @@ // via the SDK's innermost-wins shadowing. // --------------------------------------------------------------------------- -import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { createHash, randomBytes } from "node:crypto"; +import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Result, Schema } from "effect"; +import { Effect, Result } from "effect"; import { ScopeId } from "@executor-js/sdk"; +import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing"; import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; -// --------------------------------------------------------------------------- -// Fake OAuth + MCP server -// --------------------------------------------------------------------------- - -interface FakeServer { - readonly url: string; - readonly registrations: () => number; - readonly tokens: () => number; - readonly close: () => Promise; -} - -const RegistrationBody = Schema.Struct({ - redirect_uris: Schema.optional(Schema.Array(Schema.String)), - grant_types: Schema.optional(Schema.Array(Schema.String)), - response_types: Schema.optional(Schema.Array(Schema.String)), -}); -const decodeRegistrationBody = Schema.decodeUnknownOption(Schema.fromJsonString(RegistrationBody)); - -const startFakeServer = async (): Promise => { - const clients = new Map(); - const codes = new Map(); - const accessTokens = new Map(); - const refreshTokens = new Map(); - let seq = 0; - const next = (p: string) => `${p}_${++seq}_${randomBytes(6).toString("hex")}`; - let registrations = 0; - let tokenCalls = 0; - - const readBody = (req: import("node:http").IncomingMessage): Promise => - new Promise((resolve, reject) => { - let buf = ""; - req.on("data", (chunk) => (buf += chunk)); - req.on("end", () => resolve(buf)); - req.on("error", reject); - }); - - const server: Server = createServer(async (req, res) => { - const url = new URL(req.url!, `http://${req.headers.host}`); - const send = (status: number, body: unknown, headers: Record = {}) => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - res.writeHead(status, { - "content-type": typeof body === "string" ? "text/plain" : "application/json", - ...headers, - }); - res.end(payload); - }; - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fake HTTP server returns stable 500 responses for unexpected handler failures - try { - if (url.pathname === "/.well-known/oauth-protected-resource") { - const origin = `http://${req.headers.host}`; - return send(200, { - resource: origin, - authorization_servers: [origin], - bearer_methods_supported: ["header"], - }); - } - - if (url.pathname === "/.well-known/oauth-authorization-server") { - const issuer = `http://${req.headers.host}`; - return send(200, { - issuer, - authorization_endpoint: `${issuer}/authorize`, - token_endpoint: `${issuer}/token`, - registration_endpoint: `${issuer}/register`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); - } - - if (url.pathname === "/register" && req.method === "POST") { - const body = await readBody(req); - const parsedOption = decodeRegistrationBody(body); - if (Option.isNone(parsedOption)) { - return send(400, { error: "invalid_registration" }); - } - const parsed = parsedOption.value; - const clientId = next("client"); - clients.set(clientId, { redirect_uris: parsed.redirect_uris ?? [] }); - registrations += 1; - return send(201, { - client_id: clientId, - client_id_issued_at: Math.floor(Date.now() / 1000), - redirect_uris: parsed.redirect_uris ?? [], - grant_types: parsed.grant_types ?? ["authorization_code", "refresh_token"], - response_types: parsed.response_types ?? ["code"], - token_endpoint_auth_method: "none", - }); - } - - if (url.pathname === "/authorize" && req.method === "GET") { - const clientId = url.searchParams.get("client_id") ?? ""; - const redirectUri = url.searchParams.get("redirect_uri") ?? ""; - const state = url.searchParams.get("state") ?? ""; - const codeChallenge = url.searchParams.get("code_challenge") ?? ""; - const method = url.searchParams.get("code_challenge_method") ?? ""; - if (!clients.has(clientId)) { - return send(400, { error: "unknown_client" }); - } - if (method !== "S256" || !codeChallenge) { - return send(400, { error: "invalid_request" }); - } - const code = next("code"); - codes.set(code, { clientId, codeChallenge }); - const destination = new URL(redirectUri); - destination.searchParams.set("code", code); - if (state) destination.searchParams.set("state", state); - return send(302, "", { location: destination.toString() }); - } - - if (url.pathname === "/token" && req.method === "POST") { - tokenCalls += 1; - const body = await readBody(req); - const params = new URLSearchParams(body); - const grant = params.get("grant_type"); - - if (grant === "authorization_code") { - const code = params.get("code") ?? ""; - const verifier = params.get("code_verifier") ?? ""; - const record = codes.get(code); - if (!record) return send(400, { error: "invalid_grant" }); - codes.delete(code); - const computed = createHash("sha256").update(verifier).digest("base64url"); - if (computed !== record.codeChallenge) { - return send(400, { error: "invalid_grant" }); - } - const access = next("at"); - const refresh = next("rt"); - accessTokens.set(access, { refresh }); - refreshTokens.set(refresh, access); - return send(200, { - access_token: access, - refresh_token: refresh, - token_type: "Bearer", - expires_in: 3600, - }); - } - - if (grant === "refresh_token") { - const rt = params.get("refresh_token") ?? ""; - const prev = refreshTokens.get(rt); - if (!prev) return send(400, { error: "invalid_grant" }); - refreshTokens.delete(rt); - accessTokens.delete(prev); - const access = next("at"); - const refresh = next("rt"); - accessTokens.set(access, { refresh }); - refreshTokens.set(refresh, access); - return send(200, { - access_token: access, - refresh_token: refresh, - token_type: "Bearer", - expires_in: 3600, - }); - } - - return send(400, { error: "unsupported_grant_type" }); - } - - // Default: 401 with WWW-Authenticate so any MCP probe on /mcp - // gets the resource-metadata pointer the auth() discovery uses. - if (url.pathname === "/mcp") { - const origin = `http://${req.headers.host}`; - return send( - 401, - { error: "unauthorized" }, - { - "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, - }, - ); - } - - send(404, { error: "not_found", params: url.pathname }); - } catch { - send(500, { error: "server_error", message: "fake server failed" }); - } - }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address() as AddressInfo; - - return { - url: `http://127.0.0.1:${address.port}`, - registrations: () => registrations, - tokens: () => tokenCalls, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -}; - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -// Browser popup equivalent: GET the authorization URL, pull the code + -// state out of the 302 Location. -const followAuthorize = async ( - authorizationUrl: string, -): Promise<{ code: string; state: string }> => { - const response = await fetch(authorizationUrl, { redirect: "manual" }); - expect(response.status).toBe(302); - const location = response.headers.get("location"); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses - if (!location) throw new Error("no location header on authorize redirect"); - const dest = new URL(location); - const code = dest.searchParams.get("code"); - const state = dest.searchParams.get("state"); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses - if (!code || !state) throw new Error(`redirect missing code/state: ${location}`); - return { code, state }; -}; - -// --------------------------------------------------------------------------- -// Lifecycle -// --------------------------------------------------------------------------- - -let fake: FakeServer; -beforeAll(async () => { - fake = await startFakeServer(); -}); -afterAll(async () => { - await fake.close(); -}); +const countRequestsTo = (oauth: OAuthTestServerShape, path: string): Effect.Effect => + oauth.requests.pipe(Effect.map((requests) => requests.filter((r) => r.path === path).length)); // --------------------------------------------------------------------------- // Tests @@ -326,110 +106,120 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { it.effect( "startOAuth → authorize → completeOAuth writes tokens at the invoker scope", () => - Effect.gen(function* () { - const orgId = `org_${crypto.randomUUID()}`; - const userId = `user_${crypto.randomUUID()}`; - const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); - const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; - const connectionId = `mcp-oauth2-${namespace}`; - const redirectUrl = "http://test.local/api/mcp/oauth/callback"; - - const started = yield* asUser(userId, orgId, (client) => - client.oauth.start({ - params: { scopeId: userScope }, - payload: { - endpoint: `${fake.url}/mcp`, - redirectUrl, - connectionId, - tokenScope: String(userScope), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", - }, - }), - ); - expect(started.sessionId).toMatch(/^oauth2_session_/); - expect(started.authorizationUrl).not.toBeNull(); + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const orgId = `org_${crypto.randomUUID()}`; + const userId = `user_${crypto.randomUUID()}`; + const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); + const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; + const connectionId = `mcp-oauth2-${namespace}`; + const redirectUrl = "http://test.local/api/mcp/oauth/callback"; + + const started = yield* asUser(userId, orgId, (client) => + client.oauth.start({ + params: { scopeId: userScope }, + payload: { + endpoint: oauth.mcpResourceUrl, + redirectUrl, + connectionId, + tokenScope: String(userScope), + strategy: { kind: "dynamic-dcr" }, + pluginId: "mcp", + }, + }), + ); + expect(started.sessionId).toMatch(/^oauth2_session_/); + expect(started.authorizationUrl).not.toBeNull(); - const { code, state } = yield* Effect.promise(() => - followAuthorize(started.authorizationUrl!), - ); - expect(state).toBe(started.sessionId); + const { code, state } = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl!, + }); + expect(state).toBe(started.sessionId); - const completed = yield* asUser(userId, orgId, (client) => - client.oauth.complete({ - params: { scopeId: userScope }, - payload: { state, code }, - }), - ); - expect(completed.connectionId).toBe(connectionId); - }), + const completed = yield* asUser(userId, orgId, (client) => + client.oauth.complete({ + params: { scopeId: userScope }, + payload: { state, code }, + }), + ); + expect(completed.connectionId).toBe(connectionId); + }), + ), 30_000, ); it.effect( "second user on same source re-uses DCR client: registration endpoint is not re-hit", () => - Effect.gen(function* () { - const orgId = `org_${crypto.randomUUID()}`; - const userA = `user_${crypto.randomUUID()}`; - const userB = `user_${crypto.randomUUID()}`; - const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); - const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); - const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; - const connectionId = `mcp-oauth2-${namespace}`; - const endpoint = `${fake.url}/mcp`; - const redirectUrl = "http://test.local/api/mcp/oauth/callback"; - - const regsBefore = fake.registrations(); - - // --- User A: full OAuth round-trip, fresh DCR. --- - const startedA = yield* asUser(userA, orgId, (client) => - client.oauth.start({ - params: { scopeId: scopeA }, - payload: { - endpoint, - redirectUrl, - connectionId, - tokenScope: String(scopeA), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", - }, - }), - ); - const redirA = yield* Effect.promise(() => followAuthorize(startedA.authorizationUrl!)); - const completedA = yield* asUser(userA, orgId, (client) => - client.oauth.complete({ - params: { scopeId: scopeA }, - payload: { state: redirA.state, code: redirA.code }, - }), - ); - expect(completedA.connectionId).toBe(connectionId); - expect(fake.registrations()).toBe(regsBefore + 1); + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const orgId = `org_${crypto.randomUUID()}`; + const userA = `user_${crypto.randomUUID()}`; + const userB = `user_${crypto.randomUUID()}`; + const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); + const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); + const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; + const connectionId = `mcp-oauth2-${namespace}`; + const endpoint = oauth.mcpResourceUrl; + const redirectUrl = "http://test.local/api/mcp/oauth/callback"; + + const regsBefore = yield* countRequestsTo(oauth, "/register"); + + // --- User A: full OAuth round-trip, fresh DCR. --- + const startedA = yield* asUser(userA, orgId, (client) => + client.oauth.start({ + params: { scopeId: scopeA }, + payload: { + endpoint, + redirectUrl, + connectionId, + tokenScope: String(scopeA), + strategy: { kind: "dynamic-dcr" }, + pluginId: "mcp", + }, + }), + ); + const redirA = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: startedA.authorizationUrl!, + }); + const completedA = yield* asUser(userA, orgId, (client) => + client.oauth.complete({ + params: { scopeId: scopeA }, + payload: { state: redirA.state, code: redirA.code }, + }), + ); + expect(completedA.connectionId).toBe(connectionId); + expect(yield* countRequestsTo(oauth, "/register")).toBe(regsBefore + 1); - // --- User B: gets the same logical connection id in a different scope. --- - const startedB = yield* asUser(userB, orgId, (client) => - client.oauth.start({ - params: { scopeId: scopeB }, - payload: { - endpoint, - redirectUrl, - connectionId, - tokenScope: String(scopeB), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", - }, - }), - ); - const redirB = yield* Effect.promise(() => followAuthorize(startedB.authorizationUrl!)); - const completedB = yield* asUser(userB, orgId, (client) => - client.oauth.complete({ - params: { scopeId: scopeB }, - payload: { state: redirB.state, code: redirB.code }, - }), - ); - expect(completedB.connectionId).toBe(connectionId); - expect(fake.registrations()).toBe(regsBefore + 2); - }), + // --- User B: gets the same logical connection id in a different scope. --- + const startedB = yield* asUser(userB, orgId, (client) => + client.oauth.start({ + params: { scopeId: scopeB }, + payload: { + endpoint, + redirectUrl, + connectionId, + tokenScope: String(scopeB), + strategy: { kind: "dynamic-dcr" }, + pluginId: "mcp", + }, + }), + ); + const redirB = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: startedB.authorizationUrl!, + }); + const completedB = yield* asUser(userB, orgId, (client) => + client.oauth.complete({ + params: { scopeId: scopeB }, + payload: { state: redirB.state, code: redirB.code }, + }), + ); + expect(completedB.connectionId).toBe(connectionId); + expect(yield* countRequestsTo(oauth, "/register")).toBe(regsBefore + 2); + }), + ), 30_000, ); }); diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index b5c218f93..0191f9a99 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -5,286 +5,50 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Result, Schema } from "effect"; -import http from "node:http"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { readFileSync } from "node:fs"; -import type { AddressInfo } from "node:net"; import { resolve } from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { ScopeId, SecretId } from "@executor-js/sdk"; +import { + serveGraphqlTestServer, + makeGreetingGraphqlSchema, +} from "@executor-js/plugin-graphql/testing"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { + makeOpenApiHttpApiTestAddSpecPayload, + makeOpenApiHttpApiTestSpecPayload, + serveOpenApiEchoTestServer, +} from "@executor-js/plugin-openapi/testing"; import { asOrg, asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; -const MINIMAL_OPENAPI_SPEC = JSON.stringify({ - openapi: "3.0.0", - info: { title: "Sources API Test", version: "1.0.0" }, - paths: { - "/ping": { - get: { - operationId: "ping", - summary: "ping", - responses: { "200": { description: "ok" } }, - }, - }, - }, -}); - -const invocableOpenApiSpec = (baseUrl: string) => - JSON.stringify({ - openapi: "3.0.0", - info: { title: "Invocable Source API", version: "1.0.0" }, - servers: [{ url: baseUrl }], - paths: { - "/echo/{message}": { - get: { - operationId: "echoMessage", - summary: "Echo message", - parameters: [ - { - name: "message", - in: "path", - required: true, - schema: { type: "string" }, - }, - { - name: "suffix", - in: "query", - required: false, - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "ok", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string" }, - suffix: { type: "string" }, - path: { type: "string" }, - }, - required: ["message", "path"], - }, - }, - }, - }, - }, - }, - }, - }, - }); - -const startEchoServer = () => { - const requests: Array<{ readonly path: string; readonly suffix: string | null }> = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", `http://${req.headers.host}`); - const match = /^\/echo\/([^/]+)$/.exec(url.pathname); - if (!match) { - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "not_found" })); - return; - } - - requests.push({ - path: url.pathname, - suffix: url.searchParams.get("suffix"), - }); - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - message: decodeURIComponent(match[1]!), - suffix: url.searchParams.get("suffix") ?? undefined, - path: url.pathname, - }), - ); - }); - - return new Promise<{ - readonly baseUrl: string; - readonly requests: () => ReadonlyArray<{ - readonly path: string; - readonly suffix: string | null; - }>; - readonly close: () => Promise; - }>((resolveServer) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - resolveServer({ - baseUrl: `http://127.0.0.1:${port}`, - requests: () => requests, - close: () => new Promise((close) => server.close(() => close())), - }); - }); - }); -}; - -const readBody = (req: http.IncomingMessage): Promise => - new Promise((resolveBody, rejectBody) => { - let body = ""; - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => resolveBody(body)); - req.on("error", rejectBody); - }); +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); -const GRAPHQL_INTROSPECTION_RESPONSE = { - data: { - __schema: { - queryType: { name: "Query" }, - mutationType: null, - types: [ - { - kind: "OBJECT", - name: "Query", - description: null, - fields: [ - { - name: "hello", - description: "Say hello", - args: [ - { - name: "name", - description: null, - type: { kind: "SCALAR", name: "String", ofType: null }, - defaultValue: null, - }, - ], - type: { kind: "SCALAR", name: "String", ofType: null }, - }, - ], - inputFields: null, - enumValues: null, - }, - { - kind: "SCALAR", - name: "String", - description: null, - fields: null, - inputFields: null, - enumValues: null, - }, - ], - }, - }, -}; - -const GraphqlRequestSchema = Schema.Struct({ - query: Schema.optional(Schema.String), - variables: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}); - -const GraphqlRequestFromJson = Schema.fromJsonString(GraphqlRequestSchema); -const decodeGraphqlRequest = Schema.decodeUnknownPromise(GraphqlRequestFromJson); - -const startGraphqlServer = () => { - const requests: Array<{ readonly query: string; readonly variables: unknown }> = []; - const server = http.createServer(async (req, res) => { - if (req.method !== "POST" || req.url !== "/graphql") { - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ errors: [{ message: "not found" }] })); - return; - } - - const parsed = await decodeGraphqlRequest(await readBody(req)); - const query = parsed.query ?? ""; - requests.push({ query, variables: parsed.variables ?? null }); - - res.writeHead(200, { "content-type": "application/json" }); - if (query.includes("__schema")) { - res.end(JSON.stringify(GRAPHQL_INTROSPECTION_RESPONSE)); - return; - } - - res.end( - JSON.stringify({ - data: { - hello: `Hello ${String(parsed.variables?.name ?? "world")}`, - }, - }), - ); - }); +const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), +); - return new Promise<{ - readonly endpoint: string; - readonly requests: () => ReadonlyArray<{ readonly query: string; readonly variables: unknown }>; - readonly close: () => Promise; - }>((resolveServer) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - resolveServer({ - endpoint: `http://127.0.0.1:${port}/graphql`, - requests: () => requests, - close: () => new Promise((close) => server.close(() => close())), - }); - }); +const MinimalSourceApi = HttpApi.make("sourcesApiTest") + .add(PingGroup) + .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" })); + +const makeMinimalOpenApiSourcePayload = ( + targetScope: ScopeId, + namespace: string, + options: Omit< + Parameters[1], + "targetScope" | "namespace" + > = {}, +) => + makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, { + targetScope, + namespace, + ...options, }); -}; - -const createCloudMcpServer = () => { - const server = new McpServer({ name: "cloud-e2e-mcp", version: "1.0.0" }, { capabilities: {} }); - - server.registerTool( - "simple_echo", - { description: "Echoes from the cloud e2e MCP server", inputSchema: {} }, - async () => ({ - content: [{ type: "text" as const, text: "cloud-mcp-ok" }], - }), - ); - return server; -}; - -const startMcpServer = () => { - const calls: string[] = []; - const transports = new Map(); - const server = http.createServer(async (req, res) => { - calls.push(`${req.method ?? "GET"} ${req.url ?? "/"}`); - const sessionId = req.headers["mcp-session-id"] as string | undefined; - if (sessionId) { - const transport = transports.get(sessionId); - if (!transport) { - res.writeHead(404); - res.end("Session not found"); - return; - } - await transport.handleRequest(req, res); - return; - } - - const mcp = createCloudMcpServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - onsessioninitialized: (id) => { - transports.set(id, transport); - }, - }); - await mcp.connect(transport); - await transport.handleRequest(req, res); - }); - - return new Promise<{ - readonly endpoint: string; - readonly calls: () => readonly string[]; - readonly close: () => Promise; - }>((resolveServer) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - resolveServer({ - endpoint: `http://127.0.0.1:${port}`, - calls: () => calls, - close: () => - new Promise((close) => { - server.closeAllConnections(); - server.close(() => close()); - }), - }); - }); - }); -}; +const makeMinimalOpenApiPreviewPayload = () => makeOpenApiHttpApiTestSpecPayload(MinimalSourceApi); // The Cloudflare OpenAPI spec is the biggest real spec we care about: // 16MB, 2700+ operations, thousands of shared schemas. Exercising @@ -308,11 +72,7 @@ describe("sources api (HTTP)", () => { Effect.gen(function* () { const result = yield* client.openapi.addSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { - targetScope: ScopeId.make(org), - spec: MINIMAL_OPENAPI_SPEC, - namespace, - }, + payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), }); expect(result.namespace).toBe(namespace); expect(result.toolCount).toBeGreaterThan(0); @@ -334,11 +94,7 @@ describe("sources api (HTTP)", () => { yield* asOrg(org, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { - targetScope: ScopeId.make(org), - spec: MINIMAL_OPENAPI_SPEC, - namespace, - }, + payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), }), ); @@ -356,7 +112,7 @@ describe("sources api (HTTP)", () => { const preview = yield* asOrg(org, (client) => client.openapi.previewSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { spec: MINIMAL_OPENAPI_SPEC }, + payload: makeMinimalOpenApiPreviewPayload(), }), ); @@ -380,12 +136,11 @@ describe("sources api (HTTP)", () => { const result = yield* asOrg(org, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { - targetScope: ScopeId.make(org), - spec: MINIMAL_OPENAPI_SPEC, - namespace: `ns_${crypto.randomUUID().replace(/-/g, "_")}`, - baseUrl: "http://example.com", - }, + payload: makeMinimalOpenApiSourcePayload( + ScopeId.make(org), + `ns_${crypto.randomUUID().replace(/-/g, "_")}`, + { baseUrl: "http://example.com" }, + ), }), ); @@ -395,10 +150,15 @@ describe("sources api (HTTP)", () => { it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () => Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => startEchoServer()), - (fixture) => Effect.promise(() => fixture.close()), - ); + const server = yield* serveOpenApiEchoTestServer({ + transformSpec: (spec) => ({ + ...spec, + info: { title: "Invocable Source API", version: "1.0.0" }, + paths: { + "/echo/{message}": isJsonObject(spec.paths) ? spec.paths["/echo/{message}"] : {}, + }, + }), + }); const org = `org_${crypto.randomUUID()}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; const scopeId = ScopeId.make(org); @@ -408,7 +168,7 @@ describe("sources api (HTTP)", () => { params: { scopeId }, payload: { targetScope: scopeId, - spec: invocableOpenApiSpec(server.baseUrl), + spec: server.specJson, namespace, }, }), @@ -453,7 +213,9 @@ describe("sources api (HTTP)", () => { }, logs: [], }); - expect(server.requests()).toEqual([{ path: "/echo/hello", suffix: "world" }]); + expect(yield* server.requests).toContainEqual( + expect.objectContaining({ path: "/echo/hello" }), + ); }), ); @@ -502,10 +264,9 @@ describe("sources api (HTTP)", () => { it.effect("added GraphQL source can be inspected and invoked through execution", () => Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => startGraphqlServer()), - (fixture) => Effect.promise(() => fixture.close()), - ); + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema({ includeMutation: false }), + }); const org = `org_${crypto.randomUUID()}`; const namespace = `gql_${crypto.randomUUID().replace(/-/g, "_")}`; const scopeId = ScopeId.make(org); @@ -556,18 +317,24 @@ describe("sources api (HTTP)", () => { status: "completed", result: { hello: "Hello Ada" }, }); - expect(server.requests().some((request) => request.query.includes("__schema"))).toBe(true); - expect(server.requests()).toContainEqual( - expect.objectContaining({ variables: { name: "Ada" } }), + const requests = yield* server.requests; + expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true); + expect(requests).toContainEqual( + expect.objectContaining({ + payload: expect.objectContaining({ variables: { name: "Ada" } }), + }), ); }), ); it.effect("added MCP source can be inspected and invoked through execution", () => Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => startMcpServer()), - (fixture) => Effect.promise(() => fixture.close()), + const server = yield* serveMcpServer(() => + makeGreetingMcpServer({ + name: "cloud-e2e-mcp", + toolDescription: "Echoes from the cloud e2e MCP server", + text: "cloud-mcp-ok", + }), ); const org = `org_${crypto.randomUUID()}`; const namespace = `mcp_${crypto.randomUUID().replace(/-/g, "_")}`; @@ -627,7 +394,7 @@ describe("sources api (HTTP)", () => { content: [{ type: "text", text: "cloud-mcp-ok" }], }, }); - expect(server.calls().length).toBeGreaterThanOrEqual(2); + expect((yield* server.requests).length).toBeGreaterThanOrEqual(2); }), ); @@ -640,11 +407,7 @@ describe("sources api (HTTP)", () => { Effect.gen(function* () { yield* client.openapi.addSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { - targetScope: ScopeId.make(org), - spec: MINIMAL_OPENAPI_SPEC, - namespace, - }, + payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), }); yield* client.sources.remove({ params: { scopeId: ScopeId.make(org), sourceId: namespace }, @@ -698,11 +461,7 @@ describe("sources api (HTTP)", () => { Effect.gen(function* () { yield* client.openapi.addSpec({ params: { scopeId: ScopeId.make(org) }, - payload: { - targetScope: ScopeId.make(org), - spec: MINIMAL_OPENAPI_SPEC, - namespace, - }, + payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), }); yield* client.openapi.updateSource({ params: { scopeId: ScopeId.make(org), namespace }, @@ -736,9 +495,7 @@ describe("sources api (HTTP)", () => { client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgId) }, payload: { - targetScope: ScopeId.make(orgId), - spec: MINIMAL_OPENAPI_SPEC, - namespace, + ...makeMinimalOpenApiSourcePayload(ScopeId.make(orgId), namespace), headers: { Authorization: { kind: "binding", diff --git a/apps/cloud/src/services/sources-refresh.node.test.ts b/apps/cloud/src/services/sources-refresh.node.test.ts index 6a9ec53a4..5d476e581 100644 --- a/apps/cloud/src/services/sources-refresh.node.test.ts +++ b/apps/cloud/src/services/sources-refresh.node.test.ts @@ -5,91 +5,39 @@ // operation set. Raw-text sources assert the no-op branch. import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import http from "node:http"; -import { AddressInfo } from "node:net"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { ScopeId } from "@executor-js/sdk"; +import { + makeOpenApiHttpApiTestSpecPayload, + serveMutableOpenApiSpecTestServer, +} from "@executor-js/plugin-openapi/testing"; import { asOrg } from "./__test-harness__/api-harness"; -const specV1 = JSON.stringify({ - openapi: "3.0.0", - info: { title: "Refresh Fixture", version: "1.0.0" }, - paths: { - "/ping": { - get: { - operationId: "ping", - summary: "ping", - responses: { "200": { description: "ok" } }, - }, - }, - }, -}); +const PingEndpoint = HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }); +const PongEndpoint = HttpApiEndpoint.get("pong", "/pong", { success: Schema.Unknown }); -const specV2 = JSON.stringify({ - openapi: "3.0.0", - info: { title: "Refresh Fixture", version: "2.0.0" }, - paths: { - "/ping": { - get: { - operationId: "ping", - summary: "ping", - responses: { "200": { description: "ok" } }, - }, - }, - "/pong": { - get: { - operationId: "pong", - summary: "pong", - responses: { "200": { description: "ok" } }, - }, - }, - }, -}); +const RefreshGroupV1 = HttpApiGroup.make("default", { topLevel: true }).add(PingEndpoint); +const RefreshGroupV2 = HttpApiGroup.make("default", { topLevel: true }) + .add(PingEndpoint) + .add(PongEndpoint); -// Mutable ref: tests flip `current` between v1 and v2 around the -// refresh call. Using a single server keeps the URL stable across -// both addSpec and refresh — the plugin persists the original URL, -// so the second fetch goes back to the same endpoint. -const serveMutableSpec = () => { - const state = { current: specV1, requests: 0 }; - const server = http.createServer((req, res) => { - state.requests++; - res.writeHead(200, { "content-type": "application/json" }); - res.end(state.current); - }); - return new Promise<{ - baseUrl: string; - setSpec: (s: string) => void; - requestCount: () => number; - close: () => Promise; - }>((resolve) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - resolve({ - baseUrl: `http://127.0.0.1:${port}`, - setSpec: (s) => { - state.current = s; - }, - requestCount: () => state.requests, - close: () => - new Promise((r) => { - server.close(() => r()); - }), - }); - }); - }); -}; +const refreshApi = (version: "1.0.0" | "2.0.0") => + HttpApi.make("refreshFixture") + .add(version === "1.0.0" ? RefreshGroupV1 : RefreshGroupV2) + .annotateMerge(OpenApi.annotations({ title: "Refresh Fixture", version })); + +const makeRefreshSpecText = () => makeOpenApiHttpApiTestSpecPayload(refreshApi("1.0.0")).spec; describe("sources.refresh (HTTP)", () => { it.effect("addSpec from URL → canRefresh:true; refresh re-fetches and updates tools", () => Effect.scoped( Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => serveMutableSpec()), - (server) => Effect.promise(() => server.close()), - ); + const server = yield* serveMutableOpenApiSpecTestServer({ + initialApi: refreshApi("1.0.0"), + }); const org = `org_${crypto.randomUUID()}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; @@ -98,7 +46,7 @@ describe("sources.refresh (HTTP)", () => { params: { scopeId: ScopeId.make(org) }, payload: { targetScope: ScopeId.make(org), - spec: `${server.baseUrl}/spec.json`, + spec: server.specUrl, namespace, }, }), @@ -115,7 +63,7 @@ describe("sources.refresh (HTTP)", () => { params: { scopeId: ScopeId.make(org), namespace }, }), ); - expect(fetchedBefore?.config.sourceUrl).toBe(`${server.baseUrl}/spec.json`); + expect(fetchedBefore?.config.sourceUrl).toBe(server.specUrl); const beforeTools = yield* asOrg(org, (client) => client.sources.tools({ @@ -123,12 +71,12 @@ describe("sources.refresh (HTTP)", () => { }), ); expect(beforeTools.length).toBe(1); - expect(beforeTools.some((t) => t.name.startsWith("ping"))).toBe(true); - expect(beforeTools.some((t) => t.name.startsWith("pong"))).toBe(false); + expect(beforeTools.some((t) => t.id.endsWith(".default.ping"))).toBe(true); + expect(beforeTools.some((t) => t.id.endsWith(".default.pong"))).toBe(false); // Flip the remote to v2 (adds `pong`) and trigger refresh. - server.setSpec(specV2); - const requestsBefore = server.requestCount(); + yield* server.setApi(refreshApi("2.0.0")); + const requestsBefore = yield* server.requestCount; const refreshResult = yield* asOrg(org, (client) => client.sources.refresh({ @@ -136,7 +84,7 @@ describe("sources.refresh (HTTP)", () => { }), ); expect(refreshResult.refreshed).toBe(true); - expect(server.requestCount()).toBeGreaterThan(requestsBefore); + expect(yield* server.requestCount).toBeGreaterThan(requestsBefore); const afterTools = yield* asOrg(org, (client) => client.sources.tools({ @@ -144,8 +92,8 @@ describe("sources.refresh (HTTP)", () => { }), ); expect(afterTools.length).toBe(2); - expect(afterTools.some((t) => t.name.startsWith("ping"))).toBe(true); - expect(afterTools.some((t) => t.name.startsWith("pong"))).toBe(true); + expect(afterTools.some((t) => t.id.endsWith(".default.ping"))).toBe(true); + expect(afterTools.some((t) => t.id.endsWith(".default.pong"))).toBe(true); }), ), ); @@ -160,7 +108,7 @@ describe("sources.refresh (HTTP)", () => { params: { scopeId: ScopeId.make(org) }, payload: { targetScope: ScopeId.make(org), - spec: specV1, + spec: makeRefreshSpecText(), namespace, }, }), diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 765f14018..6fa074347 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -3,24 +3,35 @@ // on the full cloud module graph. import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result } from "effect"; +import { Effect, Result, Schema } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; +import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { asOrg } from "./__test-harness__/api-harness"; -const MINIMAL_OPENAPI_SPEC = JSON.stringify({ - openapi: "3.0.0", - info: { title: "Tenant Test API", version: "1.0.0" }, - paths: { - "/ping": { - get: { - operationId: "ping", - responses: { "200": { description: "ok" } }, - }, - }, - }, -}); +const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), +); + +const TenantIsolationApi = HttpApi.make("tenantIsolationTest") + .add(PingGroup) + .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" })); + +const makeTenantOpenApiSourcePayload = ( + targetScope: ScopeId, + namespace: string, + options: Omit< + Parameters[1], + "targetScope" | "namespace" + > = {}, +) => + makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, { + targetScope, + namespace, + ...options, + }); describe("tenant isolation (HTTP)", () => { it.effect("write requests cannot target another org scope", () => @@ -105,11 +116,7 @@ describe("tenant isolation (HTTP)", () => { yield* asOrg(orgA, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, - payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace: namespaceA, - }, + payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), }), ); @@ -129,11 +136,7 @@ describe("tenant isolation (HTTP)", () => { yield* asOrg(orgA, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, - payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace: namespaceA, - }, + payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), }), ); @@ -156,11 +159,7 @@ describe("tenant isolation (HTTP)", () => { yield* asOrg(orgA, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, - payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace: namespaceA, - }, + payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), }), ); @@ -260,9 +259,7 @@ describe("tenant isolation (HTTP)", () => { yield* client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace: namespaceA, + ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), headers: { Authorization: { kind: "binding", @@ -307,9 +304,7 @@ describe("tenant isolation (HTTP)", () => { yield* client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace: namespaceA, + ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), headers: { Authorization: { kind: "binding", @@ -351,25 +346,19 @@ describe("tenant isolation (HTTP)", () => { yield* asOrg(orgA, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgA) }, - payload: { - targetScope: ScopeId.make(orgA), - spec: MINIMAL_OPENAPI_SPEC, - namespace, + payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespace, { name: "Org A API", baseUrl: "https://org-a.example.com", - }, + }), }), ); yield* asOrg(orgB, (client) => client.openapi.addSpec({ params: { scopeId: ScopeId.make(orgB) }, - payload: { - targetScope: ScopeId.make(orgB), - spec: MINIMAL_OPENAPI_SPEC, - namespace, + payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgB), namespace, { name: "Org B API", baseUrl: "https://org-b.example.com", - }, + }), }), ); diff --git a/apps/local/src/server/mcp-oauth.test.ts b/apps/local/src/server/mcp-oauth.test.ts index 5f1b5e710..802172597 100644 --- a/apps/local/src/server/mcp-oauth.test.ts +++ b/apps/local/src/server/mcp-oauth.test.ts @@ -10,8 +10,8 @@ // test → HttpApiClient → in-process webHandler → LocalApi // → McpHandlers → mcpPlugin.startOAuth / completeOAuth // → MCP SDK `auth()` -// → fake OAuth server (DCR, /authorize → 302, /token, AS metadata, -// protected resource metadata) +// → OAuthTestServer (DCR, /authorize → login, /token, AS metadata, +// protected resource metadata, MCP protected resource) // // Single-scope: local has one scope per project (`${folder}-${hash}`) so // the OAuth flow lands tokens at that scope and `secrets.resolve` reads @@ -19,22 +19,21 @@ // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; -import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { HttpApi, HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { Effect, Layer, Option, Schema } from "effect"; +import { Effect, Layer } from "effect"; import { addGroup, observabilityMiddleware } from "@executor-js/api"; import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; @@ -51,169 +50,6 @@ type TestApiShape = ? HttpApiClient.Client : never; -// --------------------------------------------------------------------------- -// Fake OAuth + MCP server (mirrors the cloud test) -// --------------------------------------------------------------------------- - -interface FakeServer { - readonly url: string; - readonly close: () => Promise; -} - -const RegistrationBody = Schema.Struct({ - redirect_uris: Schema.optional(Schema.Array(Schema.String)), - grant_types: Schema.optional(Schema.Array(Schema.String)), - response_types: Schema.optional(Schema.Array(Schema.String)), -}); -const decodeRegistrationBody = Schema.decodeUnknownOption(Schema.fromJsonString(RegistrationBody)); - -const startFakeServer = async (): Promise => { - const clients = new Map(); - const codes = new Map(); - const accessTokens = new Map(); - const refreshTokens = new Map(); - let seq = 0; - const next = (p: string) => `${p}_${++seq}_${randomBytes(6).toString("hex")}`; - - const readBody = (req: import("node:http").IncomingMessage): Promise => - new Promise((resolve, reject) => { - let buf = ""; - req.on("data", (chunk) => (buf += chunk)); - req.on("end", () => resolve(buf)); - req.on("error", reject); - }); - - const server: Server = createServer(async (req, res) => { - const url = new URL(req.url!, `http://${req.headers.host}`); - const send = (status: number, body: unknown, headers: Record = {}) => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - res.writeHead(status, { - "content-type": typeof body === "string" ? "text/plain" : "application/json", - ...headers, - }); - res.end(payload); - }; - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fake HTTP server returns stable 500 responses for unexpected handler failures - try { - if (url.pathname === "/.well-known/oauth-protected-resource") { - const origin = `http://${req.headers.host}`; - return send(200, { - resource: origin, - authorization_servers: [origin], - bearer_methods_supported: ["header"], - }); - } - - if (url.pathname === "/.well-known/oauth-authorization-server") { - const issuer = `http://${req.headers.host}`; - return send(200, { - issuer, - authorization_endpoint: `${issuer}/authorize`, - token_endpoint: `${issuer}/token`, - registration_endpoint: `${issuer}/register`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - }); - } - - if (url.pathname === "/register" && req.method === "POST") { - const body = await readBody(req); - const parsedOption = decodeRegistrationBody(body); - if (Option.isNone(parsedOption)) { - return send(400, { error: "invalid_registration" }); - } - const parsed = parsedOption.value; - const clientId = next("client"); - clients.set(clientId, { redirect_uris: parsed.redirect_uris ?? [] }); - return send(201, { - client_id: clientId, - client_id_issued_at: Math.floor(Date.now() / 1000), - redirect_uris: parsed.redirect_uris ?? [], - grant_types: parsed.grant_types ?? ["authorization_code", "refresh_token"], - response_types: parsed.response_types ?? ["code"], - token_endpoint_auth_method: "none", - }); - } - - if (url.pathname === "/authorize" && req.method === "GET") { - const clientId = url.searchParams.get("client_id") ?? ""; - const redirectUri = url.searchParams.get("redirect_uri") ?? ""; - const state = url.searchParams.get("state") ?? ""; - const codeChallenge = url.searchParams.get("code_challenge") ?? ""; - const method = url.searchParams.get("code_challenge_method") ?? ""; - if (!clients.has(clientId)) { - return send(400, { error: "unknown_client" }); - } - if (method !== "S256" || !codeChallenge) { - return send(400, { error: "invalid_request" }); - } - const code = next("code"); - codes.set(code, { clientId, codeChallenge }); - const destination = new URL(redirectUri); - destination.searchParams.set("code", code); - if (state) destination.searchParams.set("state", state); - return send(302, "", { location: destination.toString() }); - } - - if (url.pathname === "/token" && req.method === "POST") { - const body = await readBody(req); - const params = new URLSearchParams(body); - const grant = params.get("grant_type"); - - if (grant === "authorization_code") { - const code = params.get("code") ?? ""; - const verifier = params.get("code_verifier") ?? ""; - const record = codes.get(code); - if (!record) return send(400, { error: "invalid_grant" }); - codes.delete(code); - const computed = createHash("sha256").update(verifier).digest("base64url"); - if (computed !== record.codeChallenge) { - return send(400, { error: "invalid_grant" }); - } - const access = next("at"); - const refresh = next("rt"); - accessTokens.set(access, { refresh }); - refreshTokens.set(refresh, access); - return send(200, { - access_token: access, - refresh_token: refresh, - token_type: "Bearer", - expires_in: 3600, - }); - } - - return send(400, { error: "unsupported_grant_type" }); - } - - if (url.pathname === "/mcp") { - const origin = `http://${req.headers.host}`; - return send( - 401, - { error: "unauthorized" }, - { - "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, - }, - ); - } - - send(404, { error: "not_found", path: url.pathname }); - } catch { - send(500, { error: "server_error", message: "fake server failed" }); - } - }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); - const address = server.address() as AddressInfo; - - return { - url: `http://127.0.0.1:${address.port}`, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -}; - // --------------------------------------------------------------------------- // In-process local API harness — tmpdir SQLite + minimal plugin set. // --------------------------------------------------------------------------- @@ -300,12 +136,10 @@ const startHarness = async (tmpDir: string): Promise => { // Lifecycle // --------------------------------------------------------------------------- -let fake: FakeServer; let tmpDir: string; let harness: Harness; beforeAll(async () => { - fake = await startFakeServer(); tmpDir = mkdtempSync(join(tmpdir(), "executor-local-mcp-")); harness = await startHarness(tmpDir); }); @@ -313,83 +147,68 @@ beforeAll(async () => { afterAll(async () => { await harness.dispose(); rmSync(tmpDir, { recursive: true, force: true }); - await fake.close(); }); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const followAuthorize = async ( - authorizationUrl: string, -): Promise<{ code: string; state: string }> => { - const response = await fetch(authorizationUrl, { redirect: "manual" }); - expect(response.status).toBe(302); - const location = response.headers.get("location"); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses - if (!location) throw new Error("no location header on authorize redirect"); - const dest = new URL(location); - const code = dest.searchParams.get("code"); - const state = dest.searchParams.get("state"); - if (!code || !state) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: browser redirect helper rejects malformed fake OAuth responses - throw new Error(`redirect missing code/state: ${location}`); - } - return { code, state }; -}; - // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- describe("local mcp oauth (real OAuth + MCP server)", () => { - it("startOAuth → authorize → completeOAuth mints a Connection at the scope", async () => { - const clientLayer = FetchHttpClient.layer.pipe( - Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), - ); - - const namespace = `ns_${randomBytes(4).toString("hex")}`; - const connectionId = `mcp-oauth2-${namespace}`; - const redirectUrl = "http://local.test/api/mcp/oauth/callback"; - const scopeId = ScopeId.make(harness.scopeId); - - const run = (body: (client: TestApiShape) => Effect.Effect): Effect.Effect => - Effect.gen(function* () { - const client = yield* HttpApiClient.make(TestApi, { - baseUrl: TEST_BASE_URL, - }); - return yield* body(client); - }).pipe(Effect.provide(clientLayer)) as Effect.Effect; - - const started = await Effect.runPromise( - run((client) => - client.oauth.start({ - params: { scopeId }, - payload: { - endpoint: `${fake.url}/mcp`, - redirectUrl, - connectionId, - tokenScope: String(scopeId), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", - }, - }), - ), - ); - expect(started.sessionId).toMatch(/^oauth2_session_/); - expect(started.authorizationUrl).not.toBeNull(); - - const { code, state } = await followAuthorize(started.authorizationUrl!); - expect(state).toBe(started.sessionId); - - const completed = await Effect.runPromise( - run((client) => - client.oauth.complete({ - params: { scopeId }, - payload: { state, code }, + it.effect( + "startOAuth → authorize → completeOAuth mints a Connection at the scope", + () => + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const clientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), + ); + + const namespace = `ns_${randomBytes(4).toString("hex")}`; + const connectionId = `mcp-oauth2-${namespace}`; + const redirectUrl = "http://local.test/api/mcp/oauth/callback"; + const scopeId = ScopeId.make(harness.scopeId); + + const run = ( + body: (client: TestApiShape) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(TestApi, { + baseUrl: TEST_BASE_URL, + }); + return yield* body(client); + }).pipe(Effect.provide(clientLayer)) as Effect.Effect; + + const started = yield* run((client) => + client.oauth.start({ + params: { scopeId }, + payload: { + endpoint: oauth.mcpResourceUrl, + redirectUrl, + connectionId, + tokenScope: String(scopeId), + strategy: { kind: "dynamic-dcr" }, + pluginId: "mcp", + }, + }), + ); + expect(started.sessionId).toMatch(/^oauth2_session_/); + expect(started.authorizationUrl).not.toBeNull(); + + const { code, state } = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl!, + }); + expect(state).toBe(started.sessionId); + + const completed = yield* run((client) => + client.oauth.complete({ + params: { scopeId }, + payload: { state, code }, + }), + ); + expect(completed.connectionId).toBe(connectionId); }), ), - ); - expect(completed.connectionId).toBe(connectionId); - }, 30_000); + 30_000, + ); }); diff --git a/bun.lock b/bun.lock index da3d2eae3..6f305389d 100644 --- a/bun.lock +++ b/bun.lock @@ -439,6 +439,23 @@ "react", ], }, + "packages/core/test-servers": { + "name": "@executor-js/test-servers", + "version": "1.4.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "effect": "catalog:", + "graphql-yoga": "^5.17.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@effect/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "wrangler": "^4.81.0", + }, + }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", "version": "0.0.14", @@ -1363,6 +1380,8 @@ "@executor-js/sdk": ["@executor-js/sdk@workspace:packages/core/sdk"], + "@executor-js/test-servers": ["@executor-js/test-servers@workspace:packages/core/test-servers"], + "@executor-js/vite-plugin": ["@executor-js/vite-plugin@workspace:packages/core/vite-plugin"], "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], diff --git a/packages/core/fumadb/test/generate.test.ts b/packages/core/fumadb/test/generate.test.ts index 124756681..7ca13639f 100644 --- a/packages/core/fumadb/test/generate.test.ts +++ b/packages/core/fumadb/test/generate.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { expect, test } from "@effect/vitest"; import * as Drizzle from "../src/adapters/drizzle/generate"; import * as Prisma from "../src/adapters/prisma/generate"; import * as TypeORM from "../src/adapters/typeorm/generate"; diff --git a/packages/core/fumadb/test/migrate.test.ts b/packages/core/fumadb/test/migrate.test.ts index 90cfaa4e7..8247d9a8f 100644 --- a/packages/core/fumadb/test/migrate.test.ts +++ b/packages/core/fumadb/test/migrate.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { expect, test } from "@effect/vitest"; import { fumadb } from "../src"; import { kyselyAdapter } from "../src/adapters/kysely"; import { mongoAdapter } from "../src/adapters/mongodb"; diff --git a/packages/core/fumadb/test/query/query.test.ts b/packages/core/fumadb/test/query/query.test.ts index 6dcb392fd..918cfdb88 100644 --- a/packages/core/fumadb/test/query/query.test.ts +++ b/packages/core/fumadb/test/query/query.test.ts @@ -1,5 +1,5 @@ import { inspect } from "node:util"; -import { expect, test } from "vitest"; +import { expect, test } from "@effect/vitest"; import { fumadb } from "../../src"; import { kyselyAdapter } from "../../src/adapters/kysely"; import { mongoAdapter } from "../../src/adapters/mongodb"; diff --git a/packages/core/fumadb/test/query/relations.test.ts b/packages/core/fumadb/test/query/relations.test.ts index 814f6be87..b13467325 100644 --- a/packages/core/fumadb/test/query/relations.test.ts +++ b/packages/core/fumadb/test/query/relations.test.ts @@ -1,5 +1,5 @@ import { inspect } from "node:util"; -import { expect, test } from "vitest"; +import { expect, test } from "@effect/vitest"; import { fumadb, type InferFumaDB } from "../../src"; import { kyselyAdapter } from "../../src/adapters/kysely"; import { diff --git a/packages/core/fumadb/test/uuid.test.ts b/packages/core/fumadb/test/uuid.test.ts index 64fef1f31..48bce926e 100644 --- a/packages/core/fumadb/test/uuid.test.ts +++ b/packages/core/fumadb/test/uuid.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { expect, test } from "@effect/vitest"; import * as Drizzle from "../src/adapters/drizzle/generate"; import * as Prisma from "../src/adapters/prisma/generate"; import * as TypeORM from "../src/adapters/typeorm/generate"; diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index 3ec0a2123..1b5e479de 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -148,24 +148,40 @@ export const makeTestConfig = { +export interface TestWorkspaceHarness< + TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], +> { readonly config: ExecutorConfig & { readonly testDb: TestFumaDb }; readonly executor: Executor; readonly testDb: TestFumaDb; + readonly scopes: readonly Scope[]; } -export class TestExecutor extends Context.Service()( - "executor-sdk/TestExecutor", -) {} +export class TestWorkspace extends Context.Service()( + "executor-sdk/TestWorkspace", +) { + static readonly current = < + const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], + >() => + Effect.gen(function* () { + const workspace = yield* TestWorkspace; + return workspace as TestWorkspaceHarness; + }); +} -export const makeTestExecutorHarness = ( +export const makeTestWorkspaceHarness = ( options?: TestConfigOptions, ) => Effect.acquireRelease( Effect.gen(function* () { const config = makeTestConfig(options); const executor = yield* createExecutor(config); - return { config, executor, testDb: config.testDb } as const; + return { + config, + executor, + testDb: config.testDb, + scopes: config.scopes, + } as const; }), ({ executor, testDb }) => executor @@ -176,18 +192,18 @@ export const makeTestExecutorHarness = ( +export const makeTestWorkspaceLayer = ( options?: TestConfigOptions, ) => - Layer.effect(TestExecutor)( - makeTestExecutorHarness(options).pipe( + Layer.effect(TestWorkspace)( + makeTestWorkspaceHarness(options).pipe( Effect.tap(({ testDb }) => Effect.promise(() => testDb.warm())), ), ); export const makeTestExecutor = ( options?: TestConfigOptions, -) => makeTestExecutorHarness(options).pipe(Effect.map(({ executor }) => executor)); +) => makeTestWorkspaceHarness(options).pipe(Effect.map(({ executor }) => executor)); export const memorySecretsPlugin = definePlugin(() => { const store = new Map(); diff --git a/packages/core/sdk/src/testing.test.ts b/packages/core/sdk/src/testing.test.ts new file mode 100644 index 000000000..8ceb0aca3 --- /dev/null +++ b/packages/core/sdk/src/testing.test.ts @@ -0,0 +1,131 @@ +import { expect, layer } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { SecretId } from "./ids"; +import { SetSecretInput } from "./secrets"; +import { + makeTestWorkspaceLayer, + memorySecretsPlugin, + OAuthTestServer, + TestWorkspace, +} from "./testing"; + +const plugins = [memorySecretsPlugin()] as const; + +const TestLayer = Layer.mergeAll(makeTestWorkspaceLayer({ plugins }), OAuthTestServer.layer()); + +layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { + it.effect("TestWorkspace exposes the real executor with an explicit scope stack", () => + Effect.gen(function* () { + const workspace = yield* TestWorkspace.current(); + + expect(workspace.scopes.map((scope) => String(scope.id))).toEqual(["test-scope"]); + expect(workspace.executor.scopes.map((scope) => String(scope.id))).toEqual(["test-scope"]); + expect(yield* workspace.executor.secrets.providers()).toEqual(["memory"]); + }), + ); + + it.effect("OAuthTestServer completes a real authorization-code OAuth flow", () => + Effect.gen(function* () { + const workspace = yield* TestWorkspace.current(); + const oauth = yield* OAuthTestServer; + const scope = workspace.scopes[0]!; + + yield* workspace.executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("oauth-client-id"), + scope: scope.id, + name: "OAuth Client ID", + value: "test-client", + }), + ); + yield* workspace.executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("oauth-client-secret"), + scope: scope.id, + name: "OAuth Client Secret", + value: "test-secret", + }), + ); + + const started = yield* workspace.executor.oauth.start({ + endpoint: oauth.resourceUrl, + connectionId: "test-oauth-authorization-code", + tokenScope: String(scope.id), + redirectUrl: "http://127.0.0.1/callback", + pluginId: "test", + identityLabel: "OAuth Test", + strategy: { + kind: "authorization-code", + authorizationEndpoint: oauth.authorizationEndpoint, + tokenEndpoint: oauth.tokenEndpoint, + clientIdSecretId: "oauth-client-id", + clientSecretSecretId: "oauth-client-secret", + scopes: ["read"], + }, + }); + + expect(started.authorizationUrl).not.toBeNull(); + const authorizationUrl = started.authorizationUrl ?? ""; + const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl }); + const completed = yield* workspace.executor.oauth.complete({ + state: callback.state, + code: callback.code, + tokenScope: String(scope.id), + }); + + expect(completed.connectionId).toBe("test-oauth-authorization-code"); + const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId); + expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true); + }), + ); + + it.effect("OAuthTestServer supports MCP-style dynamic client registration", () => + Effect.gen(function* () { + const workspace = yield* TestWorkspace.current(); + const oauth = yield* OAuthTestServer; + const scope = workspace.scopes[0]!; + + const probe = yield* workspace.executor.oauth.probe({ endpoint: oauth.mcpResourceUrl }); + expect(probe.supportsDynamicRegistration).toBe(true); + expect(probe.isBearerChallengeEndpoint).toBe(true); + + const started = yield* workspace.executor.oauth.start({ + endpoint: oauth.mcpResourceUrl, + connectionId: "test-oauth-dynamic-dcr", + tokenScope: String(scope.id), + redirectUrl: "http://127.0.0.1/callback", + pluginId: "test", + identityLabel: "MCP OAuth Test", + strategy: { kind: "dynamic-dcr", scopes: ["read"] }, + }); + + expect(started.authorizationUrl).not.toBeNull(); + const authorizationUrl = started.authorizationUrl ?? ""; + const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl }); + const completed = yield* workspace.executor.oauth.complete({ + state: callback.state, + code: callback.code, + tokenScope: String(scope.id), + }); + + expect(completed.connectionId).toBe("test-oauth-dynamic-dcr"); + const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId); + expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true); + }), + ); + + it.effect( + "OAuthTestServer can mint a bearer token through the full authorization-code flow", + () => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + + const token = yield* oauth.completeAuthorizationCodeTokenFlow({ scopes: ["read"] }); + + expect(token.tokenType).toBe("Bearer"); + expect(token.accessToken).toMatch(/^at_/); + expect(yield* oauth.acceptsAccessToken(token.accessToken)).toBe(true); + }), + ); +}); diff --git a/packages/core/sdk/src/testing.ts b/packages/core/sdk/src/testing.ts index 5b7ee2926..49c1a4771 100644 --- a/packages/core/sdk/src/testing.ts +++ b/packages/core/sdk/src/testing.ts @@ -11,15 +11,26 @@ import { export { makeTestConfig, makeTestExecutor, - makeTestExecutorHarness, - makeTestExecutorLayer, + makeTestWorkspaceHarness, + makeTestWorkspaceLayer, memorySecretsPlugin, - TestExecutor, + TestWorkspace, type TestConfigOptions, type TestDatabaseBackend, - type TestExecutorHarness, type TestFumaDb, + type TestWorkspaceHarness, } from "./test-config"; +export { + OAuthTestServer, + serveOAuthTestServer, + OAuthTestServerAddressError, + OAuthTestServerFlowError, + type OAuthAuthorizationCompletion, + type OAuthTokenSet, + type OAuthTestServerOptions, + type OAuthTestServerRequest, + type OAuthTestServerShape, +} from "./testing/oauth-test-server"; export { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; export class TestHttpServerAddressError extends Data.TaggedError("TestHttpServerAddressError")<{ @@ -67,8 +78,16 @@ export const serveTestHttpApp = ( HttpServer.serve(HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler))), ); +export const serveTestHttpServerLayer = ( + serverLayer: Layer.Layer, +): Effect.Effect< + TestHttpServerShape, + TestHttpServerAddressError | TestHttpServerServeError, + EffectScope.Scope +> => makeTestHttpServer(serverLayer); + const makeTestHttpServer = ( - serverLayer: Layer.Layer, + serverLayer: Layer.Layer, ): Effect.Effect< TestHttpServerShape, TestHttpServerAddressError | TestHttpServerServeError, diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts new file mode 100644 index 000000000..d4b4adcad --- /dev/null +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -0,0 +1,733 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect"; +import { createHash, randomUUID } from "node:crypto"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; + +export class OAuthTestServerAddressError extends Data.TaggedError("OAuthTestServerAddressError")<{ + readonly address: unknown; +}> {} + +export class OAuthTestServerFlowError extends Data.TaggedError("OAuthTestServerFlowError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface OAuthAuthorizationCompletion { + readonly callbackUrl: string; + readonly code: string; + readonly state: string; +} + +export interface OAuthTokenSet { + readonly accessToken: string; + readonly refreshToken?: string; + readonly tokenType: string; + readonly expiresIn?: number; + readonly scope?: string; +} + +export interface OAuthTestServerRequest { + readonly method: string; + readonly url: string; + readonly path: string; + readonly headers: Readonly>; + readonly body: string; +} + +export interface OAuthTestServerOptions { + readonly users?: Readonly>; + readonly defaultUsername?: string; + readonly defaultPassword?: string; + readonly defaultClientId?: string; + readonly defaultClientSecret?: string; + readonly clients?: Readonly>; + readonly scopes?: readonly string[]; + readonly supportRefresh?: boolean; +} + +export interface OAuthTestServerShape { + readonly issuerUrl: string; + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly registrationEndpoint: string; + readonly protectedResourceMetadataUrl: string; + readonly resourceUrl: string; + readonly mcpResourceUrl: string; + readonly completeAuthorizationCodeFlow: (input: { + readonly authorizationUrl: string; + readonly username?: string; + readonly password?: string; + }) => Effect.Effect; + readonly completeAuthorizationCodeTokenFlow: (input?: { + readonly username?: string; + readonly password?: string; + readonly clientId?: string; + readonly clientSecret?: string; + readonly redirectUrl?: string; + readonly scopes?: readonly string[]; + readonly resource?: string; + }) => Effect.Effect; + readonly requests: Effect.Effect; + readonly clearRequests: Effect.Effect; + readonly issuedAccessTokens: Effect.Effect; + readonly acceptsAccessToken: (token: string) => Effect.Effect; + readonly acceptsAuthorizationHeader: ( + authorization: string | null | undefined, + ) => Effect.Effect; +} + +interface ClientRecord { + readonly clientSecret: string | null; + readonly redirectUris: ReadonlySet; + readonly tokenEndpointAuthMethod: string; +} + +interface AuthorizationTransaction { + readonly clientId: string; + readonly redirectUri: string; + readonly state: string; + readonly codeChallenge: string; + readonly scope: string | null; + readonly resource: string | null; +} + +interface AuthorizationCodeRecord extends AuthorizationTransaction { + readonly username: string; +} + +interface RefreshTokenRecord { + readonly clientId: string; + readonly username: string; + readonly scope: string | null; + readonly resource: string | null; +} + +const JsonObject = Schema.Record(Schema.String, Schema.Unknown); +const decodeJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(JsonObject)); +const TokenResponse = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + token_type: Schema.String, + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), +}); +const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); + +const defaultScopes = ["read", "write"] as const; + +const jsonResponse = ( + status: number, + body: Readonly>, + headers: Readonly> = {}, +): HttpServerResponse.HttpServerResponse => + HttpServerResponse.jsonUnsafe(body, { status, headers }); + +const textResponse = ( + status: number, + body: string, + headers: Readonly> = {}, +): HttpServerResponse.HttpServerResponse => + HttpServerResponse.text(body, { + status, + headers, + contentType: "text/plain; charset=utf-8", + }); + +const redirectResponse = (location: string): HttpServerResponse.HttpServerResponse => + HttpServerResponse.redirect(location); + +const parseJsonObject = (body: string): Readonly> | null => { + const result = decodeJsonObject(body); + return Option.isSome(result) ? result.value : null; +}; + +const arrayOfStrings = (value: unknown): readonly string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const decodeBasicAuthorization = ( + value: string | undefined, +): { readonly username: string; readonly password: string } | null => { + if (!value) return null; + const match = /^Basic\s+(.+)$/i.exec(value); + if (!match) return null; + const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator < 0) return null; + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +}; + +const codeChallengeForVerifier = (verifier: string): string => + createHash("sha256").update(verifier).digest("base64url"); + +const oauthError = (status: number, error: string, errorDescription: string) => + jsonResponse( + status, + { + error, + error_description: errorDescription, + }, + status === 401 ? { "www-authenticate": 'Basic realm="OAuth test server"' } : {}, + ); + +const manualRedirectHttpClientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" })), +); + +const executeManualRedirect = ( + request: HttpClientRequest.HttpClientRequest, + requestUrl: string, +): Effect.Effect => + HttpClient.execute(request).pipe( + Effect.mapError( + (cause) => + new OAuthTestServerFlowError({ + message: `OAuth test flow request failed for ${requestUrl}`, + cause, + }), + ), + Effect.provide(manualRedirectHttpClientLayer), + ); + +const executeOAuthHttp = ( + request: HttpClientRequest.HttpClientRequest, + requestUrl: string, +): Effect.Effect => + HttpClient.execute(request).pipe( + Effect.mapError( + (cause) => + new OAuthTestServerFlowError({ + message: `OAuth test flow request failed for ${requestUrl}`, + cause, + }), + ), + Effect.provide(FetchHttpClient.layer), + ); + +const requiredRedirectLocation = ( + response: HttpClientResponse.HttpClientResponse, + requestUrl: string, +): Effect.Effect => + Effect.gen(function* () { + if (response.status < 300 || response.status >= 400) { + return yield* new OAuthTestServerFlowError({ + message: `Expected redirect from ${requestUrl}, got HTTP ${response.status}`, + }); + } + const location = response.headers.location; + if (!location) { + return yield* new OAuthTestServerFlowError({ + message: `Expected Location header from ${requestUrl}`, + }); + } + return new URL(location, requestUrl).toString(); + }); + +const serveOAuthTestHttpApp = ( + handler: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect, +): Effect.Effect<{ readonly baseUrl: string }, OAuthTestServerAddressError, Scope.Scope> => + Effect.gen(function* () { + const context = yield* Layer.build( + Layer.fresh( + HttpServer.serve( + HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler)), + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), + ), + ).pipe(Effect.mapError((address) => new OAuthTestServerAddressError({ address }))); + const server = Context.get(context, HttpServer.HttpServer); + const address = server.address; + if (!Predicate.isTagged(address, "TcpAddress")) { + return yield* new OAuthTestServerAddressError({ address }); + } + return { baseUrl: `http://127.0.0.1:${address.port}` }; + }); + +const requestBodyText = (request: HttpServerRequest.HttpServerRequest): Effect.Effect => + request.text.pipe(Effect.catch(() => Effect.succeed(""))); + +const completeAuthorizationCodeFlow = + (defaults: { readonly username: string; readonly password: string }) => + (input: { + readonly authorizationUrl: string; + readonly username?: string; + readonly password?: string; + }): Effect.Effect => + Effect.gen(function* () { + const loginResponse = yield* executeManualRedirect( + HttpClientRequest.get(input.authorizationUrl), + input.authorizationUrl, + ); + const loginUrl = yield* requiredRedirectLocation(loginResponse, input.authorizationUrl); + const credentials = Buffer.from( + `${input.username ?? defaults.username}:${input.password ?? defaults.password}`, + ).toString("base64"); + const callbackResponse = yield* executeManualRedirect( + HttpClientRequest.post(loginUrl).pipe( + HttpClientRequest.setHeader("authorization", `Basic ${credentials}`), + ), + loginUrl, + ); + const callbackUrl = yield* requiredRedirectLocation(callbackResponse, loginUrl); + const parsed = new URL(callbackUrl); + const state = parsed.searchParams.get("state"); + const code = parsed.searchParams.get("code"); + if (!state || !code) { + return yield* new OAuthTestServerFlowError({ + message: "OAuth callback did not include both state and code", + }); + } + return { callbackUrl, state, code }; + }); + +const completeAuthorizationCodeTokenFlow = + (defaults: { + readonly username: string; + readonly password: string; + readonly clientId: string; + readonly clientSecret: string; + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + }) => + ( + input: { + readonly username?: string; + readonly password?: string; + readonly clientId?: string; + readonly clientSecret?: string; + readonly redirectUrl?: string; + readonly scopes?: readonly string[]; + readonly resource?: string; + } = {}, + ): Effect.Effect => + Effect.gen(function* () { + const clientId = input.clientId ?? defaults.clientId; + const clientSecret = input.clientSecret ?? defaults.clientSecret; + const redirectUrl = input.redirectUrl ?? "http://127.0.0.1/callback"; + const codeVerifier = `verifier_${randomUUID()}`; + const state = `state_${randomUUID()}`; + const authorizationUrl = new URL(defaults.authorizationEndpoint); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("client_id", clientId); + authorizationUrl.searchParams.set("redirect_uri", redirectUrl); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("code_challenge", codeChallengeForVerifier(codeVerifier)); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + if (input.scopes && input.scopes.length > 0) { + authorizationUrl.searchParams.set("scope", input.scopes.join(" ")); + } + if (input.resource) { + authorizationUrl.searchParams.set("resource", input.resource); + } + + const callback = yield* completeAuthorizationCodeFlow({ + username: defaults.username, + password: defaults.password, + })({ + authorizationUrl: authorizationUrl.toString(), + username: input.username, + password: input.password, + }); + const tokenResponse = yield* executeOAuthHttp( + HttpClientRequest.post(defaults.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams({ + grant_type: "authorization_code", + code: callback.code, + redirect_uri: redirectUrl, + client_id: clientId, + client_secret: clientSecret, + code_verifier: codeVerifier, + }), + ), + defaults.tokenEndpoint, + ); + if (tokenResponse.status !== 200) { + const body = yield* tokenResponse.text.pipe( + Effect.catch(() => Effect.succeed("")), + ); + return yield* new OAuthTestServerFlowError({ + message: `Expected token response HTTP 200, got HTTP ${tokenResponse.status}: ${body}`, + }); + } + const raw = yield* tokenResponse.json.pipe( + Effect.mapError( + (cause) => + new OAuthTestServerFlowError({ + message: "OAuth token response was not valid JSON", + cause, + }), + ), + ); + const token = yield* decodeTokenResponse(raw).pipe( + Effect.mapError( + (cause) => + new OAuthTestServerFlowError({ + message: "OAuth token response did not match the expected shape", + cause, + }), + ), + ); + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + tokenType: token.token_type, + expiresIn: token.expires_in, + scope: token.scope, + }; + }); + +export const serveOAuthTestServer = ( + options: OAuthTestServerOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const requests = yield* Ref.make([]); + const issuedAccessTokens = yield* Ref.make>(new Set()); + const users = { + [options.defaultUsername ?? "alice"]: options.defaultPassword ?? "password", + ...(options.users ?? {}), + }; + const supportRefresh = options.supportRefresh ?? true; + const scopes = options.scopes ?? defaultScopes; + const clients = new Map(); + const transactions = new Map(); + const authorizationCodes = new Map(); + const refreshTokens = new Map(); + const defaultClientId = options.defaultClientId ?? "test-client"; + const defaultClientSecret = options.defaultClientSecret ?? "test-secret"; + + clients.set(defaultClientId, { + clientSecret: defaultClientSecret, + redirectUris: new Set(), + tokenEndpointAuthMethod: "client_secret_post", + }); + for (const [clientId, clientSecret] of Object.entries(options.clients ?? {})) { + clients.set(clientId, { + clientSecret, + redirectUris: new Set(), + tokenEndpointAuthMethod: clientSecret === null ? "none" : "client_secret_post", + }); + } + + let issuerUrl = ""; + const server = yield* serveOAuthTestHttpApp((request) => + Effect.gen(function* () { + const currentIssuerUrl = issuerUrl || "http://127.0.0.1"; + const requestUrl = new URL(request.url, currentIssuerUrl); + const body = yield* requestBodyText(request); + const headers = request.headers; + + yield* Ref.update(requests, (all) => [ + ...all, + { + method: request.method, + url: requestUrl.toString(), + path: requestUrl.pathname, + headers, + body, + }, + ]); + + if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) { + const suffix = requestUrl.pathname.slice("/.well-known/oauth-protected-resource".length); + const resource = `${currentIssuerUrl}${suffix}`; + return jsonResponse(200, { + resource, + authorization_servers: [currentIssuerUrl], + bearer_methods_supported: ["header"], + scopes_supported: scopes, + }); + } + + if ( + requestUrl.pathname === "/.well-known/oauth-authorization-server" || + requestUrl.pathname === "/.well-known/openid-configuration" + ) { + return jsonResponse(200, { + issuer: currentIssuerUrl, + authorization_endpoint: `${currentIssuerUrl}/authorize`, + token_endpoint: `${currentIssuerUrl}/token`, + registration_endpoint: `${currentIssuerUrl}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: [ + "none", + "client_secret_post", + "client_secret_basic", + ], + scopes_supported: scopes, + }); + } + + if (requestUrl.pathname === "/register" && request.method === "POST") { + const json = parseJsonObject(body); + if (!json) { + return oauthError(400, "invalid_client_metadata", "Expected JSON body"); + } + const requestedMethod = + typeof json.token_endpoint_auth_method === "string" + ? json.token_endpoint_auth_method + : "none"; + const clientId = `client_${randomUUID()}`; + const clientSecret = + requestedMethod === "client_secret_basic" || requestedMethod === "client_secret_post" + ? `secret_${randomUUID()}` + : null; + const redirectUris = new Set(arrayOfStrings(json.redirect_uris)); + clients.set(clientId, { + clientSecret, + redirectUris, + tokenEndpointAuthMethod: requestedMethod, + }); + return jsonResponse( + 201, + { + client_id: clientId, + ...(clientSecret ? { client_secret: clientSecret } : {}), + client_id_issued_at: Math.floor(Date.now() / 1000), + token_endpoint_auth_method: requestedMethod, + redirect_uris: [...redirectUris], + grant_types: arrayOfStrings(json.grant_types), + response_types: arrayOfStrings(json.response_types), + scope: typeof json.scope === "string" ? json.scope : scopes.join(" "), + }, + { "cache-control": "no-store" }, + ); + } + + if (requestUrl.pathname === "/authorize" && request.method === "GET") { + const clientId = requestUrl.searchParams.get("client_id"); + const redirectUri = requestUrl.searchParams.get("redirect_uri"); + const state = requestUrl.searchParams.get("state"); + const codeChallenge = requestUrl.searchParams.get("code_challenge"); + const responseType = requestUrl.searchParams.get("response_type"); + if (!clientId || !redirectUri || !state || !codeChallenge || responseType !== "code") { + return oauthError(400, "invalid_request", "Missing authorization parameters"); + } + const client = clients.get(clientId); + if (client && client.redirectUris.size > 0 && !client.redirectUris.has(redirectUri)) { + return oauthError(400, "invalid_request", "redirect_uri is not registered"); + } + if (!client) { + clients.set(clientId, { + clientSecret: null, + redirectUris: new Set([redirectUri]), + tokenEndpointAuthMethod: "none", + }); + } + const transaction = `txn_${randomUUID()}`; + transactions.set(transaction, { + clientId, + redirectUri, + state, + codeChallenge, + scope: requestUrl.searchParams.get("scope"), + resource: requestUrl.searchParams.get("resource"), + }); + return redirectResponse( + `${currentIssuerUrl}/login?transaction=${encodeURIComponent(transaction)}`, + ); + } + + if (requestUrl.pathname === "/login") { + const transactionId = requestUrl.searchParams.get("transaction"); + const transaction = transactionId ? transactions.get(transactionId) : undefined; + if (!transactionId || !transaction) { + return oauthError(400, "invalid_request", "Unknown login transaction"); + } + if (request.method === "GET") { + return textResponse(200, "OAuth test login"); + } + const basic = decodeBasicAuthorization(headers.authorization); + if (!basic || users[basic.username] !== basic.password) { + return jsonResponse( + 401, + { error: "access_denied" }, + { "www-authenticate": 'Basic realm="OAuth test server"' }, + ); + } + const code = `code_${randomUUID()}`; + transactions.delete(transactionId); + authorizationCodes.set(code, { ...transaction, username: basic.username }); + const callbackUrl = new URL(transaction.redirectUri); + callbackUrl.searchParams.set("code", code); + callbackUrl.searchParams.set("state", transaction.state); + return redirectResponse(callbackUrl.toString()); + } + + if (requestUrl.pathname === "/token" && request.method === "POST") { + const params = new URLSearchParams(body); + const basic = decodeBasicAuthorization(headers.authorization); + const clientId = basic?.username ?? params.get("client_id"); + const clientSecret = basic?.password ?? params.get("client_secret"); + const client = clientId ? clients.get(clientId) : undefined; + if (!clientId || !client) { + return oauthError(401, "invalid_client", "Unknown client"); + } + if (client.clientSecret !== null && client.clientSecret !== clientSecret) { + return oauthError(401, "invalid_client", "Invalid client secret"); + } + + const grantType = params.get("grant_type"); + if (grantType === "authorization_code") { + const code = params.get("code"); + const redirectUri = params.get("redirect_uri"); + const codeVerifier = params.get("code_verifier"); + const record = code ? authorizationCodes.get(code) : undefined; + if (!code || !redirectUri || !codeVerifier || !record) { + return oauthError(400, "invalid_grant", "Unknown authorization code"); + } + if ( + record.clientId !== clientId || + record.redirectUri !== redirectUri || + record.codeChallenge !== codeChallengeForVerifier(codeVerifier) + ) { + return oauthError(400, "invalid_grant", "Authorization code validation failed"); + } + authorizationCodes.delete(code); + const accessToken = `at_${randomUUID()}`; + const refreshToken = `rt_${randomUUID()}`; + yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken])); + refreshTokens.set(refreshToken, { + clientId, + username: record.username, + scope: record.scope, + resource: record.resource, + }); + return jsonResponse( + 200, + { + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer", + expires_in: 3600, + ...(record.scope ? { scope: record.scope } : {}), + }, + { "cache-control": "no-store" }, + ); + } + + if (grantType === "refresh_token") { + const refreshToken = params.get("refresh_token"); + const record = refreshToken ? refreshTokens.get(refreshToken) : undefined; + if (!supportRefresh || !refreshToken || !record || record.clientId !== clientId) { + return oauthError(400, "invalid_grant", "Unknown refresh token"); + } + const nextAccessToken = `at_${randomUUID()}`; + const nextRefreshToken = `rt_${randomUUID()}`; + refreshTokens.delete(refreshToken); + refreshTokens.set(nextRefreshToken, record); + yield* Ref.update( + issuedAccessTokens, + (tokens) => new Set([...tokens, nextAccessToken]), + ); + return jsonResponse( + 200, + { + access_token: nextAccessToken, + refresh_token: nextRefreshToken, + token_type: "Bearer", + expires_in: 3600, + ...(record.scope ? { scope: record.scope } : {}), + }, + { "cache-control": "no-store" }, + ); + } + + if (grantType === "client_credentials") { + const accessToken = `at_${randomUUID()}`; + yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken])); + return jsonResponse( + 200, + { + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + scope: params.get("scope") ?? scopes.join(" "), + }, + { "cache-control": "no-store" }, + ); + } + + return oauthError(400, "unsupported_grant_type", "Unsupported grant type"); + } + + if (requestUrl.pathname === "/mcp") { + const authorization = headers.authorization; + const token = authorization?.replace(/^Bearer\s+/i, ""); + const valid = token + ? yield* Ref.get(issuedAccessTokens).pipe(Effect.map((tokens) => tokens.has(token))) + : false; + if (!valid) { + return jsonResponse( + 401, + { error: "invalid_token" }, + { + "www-authenticate": `Bearer resource_metadata="${currentIssuerUrl}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`, + }, + ); + } + return jsonResponse(200, { + jsonrpc: "2.0", + id: 1, + result: { protocolVersion: "2025-06-18", capabilities: {} }, + }); + } + + return jsonResponse(404, { error: "not_found" }); + }), + ); + + issuerUrl = server.baseUrl; + const accessTokenSet = Ref.get(issuedAccessTokens); + + return { + issuerUrl, + authorizationEndpoint: `${issuerUrl}/authorize`, + tokenEndpoint: `${issuerUrl}/token`, + registrationEndpoint: `${issuerUrl}/register`, + protectedResourceMetadataUrl: `${issuerUrl}/.well-known/oauth-protected-resource`, + resourceUrl: issuerUrl, + mcpResourceUrl: `${issuerUrl}/mcp`, + completeAuthorizationCodeFlow: completeAuthorizationCodeFlow({ + username: options.defaultUsername ?? "alice", + password: options.defaultPassword ?? "password", + }), + completeAuthorizationCodeTokenFlow: completeAuthorizationCodeTokenFlow({ + username: options.defaultUsername ?? "alice", + password: options.defaultPassword ?? "password", + clientId: defaultClientId, + clientSecret: defaultClientSecret, + authorizationEndpoint: `${issuerUrl}/authorize`, + tokenEndpoint: `${issuerUrl}/token`, + }), + requests: Ref.get(requests), + clearRequests: Ref.set(requests, []), + issuedAccessTokens: accessTokenSet.pipe(Effect.map((tokens) => [...tokens])), + acceptsAccessToken: (token) => accessTokenSet.pipe(Effect.map((tokens) => tokens.has(token))), + acceptsAuthorizationHeader: (authorization) => { + const token = authorization?.replace(/^Bearer\s+/i, ""); + return token + ? accessTokenSet.pipe(Effect.map((tokens) => tokens.has(token))) + : Effect.succeed(false); + }, + }; + }); + +export class OAuthTestServer extends Context.Service()( + "@executor-js/sdk/testing/OAuthTestServer", +) { + static readonly layer = (options?: OAuthTestServerOptions) => + Layer.effect(OAuthTestServer, serveOAuthTestServer(options)); +} diff --git a/packages/core/test-servers/CHANGELOG.md b/packages/core/test-servers/CHANGELOG.md new file mode 100644 index 000000000..3f3bb2d31 --- /dev/null +++ b/packages/core/test-servers/CHANGELOG.md @@ -0,0 +1,4 @@ +# @executor-js/test-servers changelog + +This file exists for Changesets release workflow compatibility. +Canonical user-facing release notes are published on GitHub Releases. diff --git a/packages/core/test-servers/README.md b/packages/core/test-servers/README.md new file mode 100644 index 000000000..27ec20ebd --- /dev/null +++ b/packages/core/test-servers/README.md @@ -0,0 +1,61 @@ +# @executor-js/test-servers + +Deployable realistic protocol test servers for smoke and end-to-end tests. + +The Worker exposes OAuth-protected OpenAPI, GraphQL, and MCP endpoints from one +origin. Tests can run against the in-process Worker export, `wrangler dev`, or a +Cloudflare Workers deployment. + +## Commands + +```sh +bun run test +bun run typecheck +bun run dev:cloudflare +bun run deploy:cloudflare +``` + +## OAuth + +Default client credentials: + +- `client_id`: `test-client` +- `client_secret`: `test-secret` + +Resource owner credentials for the basic-login authorization page: + +- username: `alice` +- password: `password` + +Discovery endpoints: + +- `/.well-known/oauth-authorization-server` +- `/.well-known/openid-configuration` +- `/.well-known/oauth-protected-resource/openapi/items` +- `/.well-known/oauth-protected-resource/graphql` +- `/.well-known/oauth-protected-resource/mcp` + +The authorization-code flow uses PKCE `S256`. A successful token response mints +Bearer access tokens that authorize the protocol endpoints. + +## Protocol Endpoints + +- `GET /openapi/spec.json`: OpenAPI 3 spec generated from an Effect `HttpApi`. +- `GET /openapi/items`: OAuth-protected OpenAPI operation. +- `POST /graphql`: OAuth-protected GraphQL Yoga endpoint. +- `/mcp`: OAuth-protected MCP Streamable HTTP endpoint. + +The OpenAPI spec includes OAuth2 authorization-code security metadata pointing +back to this Worker origin, so clients can discover and complete the OAuth flow +against the same deployed endpoint. + +## In-Process Use + +```ts +import worker from "@executor-js/test-servers"; + +const response = await worker.fetch(new Request("https://example.test/health")); +``` + +The in-process path is what `src/worker.test.ts` uses, so the deployed Worker +and the local test fixture exercise the same implementation. diff --git a/packages/core/test-servers/package.json b/packages/core/test-servers/package.json new file mode 100644 index 000000000..0015254cc --- /dev/null +++ b/packages/core/test-servers/package.json @@ -0,0 +1,28 @@ +{ + "name": "@executor-js/test-servers", + "version": "1.4.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/worker.ts" + }, + "scripts": { + "deploy:cloudflare": "wrangler deploy", + "dev:cloudflare": "wrangler dev", + "typecheck": "tsgo --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "effect": "catalog:", + "graphql-yoga": "^5.17.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250620.0", + "@effect/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "wrangler": "^4.81.0" + } +} diff --git a/packages/core/test-servers/src/worker.test.ts b/packages/core/test-servers/src/worker.test.ts new file mode 100644 index 000000000..1fe011e68 --- /dev/null +++ b/packages/core/test-servers/src/worker.test.ts @@ -0,0 +1,230 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import worker from "./worker"; + +const origin = "https://executor-test-servers.example"; + +const TokenResponse = Schema.Struct({ + access_token: Schema.String, +}); +const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); + +const OpenApiItemsResponse = Schema.Array( + Schema.Struct({ id: Schema.Number, name: Schema.String }), +); +const decodeOpenApiItemsResponse = Schema.decodeUnknownEffect(OpenApiItemsResponse); + +const OpenApiSpecResponse = Schema.Struct({ + openapi: Schema.String, + servers: Schema.Array(Schema.Struct({ url: Schema.String })), + paths: Schema.Record(Schema.String, Schema.Unknown), + components: Schema.Struct({ + securitySchemes: Schema.Struct({ + oauth2: Schema.Struct({ + type: Schema.Literal("oauth2"), + flows: Schema.Struct({ + authorizationCode: Schema.Struct({ + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + scopes: Schema.Record(Schema.String, Schema.String), + }), + }), + }), + }), + }), +}); +const decodeOpenApiSpecResponse = Schema.decodeUnknownEffect(OpenApiSpecResponse); + +const OAuthMetadataResponse = Schema.Struct({ + issuer: Schema.String, + authorization_endpoint: Schema.String, + token_endpoint: Schema.String, + registration_endpoint: Schema.String, + code_challenge_methods_supported: Schema.Array(Schema.String), +}); +const decodeOAuthMetadataResponse = Schema.decodeUnknownEffect(OAuthMetadataResponse); + +const ProtectedResourceMetadataResponse = Schema.Struct({ + resource: Schema.String, + authorization_servers: Schema.Array(Schema.String), + bearer_methods_supported: Schema.Array(Schema.String), + scopes_supported: Schema.Array(Schema.String), +}); +const decodeProtectedResourceMetadataResponse = Schema.decodeUnknownEffect( + ProtectedResourceMetadataResponse, +); + +const GraphqlResponse = Schema.Struct({ + data: Schema.Struct({ hello: Schema.String }), +}); +const decodeGraphqlResponse = Schema.decodeUnknownEffect(GraphqlResponse); + +const request = (path: string, init?: RequestInit) => new Request(`${origin}${path}`, init); + +const workerFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const nextRequest = + input instanceof Request ? new Request(input, init) : new Request(input, init); + return worker.fetch(nextRequest); +}; + +const base64url = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; + +const codeChallengeForVerifier = (verifier: string): Promise => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)).then(base64url); + +const authorize = Effect.gen(function* () { + const redirectUrl = `${origin}/callback`; + const verifier = `verifier_${crypto.randomUUID()}`; + const authorizationUrl = new URL(`${origin}/authorize`); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("client_id", "test-client"); + authorizationUrl.searchParams.set("redirect_uri", redirectUrl); + authorizationUrl.searchParams.set("state", "state"); + authorizationUrl.searchParams.set("scope", "read"); + authorizationUrl.searchParams.set( + "code_challenge", + yield* Effect.promise(() => codeChallengeForVerifier(verifier)), + ); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + + const loginRedirect = yield* Effect.promise(() => worker.fetch(new Request(authorizationUrl))); + const loginUrl = loginRedirect.headers.get("location"); + expect(loginRedirect.status).toBe(302); + expect(loginUrl).not.toBeNull(); + + const callbackRedirect = yield* Effect.promise(() => + worker.fetch( + new Request(new URL(loginUrl ?? "", origin), { + method: "POST", + headers: { authorization: `Basic ${btoa("alice:password")}` }, + }), + ), + ); + const callbackUrl = callbackRedirect.headers.get("location"); + expect(callbackRedirect.status).toBe(302); + expect(callbackUrl).not.toBeNull(); + + const code = new URL(callbackUrl ?? "").searchParams.get("code"); + expect(code).not.toBeNull(); + + const tokenResponse = yield* Effect.promise(() => + worker.fetch( + request("/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: "test-client", + client_secret: "test-secret", + redirect_uri: redirectUrl, + code: code ?? "", + code_verifier: verifier, + }), + }), + ), + ); + expect(tokenResponse.status).toBe(200); + const tokenBody = yield* Effect.promise(() => tokenResponse.json()).pipe( + Effect.flatMap(decodeTokenResponse), + ); + return tokenBody.access_token; +}); + +it.effect("worker exposes OAuth-protected OpenAPI, GraphQL, and MCP endpoints", () => + Effect.gen(function* () { + const oauthMetadataResponse = yield* Effect.promise(() => + worker.fetch(request("/.well-known/oauth-authorization-server")), + ); + const oauthMetadata = yield* Effect.promise(() => oauthMetadataResponse.json()).pipe( + Effect.flatMap(decodeOAuthMetadataResponse), + ); + expect(oauthMetadata).toMatchObject({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + }); + expect(oauthMetadata.code_challenge_methods_supported).toContain("S256"); + + const resourceMetadataResponse = yield* Effect.promise(() => + worker.fetch(request("/.well-known/oauth-protected-resource/openapi/items")), + ); + const resourceMetadata = yield* Effect.promise(() => resourceMetadataResponse.json()).pipe( + Effect.flatMap(decodeProtectedResourceMetadataResponse), + ); + expect(resourceMetadata).toEqual({ + resource: `${origin}/openapi/items`, + authorization_servers: [origin], + bearer_methods_supported: ["header"], + scopes_supported: ["read", "write"], + }); + + const openApiSpecResponse = yield* Effect.promise(() => + worker.fetch(request("/openapi/spec.json")), + ); + const openApiSpec = yield* Effect.promise(() => openApiSpecResponse.json()).pipe( + Effect.flatMap(decodeOpenApiSpecResponse), + ); + expect(openApiSpec.servers).toEqual([{ url: `${origin}/openapi` }]); + expect(openApiSpec.paths).toHaveProperty("/items"); + expect(openApiSpec.components.securitySchemes.oauth2.flows.authorizationCode).toMatchObject({ + authorizationUrl: `${origin}/authorize`, + tokenUrl: `${origin}/token`, + scopes: { read: "Read test resources" }, + }); + + const accessToken = yield* authorize; + + const openApiResponse = yield* Effect.promise(() => + worker.fetch( + request("/openapi/items", { headers: { authorization: `Bearer ${accessToken}` } }), + ), + ); + const items = yield* Effect.promise(() => openApiResponse.json()).pipe( + Effect.flatMap(decodeOpenApiItemsResponse), + ); + expect(items).toEqual([ + { id: 1, name: "Widget" }, + { id: 2, name: "Gadget" }, + ]); + + const graphqlResponse = yield* Effect.promise(() => + worker.fetch( + request("/graphql", { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ query: '{ hello(name: "Ada") }' }), + }), + ), + ); + const graphqlBody = yield* Effect.promise(() => graphqlResponse.json()).pipe( + Effect.flatMap(decodeGraphqlResponse), + ); + expect(graphqlBody).toEqual({ data: { hello: "Hello Ada" } }); + + const client = new Client({ name: "executor-worker-test-client", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + fetch: workerFetch, + requestInit: { headers: { authorization: `Bearer ${accessToken}` } }, + }); + yield* Effect.tryPromise(() => client.connect(transport)); + const tools = yield* Effect.tryPromise(() => client.listTools()); + const result = yield* Effect.tryPromise(() => + client.callTool({ name: "hello", arguments: { name: "Ada" } }), + ); + yield* Effect.promise(() => client.close()); + + expect(tools.tools.map((tool) => tool.name)).toEqual(["hello"]); + expect(result).toMatchObject({ content: [{ type: "text", text: "Hello Ada" }] }); + }), +); diff --git a/packages/core/test-servers/src/worker.ts b/packages/core/test-servers/src/worker.ts new file mode 100644 index 000000000..3158b84d3 --- /dev/null +++ b/packages/core/test-servers/src/worker.ts @@ -0,0 +1,379 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { createSchema, createYoga } from "graphql-yoga"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import z from "zod"; + +type ClientRecord = { + readonly clientSecret: string | null; + readonly redirectUris: ReadonlySet; +}; + +type AuthorizationTransaction = { + readonly clientId: string; + readonly redirectUri: string; + readonly state: string; + readonly codeChallenge: string; + readonly scope: string | null; +}; + +type AuthorizationCode = AuthorizationTransaction & { + readonly username: string; +}; + +const clients = new Map([ + ["test-client", { clientSecret: "test-secret", redirectUris: new Set() }], +]); +const transactions = new Map(); +const authorizationCodes = new Map(); +const issuedAccessTokens = new Set(); + +const json = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + +const text = (body: string, init?: ResponseInit) => + new Response(body, { + ...init, + headers: { "content-type": "text/plain; charset=utf-8", ...init?.headers }, + }); + +const redirect = (location: string) => new Response(null, { status: 302, headers: { location } }); + +const decodeBasic = ( + header: string | null, +): { readonly username: string; readonly password: string } | null => { + if (!header?.startsWith("Basic ")) return null; + const decoded = atob(header.slice("Basic ".length)); + const separator = decoded.indexOf(":"); + if (separator < 0) return null; + return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; +}; + +const base64url = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; + +const codeChallengeForVerifier = async (verifier: string): Promise => + base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); + +const bearerToken = (request: Request): string | null => { + const header = request.headers.get("authorization"); + return header?.replace(/^Bearer\s+/i, "") ?? null; +}; + +const isAuthorized = (request: Request): boolean => { + const token = bearerToken(request); + return token ? issuedAccessTokens.has(token) : false; +}; + +const unauthorized = (request: Request) => { + const url = new URL(request.url); + return json( + { error: "invalid_token" }, + { + status: 401, + headers: { + "www-authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${url.pathname}", error="invalid_token"`, + }, + }, + ); +}; + +const oauthMetadata = (request: Request) => { + const url = new URL(request.url); + return json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + registration_endpoint: `${url.origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"], + scopes_supported: ["read", "write"], + }); +}; + +const protectedResourceMetadata = (request: Request) => { + const url = new URL(request.url); + const suffix = url.pathname.slice("/.well-known/oauth-protected-resource".length); + return json({ + resource: `${url.origin}${suffix}`, + authorization_servers: [url.origin], + bearer_methods_supported: ["header"], + scopes_supported: ["read", "write"], + }); +}; + +const handleRegister = async (request: Request) => { + const body = (await request.json()) as { + readonly redirect_uris?: readonly string[]; + readonly token_endpoint_auth_method?: string; + }; + const clientId = `client_${crypto.randomUUID()}`; + const authMethod = body.token_endpoint_auth_method ?? "none"; + const clientSecret = authMethod === "none" ? null : `secret_${crypto.randomUUID()}`; + clients.set(clientId, { + clientSecret, + redirectUris: new Set(body.redirect_uris ?? []), + }); + return json( + { + client_id: clientId, + ...(clientSecret ? { client_secret: clientSecret } : {}), + token_endpoint_auth_method: authMethod, + redirect_uris: body.redirect_uris ?? [], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + }, + { status: 201, headers: { "cache-control": "no-store" } }, + ); +}; + +const handleAuthorize = (request: Request) => { + const url = new URL(request.url); + const clientId = url.searchParams.get("client_id"); + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + const codeChallenge = url.searchParams.get("code_challenge"); + if (!clientId || !redirectUri || !state || !codeChallenge) { + return json({ error: "invalid_request" }, { status: 400 }); + } + const client = clients.get(clientId); + if (client?.redirectUris.size && !client.redirectUris.has(redirectUri)) { + return json( + { error: "invalid_request", error_description: "redirect_uri is not registered" }, + { status: 400 }, + ); + } + if (!client) { + clients.set(clientId, { clientSecret: null, redirectUris: new Set([redirectUri]) }); + } + const transaction = `txn_${crypto.randomUUID()}`; + transactions.set(transaction, { + clientId, + redirectUri, + state, + codeChallenge, + scope: url.searchParams.get("scope"), + }); + return redirect(`${url.origin}/login?transaction=${encodeURIComponent(transaction)}`); +}; + +const handleLogin = async (request: Request) => { + const url = new URL(request.url); + const transactionId = url.searchParams.get("transaction"); + const transaction = transactionId ? transactions.get(transactionId) : undefined; + if (!transactionId || !transaction) return json({ error: "invalid_request" }, { status: 400 }); + if (request.method === "GET") return text("OAuth test login"); + + const basic = decodeBasic(request.headers.get("authorization")); + if (!basic || basic.username !== "alice" || basic.password !== "password") { + return json( + { error: "access_denied" }, + { status: 401, headers: { "www-authenticate": 'Basic realm="Executor test servers"' } }, + ); + } + const code = `code_${crypto.randomUUID()}`; + transactions.delete(transactionId); + authorizationCodes.set(code, { ...transaction, username: basic.username }); + const callback = new URL(transaction.redirectUri); + callback.searchParams.set("code", code); + callback.searchParams.set("state", transaction.state); + return redirect(callback.toString()); +}; + +const handleToken = async (request: Request) => { + const params = new URLSearchParams(await request.text()); + const basic = decodeBasic(request.headers.get("authorization")); + const clientId = basic?.username ?? params.get("client_id"); + const clientSecret = basic?.password ?? params.get("client_secret"); + const client = clientId ? clients.get(clientId) : undefined; + if (!clientId || !client) return json({ error: "invalid_client" }, { status: 401 }); + if (client.clientSecret !== null && client.clientSecret !== clientSecret) { + return json({ error: "invalid_client" }, { status: 401 }); + } + const code = params.get("code"); + const redirectUri = params.get("redirect_uri"); + const verifier = params.get("code_verifier"); + const record = code ? authorizationCodes.get(code) : undefined; + if (!code || !redirectUri || !verifier || !record) { + return json({ error: "invalid_grant" }, { status: 400 }); + } + if ( + record.clientId !== clientId || + record.redirectUri !== redirectUri || + record.codeChallenge !== (await codeChallengeForVerifier(verifier)) + ) { + return json({ error: "invalid_grant" }, { status: 400 }); + } + authorizationCodes.delete(code); + const accessToken = `at_${crypto.randomUUID()}`; + const refreshToken = `rt_${crypto.randomUUID()}`; + issuedAccessTokens.add(accessToken); + return json( + { + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer", + expires_in: 3600, + ...(record.scope ? { scope: record.scope } : {}), + }, + { headers: { "cache-control": "no-store" } }, + ); +}; + +const OpenApiEchoItem = Schema.Struct({ id: Schema.Number, name: Schema.String }); +const OpenApiEchoItemsGroup = HttpApiGroup.make("items").add( + HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(OpenApiEchoItem) }), +); +const OpenApiEchoApi = HttpApi.make("executorOpenApiWorkerTest") + .add(OpenApiEchoItemsGroup) + .annotateMerge( + OpenApi.annotations({ title: "Executor Worker OpenAPI Test Server", version: "1.0.0" }), + ); + +const openApiSpec = (request: Request) => { + const url = new URL(request.url); + const apiBaseUrl = `${url.origin}/openapi`; + const spec = OpenApi.fromApi( + (OpenApiEchoApi as HttpApi.AnyWithProps).annotateMerge( + OpenApi.annotations({ + servers: [{ url: apiBaseUrl }], + transform: (source) => ({ + ...source, + components: { + ...(typeof source.components === "object" && source.components !== null + ? source.components + : {}), + securitySchemes: { + oauth2: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: `${url.origin}/authorize`, + tokenUrl: `${url.origin}/token`, + scopes: { read: "Read test resources" }, + }, + }, + }, + }, + }, + security: [{ oauth2: ["read"] }], + }), + }), + ), + ); + return json(spec); +}; + +const handleOpenApi = (request: Request) => { + const url = new URL(request.url); + if (url.pathname === "/openapi/spec.json") return openApiSpec(request); + if (!isAuthorized(request)) return unauthorized(request); + if (url.pathname === "/openapi/items") { + return json([ + { id: 1, name: "Widget" }, + { id: 2, name: "Gadget" }, + ]); + } + return json({ error: "not_found" }, { status: 404 }); +}; + +const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello(name: String): String + } + + type Mutation { + setGreeting(message: String!): String + } + `, + resolvers: { + Query: { + hello: (_source: unknown, args: { readonly name?: string }) => + `Hello ${args.name ?? "world"}`, + }, + Mutation: { + setGreeting: (_source: unknown, args: { readonly message: string }) => args.message, + }, + }, + }), + graphqlEndpoint: "/graphql", + graphiql: false, + logging: false, + maskedErrors: false, +}); + +const handleGraphql = (request: Request) => + isAuthorized(request) ? yoga.handle(request, {}) : unauthorized(request); + +const createMcpServer = () => { + const server = new McpServer( + { name: "executor-worker-mcp-test", version: "1.0.0" }, + { capabilities: {} }, + ); + server.registerTool( + "hello", + { description: "Greets a person", inputSchema: { name: z.string() } }, + async ({ name }: { readonly name: string }) => ({ + content: [{ type: "text" as const, text: `Hello ${name}` }], + }), + ); + return server; +}; + +const mcpTransports = new Map(); + +const handleMcp = async (request: Request) => { + if (!isAuthorized(request)) return unauthorized(request); + const sessionId = request.headers.get("mcp-session-id") ?? undefined; + const existing = sessionId ? mcpTransports.get(sessionId) : undefined; + if (sessionId && !existing) return text("Session not found", { status: 404 }); + if (existing) return existing.handleRequest(request); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (sid) => { + mcpTransports.set(sid, transport); + }, + }); + await createMcpServer().connect(transport); + return transport.handleRequest(request); +}; + +const handleRequest = async (request: Request): Promise => { + const url = new URL(request.url); + if (url.pathname === "/health") return json({ ok: true }); + if ( + url.pathname === "/.well-known/oauth-authorization-server" || + url.pathname === "/.well-known/openid-configuration" + ) { + return oauthMetadata(request); + } + if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) { + return protectedResourceMetadata(request); + } + if (url.pathname === "/register" && request.method === "POST") return handleRegister(request); + if (url.pathname === "/authorize" && request.method === "GET") return handleAuthorize(request); + if (url.pathname === "/login") return handleLogin(request); + if (url.pathname === "/token" && request.method === "POST") return handleToken(request); + if (url.pathname.startsWith("/openapi/")) return handleOpenApi(request); + if (url.pathname === "/graphql") return handleGraphql(request); + if (url.pathname === "/mcp") return handleMcp(request); + return json({ error: "not_found" }, { status: 404 }); +}; + +export default { + fetch: handleRequest, +}; diff --git a/packages/core/test-servers/tsconfig.json b/packages/core/test-servers/tsconfig.json new file mode 100644 index 000000000..b51e8c2c6 --- /dev/null +++ b/packages/core/test-servers/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/core/test-servers/vitest.config.ts b/packages/core/test-servers/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/core/test-servers/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/core/test-servers/wrangler.toml b/packages/core/test-servers/wrangler.toml new file mode 100644 index 000000000..835597710 --- /dev/null +++ b/packages/core/test-servers/wrangler.toml @@ -0,0 +1,7 @@ +name = "executor-test-servers" +main = "src/worker.ts" +compatibility_date = "2026-05-01" +workers_dev = true + +[observability] +enabled = true diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index e28d01bfe..df4d57fab 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "@effect/vitest"; import { Effect, Predicate } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; import { ConnectionId, @@ -15,14 +14,18 @@ import { TokenMaterial, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; -import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing"; +import { memorySecretsPlugin } from "@executor-js/sdk/testing"; import { graphqlPlugin } from "./plugin"; import { endpointForTelemetry } from "./invoke"; import { introspect } from "./introspect"; import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; import type { IntrospectionResult } from "./introspect"; -import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; +import { + makeGreetingGraphqlSchema, + serveGraphqlFailureTestServer, + serveGraphqlTestServer, +} from "../testing"; const TEST_SCOPE = "test-scope"; @@ -132,16 +135,12 @@ describe("graphqlPlugin real protocol server", () => { it.effect("does not include upstream response bodies in introspection status errors", () => Effect.gen(function* () { - const server = yield* serveTestHttpApp(() => - Effect.succeed( - HttpServerResponse.text("internal token value", { - status: 500, - contentType: "text/plain", - }), - ), - ); + const server = yield* serveGraphqlFailureTestServer({ + status: 500, + body: "internal token value", + }); - const error = yield* introspect(server.url("/graphql")).pipe( + const error = yield* introspect(server.endpoint).pipe( Effect.provide(server.httpClientLayer), Effect.flip, ); diff --git a/packages/plugins/graphql/src/testing.test.ts b/packages/plugins/graphql/src/testing.test.ts new file mode 100644 index 000000000..4782a6741 --- /dev/null +++ b/packages/plugins/graphql/src/testing.test.ts @@ -0,0 +1,54 @@ +import { expect, layer } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { GraphqlTestServer, makeGreetingGraphqlSchema } from "./testing"; + +const TestLayer = GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }).pipe( + Layer.provideMerge(OAuthTestServer.layer()), +); + +const GreetingResponse = Schema.Struct({ + data: Schema.Struct({ + hello: Schema.String, + }), +}); +const decodeGreetingResponse = Schema.decodeUnknownEffect(GreetingResponse); + +const graphqlRequest = (endpoint: string) => + HttpClientRequest.post(endpoint).pipe( + HttpClientRequest.bodyJsonUnsafe({ + query: "query Greeting($name: String) { hello(name: $name) }", + operationName: "Greeting", + variables: { name: "Ada" }, + }), + ); + +layer(TestLayer, { timeout: "15 seconds" })("GraphQL testing fixtures", (it) => { + it.effect("serves an OAuth-protected Yoga GraphQL server", () => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + const server = yield* GraphqlTestServer; + + const unauthorized = yield* HttpClient.execute(graphqlRequest(server.endpoint)).pipe( + Effect.provide(FetchHttpClient.layer), + ); + expect(unauthorized.status).toBe(401); + + const token = yield* oauth.completeAuthorizationCodeTokenFlow({ scopes: ["read"] }); + const authorized = yield* HttpClient.execute( + graphqlRequest(server.endpoint).pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${token.accessToken}`), + ), + ).pipe(Effect.provide(FetchHttpClient.layer)); + + expect(authorized.status).toBe(200); + const body = yield* authorized.json.pipe(Effect.flatMap(decodeGreetingResponse)); + expect(body).toEqual({ data: { hello: "Hello Ada" } }); + + const requests = yield* server.requests; + expect(requests.map((request) => request.payload.operationName)).toEqual(["Greeting"]); + }), + ); +}); diff --git a/packages/plugins/graphql/src/testing/index.ts b/packages/plugins/graphql/src/testing/index.ts index b074f80b9..8e972acb1 100644 --- a/packages/plugins/graphql/src/testing/index.ts +++ b/packages/plugins/graphql/src/testing/index.ts @@ -9,9 +9,14 @@ import { Scope, } from "effect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from "graphql"; -import { createYoga, type GraphQLParams, type YogaInitialContext } from "graphql-yoga"; -import { serveTestHttpApp } from "@executor-js/sdk/testing"; +import type { GraphQLSchema } from "graphql"; +import { + createSchema, + createYoga, + type GraphQLParams, + type YogaInitialContext, +} from "graphql-yoga"; +import { OAuthTestServer, serveTestHttpApp } from "@executor-js/sdk/testing"; const GraphqlRequestPayload = EffectSchema.Struct({ query: EffectSchema.optional(EffectSchema.String), @@ -36,6 +41,10 @@ export interface GraphqlTestContext { export interface GraphqlTestServerOptions { readonly schema: GraphQLSchema; readonly path?: string; + readonly auth?: { + readonly validateAuthorization: (authorization: string | null) => Effect.Effect; + readonly wwwAuthenticate?: string; + }; } export interface GraphqlTestServerShape { @@ -108,6 +117,23 @@ export const serveGraphqlTestServer = ( const server = yield* serveTestHttpApp((request) => Effect.gen(function* () { + if (options.auth) { + const accepted = yield* options.auth.validateAuthorization( + request.headers.authorization ?? null, + ); + if (!accepted) { + const responseOptions = options.auth.wwwAuthenticate + ? { + status: 401, + headers: { "www-authenticate": options.auth.wwwAuthenticate }, + } + : { status: 401 }; + return HttpServerResponse.jsonUnsafe( + { errors: [{ message: "Unauthorized" }] }, + responseOptions, + ); + } + } const webRequest = yield* HttpServerRequest.toWeb(request); const response = yield* Effect.promise(() => Promise.resolve(yoga.handle(webRequest, {}))); return HttpServerResponse.fromWeb(response); @@ -137,11 +163,46 @@ export const serveGraphqlTestServer = ( }; }); +export const serveGraphqlFailureTestServer = (options: { + readonly status: number; + readonly body: string; + readonly contentType?: string; + readonly path?: string; +}) => + serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.text(options.body, { + status: options.status, + contentType: options.contentType ?? "text/plain", + }), + ), + ).pipe( + Effect.map((server) => ({ + endpoint: server.url(options.path ?? "/graphql"), + httpClientLayer: server.httpClientLayer, + })), + ); + export class GraphqlTestServer extends Context.Service()( "@executor-js/plugin-graphql/testing/GraphqlTestServer", ) { static readonly layer = (options: GraphqlTestServerOptions) => Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options)); + + static readonly layerWithOAuth = (options: Omit) => + Layer.effect( + GraphqlTestServer, + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + return yield* serveGraphqlTestServer({ + ...options, + auth: { + validateAuthorization: oauth.acceptsAuthorizationHeader, + wwwAuthenticate: 'Bearer error="invalid_token"', + }, + }); + }), + ); } const stringArgument = ( @@ -153,38 +214,45 @@ const stringArgument = ( return typeof value === "string" ? value : fallback; }; -export const makeGreetingGraphqlSchema = (): GraphQLSchema => { - const Query = new GraphQLObjectType({ - name: "Query", - fields: { - hello: { - type: GraphQLString, - description: "Say hello", - args: { - name: { type: GraphQLString }, - }, - resolve: (_source, args) => `Hello ${stringArgument(args, "name", "world")}`, - }, - }, - }); +export const makeGreetingGraphqlSchema = ( + options: { readonly includeMutation?: boolean } = {}, +): GraphQLSchema => { + const includeMutation = options.includeMutation ?? true; + return createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello(name: String): String + } - const Mutation = new GraphQLObjectType({ - name: "Mutation", - fields: { - setGreeting: { - type: GraphQLString, - description: "Set greeting message", - args: { - message: { type: new GraphQLNonNull(GraphQLString) }, - }, - resolve: (_source, args) => stringArgument(args, "message", ""), + ${ + includeMutation + ? /* GraphQL */ ` + type Mutation { + setGreeting(message: String!): String + } + ` + : "" + } + `, + resolvers: { + Query: { + hello: (_source: unknown, args: Readonly>) => + `Hello ${stringArgument(args, "name", "world")}`, }, + ...(includeMutation + ? { + Mutation: { + setGreeting: (_source: unknown, args: Readonly>) => + stringArgument(args, "message", ""), + }, + } + : {}), }, }); - - return new GraphQLSchema({ query: Query, mutation: Mutation }); }; export const TestLayers = { greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }), + greetingWithOAuth: () => + GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }), }; diff --git a/packages/plugins/mcp/src/sdk/connection-pool.test.ts b/packages/plugins/mcp/src/sdk/connection-pool.test.ts index 9965dd55f..c6a526d5f 100644 --- a/packages/plugins/mcp/src/sdk/connection-pool.test.ts +++ b/packages/plugins/mcp/src/sdk/connection-pool.test.ts @@ -23,33 +23,24 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import * as http from "node:http"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import z from "zod"; import { createExecutor } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; // --------------------------------------------------------------------------- // Test MCP server — counts session connects (each = one cold handshake) // --------------------------------------------------------------------------- function createTestMcpServer() { - const server = new McpServer( - { name: "pool-test-server", version: "1.0.0" }, - { capabilities: {} }, - ); - - server.registerTool( - "echo", - { description: "Echo a value", inputSchema: { value: z.string() } }, - async ({ value }: { value: string }) => ({ - content: [{ type: "text" as const, text: value }], - }), - ); + const server = makeEchoMcpServer({ + name: "pool-test-server", + toolName: "echo", + toolDescription: "Echo a value", + }); server.registerTool( "echo2", @@ -62,63 +53,7 @@ function createTestMcpServer() { return server; } -type TestServer = { - readonly url: string; - readonly httpServer: http.Server; - /** Number of MCP sessions created (1 per cold transport handshake). */ - readonly sessionCount: () => number; -}; - -const serveMcpServer = Effect.acquireRelease( - Effect.callback((resume) => { - const transports = new Map(); - let sessions = 0; - - const httpServer = http.createServer(async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (sessionId) { - const transport = transports.get(sessionId); - if (!transport) { - res.writeHead(404); - res.end("Session not found"); - return; - } - await transport.handleRequest(req, res); - return; - } - - const mcpServer = createTestMcpServer(); - sessions++; - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - onsessioninitialized: (sid) => { - transports.set(sid, transport); - }, - }); - - await mcpServer.connect(transport); - await transport.handleRequest(req, res); - }); - - httpServer.listen(0, () => { - const addr = httpServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - resume( - Effect.succeed({ - url: `http://127.0.0.1:${port}`, - httpServer, - sessionCount: () => sessions, - }), - ); - }); - }), - ({ httpServer }) => - Effect.sync(() => { - httpServer.close(); - }), -); +const servePoolMcpServer = serveMcpServer(createTestMcpServer); // --------------------------------------------------------------------------- // Helper — one executor, one mcp source pointed at the test server @@ -150,7 +85,7 @@ describe("MCP connection pooling (regression)", () => { "five sequential invokes against the same source perform exactly one transport handshake", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* servePoolMcpServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const echo = tools.find((t) => t.name === "echo")!; @@ -185,7 +120,7 @@ describe("MCP connection pooling (regression)", () => { it.effect("different tools on the same source share the cached connection", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* servePoolMcpServer; const executor = yield* makeTestExecutor(server.url); const tools = yield* executor.tools.list(); const echo = tools.find((t) => t.name === "echo")!; @@ -212,7 +147,7 @@ describe("MCP connection pooling (regression)", () => { it.effect("different sources with the same endpoint use separate cached connections", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* servePoolMcpServer; const executor = yield* createExecutor( makeTestConfig({ plugins: [mcpPlugin()] as const, diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 5f987a472..f1d92029e 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import z from "zod"; import { createExecutor, @@ -12,66 +10,11 @@ import { import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; -import { serveMcpServer } from "../testing"; +import { makeElicitationMcpServer, serveMcpServer } from "../testing"; const isFormElicitation = Schema.is(FormElicitation); -// --------------------------------------------------------------------------- -// Test MCP server on a real HTTP port -// --------------------------------------------------------------------------- - -function createTestMcpServer() { - const server = new McpServer( - { name: "elicitation-test-server", version: "1.0.0" }, - { capabilities: {} }, - ); - - server.registerTool( - "gated_echo", - { - description: "Asks for approval before echoing a value", - inputSchema: { value: z.string() }, - }, - async ({ value }: { value: string }) => { - const response = await server.server.elicitInput({ - mode: "form", - message: `Approve echo for "${value}"?`, - requestedSchema: { - type: "object", - properties: { - approved: { type: "boolean", title: "Approve" }, - }, - required: ["approved"], - }, - }); - - if (response.action !== "accept" || !response.content || response.content.approved !== true) { - return { - content: [{ type: "text" as const, text: `denied:${value}` }], - }; - } - - return { - content: [{ type: "text" as const, text: `approved:${value}` }], - }; - }, - ); - - server.registerTool( - "simple_echo", - { - description: "Echoes a value without elicitation", - inputSchema: { value: z.string() }, - }, - async ({ value }: { value: string }) => ({ - content: [{ type: "text" as const, text: value }], - }), - ); - - return server; -} - -const serveElicitationTestServer = serveMcpServer(createTestMcpServer); +const serveElicitationTestServer = serveMcpServer(makeElicitationMcpServer); // --------------------------------------------------------------------------- // Helper — create executor with MCP plugin pointed at test server diff --git a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts index 8bf14f93d..af155b7c1 100644 --- a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts +++ b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts @@ -1,10 +1,5 @@ -import * as http from "node:http"; - import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Predicate } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import z from "zod"; import { ConnectionId, @@ -21,16 +16,7 @@ import { import { makeTestConfig, memorySecretsPlugin } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; - -type RecordedRequest = { - readonly authorization: string | undefined; -}; - -type TestServer = { - readonly url: string; - readonly httpServer: http.Server; - readonly recorded: () => readonly RecordedRequest[]; -}; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; const USER_A = ScopeId.make("user-a"); const USER_B = ScopeId.make("user-b"); @@ -44,67 +30,16 @@ const failureError = (exit: Exit.Exit): E | undefined => const isToolInvocationError = (error: unknown): error is ToolInvocationError => Predicate.isTagged(error, "ToolInvocationError"); -const createAuthRecordingServer: Effect.Effect = Effect.callback((resume) => { - const transports = new Map(); - const recorded: RecordedRequest[] = []; - - const httpServer = http.createServer(async (req, res) => { - recorded.push({ - authorization: req.headers["authorization"] as string | undefined, - }); - - const sessionId = req.headers["mcp-session-id"] as string | undefined; - if (sessionId) { - const transport = transports.get(sessionId); - if (!transport) { - res.writeHead(404); - res.end("Session not found"); - return; - } - await transport.handleRequest(req, res); - return; - } - - const mcpServer = new McpServer({ name: "iso-test", version: "1.0.0" }, { capabilities: {} }); - mcpServer.registerTool( - "whoami", - { - description: "Echoes a marker so the test can prove the invoke reached the server", - inputSchema: { marker: z.string() }, - }, - async ({ marker }: { marker: string }) => ({ - content: [{ type: "text" as const, text: `ok:${marker}` }], - }), - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - onsessioninitialized: (sid) => { - transports.set(sid, transport); - }, - }); - await mcpServer.connect(transport); - await transport.handleRequest(req, res); +const createAuthRecordingMcpServer = () => + makeEchoMcpServer({ + name: "iso-test", + toolName: "whoami", + toolDescription: "Echoes a marker so the test can prove the invoke reached the server", + inputName: "marker", + text: (marker) => `ok:${marker}`, }); - httpServer.listen(0, () => { - const addr = httpServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - resume( - Effect.succeed({ - url: `http://127.0.0.1:${port}`, - httpServer, - recorded: () => recorded, - }), - ); - }); -}); - -const serveMcpServer = Effect.acquireRelease(createAuthRecordingServer, ({ httpServer }) => - Effect.sync(() => { - httpServer.close(); - }), -); +const serveAuthRecordingMcpServer = serveMcpServer(createAuthRecordingMcpServer); const makeLayeredMcpExecutors = () => Effect.acquireRelease( @@ -137,7 +72,7 @@ describe("per-user MCP auth isolation", () => { "oauth2 source: unauthenticated user B cannot invoke and never sees user A's token", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* serveAuthRecordingMcpServer; const { execUserA, execUserB } = yield* makeLayeredMcpExecutors(); const sharedConnId = "mcp-oauth2-iso-test"; @@ -173,7 +108,7 @@ describe("per-user MCP auth isolation", () => { const whoamiForA = userATools.find((tool) => tool.name === "whoami"); expect(whoamiForA).toBeDefined(); - const recordedBeforeUserA = server.recorded().length; + const recordedBeforeUserA = (yield* server.requests).length; const userAResult = yield* execUserA.tools.invoke( whoamiForA!.id, { marker: "from-user-a" }, @@ -183,13 +118,12 @@ describe("per-user MCP auth isolation", () => { content: [{ type: "text", text: "ok:from-user-a" }], }); expect( - server - .recorded() + (yield* server.requests) .slice(recordedBeforeUserA) .some((request) => request.authorization === "Bearer token-user-a"), ).toBe(true); - const recordedBeforeUserB = server.recorded().length; + const recordedBeforeUserB = (yield* server.requests).length; const userBTools = yield* execUserB.tools.list(); const whoamiForB = userBTools.find((tool) => tool.name === "whoami"); expect(whoamiForB).toBeDefined(); @@ -208,7 +142,7 @@ describe("per-user MCP auth isolation", () => { const inner = isToolInvocationError(outer) ? outer.cause : undefined; expect(Predicate.isTagged(inner, "McpConnectionError")).toBe(true); - for (const request of server.recorded().slice(recordedBeforeUserB)) { + for (const request of (yield* server.requests).slice(recordedBeforeUserB)) { expect(request.authorization).not.toBe("Bearer token-user-a"); } }), @@ -216,7 +150,7 @@ describe("per-user MCP auth isolation", () => { it.effect("header source: unauthenticated user B cannot invoke via a per-user secret", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* serveAuthRecordingMcpServer; const { execUserA, execUserB } = yield* makeLayeredMcpExecutors(); const secret = SecretId.make("shared-mcp-token"); @@ -246,7 +180,7 @@ describe("per-user MCP auth isolation", () => { const userATools = yield* execUserA.tools.list(); const whoamiForA = userATools.find((tool) => tool.name === "whoami")!; - const recordedBeforeUserA = server.recorded().length; + const recordedBeforeUserA = (yield* server.requests).length; const userAResult = yield* execUserA.tools.invoke( whoamiForA.id, { marker: "user-a-header" }, @@ -256,13 +190,12 @@ describe("per-user MCP auth isolation", () => { content: [{ type: "text", text: "ok:user-a-header" }], }); expect( - server - .recorded() + (yield* server.requests) .slice(recordedBeforeUserA) .some((request) => request.authorization === "Bearer token-user-a-header"), ).toBe(true); - const recordedBeforeUserB = server.recorded().length; + const recordedBeforeUserB = (yield* server.requests).length; const userBTools = yield* execUserB.tools.list(); const whoamiForB = userBTools.find((tool) => tool.name === "whoami")!; const userBResult = yield* Effect.exit( @@ -279,7 +212,7 @@ describe("per-user MCP auth isolation", () => { const inner = isToolInvocationError(outer) ? outer.cause : undefined; expect(Predicate.isTagged(inner, "McpConnectionError")).toBe(true); - for (const request of server.recorded().slice(recordedBeforeUserB)) { + for (const request of (yield* server.requests).slice(recordedBeforeUserB)) { expect(request.authorization).not.toBe("Bearer token-user-a-header"); } }), @@ -287,7 +220,7 @@ describe("per-user MCP auth isolation", () => { it.effect("org header binding resolves the org secret when a user has the same secret id", () => Effect.gen(function* () { - const server = yield* serveMcpServer; + const server = yield* serveAuthRecordingMcpServer; const { execUserA } = yield* makeLayeredMcpExecutors(); const secretId = SecretId.make("shared-mcp-token"); @@ -326,7 +259,7 @@ describe("per-user MCP auth isolation", () => { const tools = yield* execUserA.tools.list(); const whoami = tools.find((tool) => tool.name === "whoami")!; - const beforeInvoke = server.recorded().length; + const beforeInvoke = (yield* server.requests).length; const result = yield* execUserA.tools.invoke( whoami.id, { marker: "org-header" }, @@ -336,7 +269,7 @@ describe("per-user MCP auth isolation", () => { expect(result).toMatchObject({ content: [{ type: "text", text: "ok:org-header" }], }); - const invokeRequests = server.recorded().slice(beforeInvoke); + const invokeRequests = (yield* server.requests).slice(beforeInvoke); expect( invokeRequests.some((request) => request.authorization === "Bearer token-org-header"), ).toBe(true); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 191492663..88ba85c50 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Result } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import z from "zod"; import { ConnectionId, @@ -20,7 +18,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin, userFacingProbeMessage } from "./plugin"; import { MCP_OAUTH_CONNECTION_SLOT } from "./types"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; -import { serveMcpServer } from "../testing"; +import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; // --------------------------------------------------------------------------- // Memory secrets plugin — without a writable provider in the stack, @@ -855,52 +853,7 @@ describe("mcpPlugin", () => { // destructiveHint → requiresApproval (end-to-end with a real local server) // --------------------------------------------------------------------------- -const createAnnotationsTestServer = () => { - const mcpServer = new McpServer( - { name: "annotations-test-server", version: "1.0.0" }, - { capabilities: {} }, - ); - - mcpServer.registerTool( - "delete", - { - description: "A destructive tool", - inputSchema: { id: z.string() }, - annotations: { destructiveHint: true }, - }, - async () => ({ content: [] }), - ); - - mcpServer.registerTool( - "delete_titled", - { - description: "A destructive tool with a title annotation", - inputSchema: { id: z.string() }, - annotations: { destructiveHint: true, title: "Delete dataset" }, - }, - async () => ({ content: [] }), - ); - - mcpServer.registerTool( - "list", - { - description: "A read-only tool", - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - async () => ({ content: [] }), - ); - - mcpServer.registerTool( - "ping", - { description: "An unannotated tool", inputSchema: {} }, - async () => ({ content: [] }), - ); - - return mcpServer; -}; - -const serveAnnotationsTestServer = serveMcpServer(createAnnotationsTestServer); +const serveAnnotationsTestServer = serveMcpServer(makeAnnotationsMcpServer); describe("MCP destructiveHint → requiresApproval", () => { it.effect("destructiveHint becomes requiresApproval, others stay false", () => diff --git a/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts new file mode 100644 index 000000000..b80433539 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts @@ -0,0 +1,58 @@ +import { expect, layer } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; + +import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; + +const createGreetingMcpServer = () => + makeEchoMcpServer({ + name: "executor-test-mcp", + toolName: "hello", + toolDescription: "Greets a person", + inputName: "name", + text: (name) => `Hello ${name}`, + }); + +const makeClient = (endpoint: string, accessToken: string) => { + const client = new Client({ name: "executor-test-client", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(endpoint), { + requestInit: { + headers: { authorization: `Bearer ${accessToken}` }, + }, + }); + return { client, transport }; +}; + +layer(OAuthTestServer.layer(), { timeout: "15 seconds" })("MCP testing fixtures", (it) => { + it.effect("serves an OAuth-protected MCP server through the MCP SDK transport", () => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + const server = yield* serveMcpServerWithOAuth(createGreetingMcpServer, { path: "/mcp" }); + const token = yield* oauth.completeAuthorizationCodeTokenFlow({ + resource: server.endpoint, + scopes: ["read"], + }); + const { client, transport } = makeClient(server.endpoint, token.accessToken); + + yield* Effect.tryPromise(() => client.connect(transport)); + const tools = yield* Effect.tryPromise(() => client.listTools()); + const result = yield* Effect.tryPromise(() => + client.callTool({ name: "hello", arguments: { name: "Ada" } }), + ); + yield* Effect.promise(() => client.close()); + + expect(tools.tools.map((tool) => tool.name)).toEqual(["hello"]); + expect(result).toMatchObject({ + content: [{ type: "text", text: "Hello Ada" }], + }); + expect(server.sessionCount()).toBe(1); + + const requests = yield* server.requests; + expect( + requests.some((request) => request.authorization === `Bearer ${token.accessToken}`), + ).toBe(true); + }), + ); +}); diff --git a/packages/plugins/mcp/src/testing/index.ts b/packages/plugins/mcp/src/testing/index.ts index b7e49a053..0d1edfb1f 100644 --- a/packages/plugins/mcp/src/testing/index.ts +++ b/packages/plugins/mcp/src/testing/index.ts @@ -1 +1,13 @@ -export { serveMcpServer, type McpTestServer } from "./server"; +export { + McpTestServerError, + McpTestServerLayer, + makeAnnotationsMcpServer, + makeEchoMcpServer, + makeElicitationMcpServer, + makeGreetingMcpServer, + serveMcpServer, + serveMcpServerWithOAuth, + type McpTestRequest, + type McpTestServer, + type McpTestServerOptions, +} from "./server"; diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 5e16b4758..f4f46b35e 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -1,63 +1,424 @@ -import { Effect } from "effect"; +import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; import * as http from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; +import z from "zod"; export type McpTestServer = { readonly url: string; - readonly httpServer: http.Server; + readonly endpoint: string; /** Number of MCP sessions created (each connect = 1 session) */ readonly sessionCount: () => number; + readonly requests: Effect.Effect; + readonly clearRequests: Effect.Effect; }; -export const serveMcpServer = (factory: () => McpServer) => +export type McpTestRequest = { + readonly method: string; + readonly url: string; + readonly authorization: string | undefined; + readonly sessionId: string | undefined; +}; + +export type McpTestServerOptions = { + readonly path?: string; + readonly auth?: { + readonly validateAuthorization: (authorization: string | undefined) => Effect.Effect; + readonly authorizationServerUrls?: readonly string[]; + readonly scopes?: readonly string[]; + readonly wwwAuthenticate?: string; + }; +}; + +export class McpTestServerError extends Data.TaggedError("McpTestServerError")<{ + readonly cause: unknown; +}> {} + +const writeJson = ( + response: http.ServerResponse, + status: number, + body: Readonly>, + headers: Readonly> = {}, +) => { + response.writeHead(status, { + "content-type": "application/json", + ...headers, + }); + response.end(JSON.stringify(body)); +}; + +const writeText = (response: http.ServerResponse, status: number, body: string) => { + response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }); + response.end(body); +}; + +const isMcpPath = (url: string, path: string): boolean => { + const parsed = new URL(url, "http://executor.test"); + return parsed.pathname === path; +}; + +const protectedResourcePath = "/.well-known/oauth-protected-resource"; + +export const serveMcpServer = (factory: () => McpServer, options: McpTestServerOptions = {}) => Effect.acquireRelease( - Effect.callback((resume) => { + Effect.gen(function* () { const transports = new Map(); + const requests = yield* Ref.make([]); + const path = options.path ?? "/"; let sessions = 0; - const httpServer = http.createServer(async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; + const handleMcpRequest = ( + request: http.IncomingMessage, + response: http.ServerResponse, + ): Effect.Effect => + Effect.gen(function* () { + const requestUrl = request.url ?? "/"; + const sessionId = Array.isArray(request.headers["mcp-session-id"]) + ? request.headers["mcp-session-id"][0] + : request.headers["mcp-session-id"]; + const authorization = Array.isArray(request.headers.authorization) + ? request.headers.authorization[0] + : request.headers.authorization; + const origin = request.headers.host + ? `http://${request.headers.host}` + : "http://127.0.0.1"; - if (sessionId) { - const transport = transports.get(sessionId); - if (!transport) { - res.writeHead(404); - res.end("Session not found"); + yield* Ref.update(requests, (all) => [ + ...all, + { + method: request.method ?? "GET", + url: requestUrl, + authorization, + sessionId, + }, + ]); + + if ( + options.auth?.authorizationServerUrls && + requestUrl.startsWith(protectedResourcePath) + ) { + const resourcePath = requestUrl.slice(protectedResourcePath.length); + writeJson(response, 200, { + resource: `${origin}${resourcePath}`, + authorization_servers: options.auth.authorizationServerUrls, + bearer_methods_supported: ["header"], + scopes_supported: options.auth.scopes ?? ["read"], + }); return; } - await transport.handleRequest(req, res); - return; - } - const mcpServer = factory(); - sessions++; + if (!isMcpPath(requestUrl, path)) { + writeJson(response, 404, { error: "not_found" }); + return; + } - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - onsessioninitialized: (sid) => { - transports.set(sid, transport); - }, - }); + if (options.auth) { + const accepted = yield* options.auth.validateAuthorization(authorization); + if (!accepted) { + writeJson( + response, + 401, + { error: "invalid_token" }, + { + "www-authenticate": + options.auth.wwwAuthenticate ?? + `Bearer resource_metadata="${origin}${protectedResourcePath}${path}", error="invalid_token"`, + }, + ); + return; + } + } - await mcpServer.connect(transport); - await transport.handleRequest(req, res); - }); + const existingTransport = sessionId ? transports.get(sessionId) : undefined; + if (sessionId && !existingTransport) { + writeText(response, 404, "Session not found"); + return; + } + + if (existingTransport) { + yield* Effect.tryPromise({ + try: () => existingTransport.handleRequest(request, response), + catch: (cause) => new McpTestServerError({ cause }), + }); + return; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (sid) => { + transports.set(sid, transport); + }, + }); + sessions += 1; - httpServer.listen(0, () => { - const addr = httpServer.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - resume( - Effect.succeed({ - url: `http://127.0.0.1:${port}`, - httpServer, - sessionCount: () => sessions, - }), + const mcpServer = factory(); + yield* Effect.tryPromise({ + try: () => mcpServer.connect(transport), + catch: (cause) => new McpTestServerError({ cause }), + }); + yield* Effect.tryPromise({ + try: () => transport.handleRequest(request, response), + catch: (cause) => new McpTestServerError({ cause }), + }); + }).pipe( + Effect.catch(() => + Effect.sync(() => { + if (!response.headersSent) { + writeJson(response, 500, { error: "mcp_test_server_failed" }); + } else if (!response.writableEnded) { + response.end(); + } + }), + ), ); + + const nodeServer = http.createServer((request, response) => { + void Effect.runPromise(handleMcpRequest(request, response)); }); + + const port = yield* Effect.callback((resume) => { + const onError = (cause: Error) => { + nodeServer.off("error", onError); + resume(Effect.fail(new McpTestServerError({ cause }))); + }; + nodeServer.once("error", onError); + nodeServer.listen(0, () => { + nodeServer.off("error", onError); + const address = nodeServer.address(); + if (typeof address === "object" && address) { + resume(Effect.succeed(address.port)); + return; + } + resume(Effect.fail(new McpTestServerError({ cause: address }))); + }); + }); + + const baseUrl = `http://127.0.0.1:${port}`; + const endpoint = path === "/" ? baseUrl : new URL(path, baseUrl).toString(); + return { + url: endpoint, + endpoint, + sessionCount: () => sessions, + requests: Ref.get(requests), + clearRequests: Ref.set(requests, []), + close: Effect.gen(function* () { + for (const transport of transports.values()) { + yield* Effect.tryPromise({ + try: () => transport.close(), + catch: (cause) => new McpTestServerError({ cause }), + }).pipe(Effect.ignore); + } + yield* Effect.sync(() => { + nodeServer.close(); + nodeServer.closeAllConnections?.(); + }); + }), + }; }), - ({ httpServer }) => - Effect.sync(() => { - httpServer.close(); - }), + (server) => server.close, + ).pipe(Effect.map(({ close: _close, ...server }) => server)); + +export const serveMcpServerWithOAuth = ( + factory: () => McpServer, + options: Omit & { + readonly scopes?: readonly string[]; + readonly wwwAuthenticate?: string; + } = {}, +) => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + return yield* serveMcpServer(factory, { + path: options.path, + auth: { + validateAuthorization: oauth.acceptsAuthorizationHeader, + authorizationServerUrls: [oauth.issuerUrl], + scopes: options.scopes ?? ["read"], + wwwAuthenticate: options.wwwAuthenticate, + }, + }); + }); + +export class McpTestServerLayer extends Context.Service()( + "@executor-js/plugin-mcp/testing/McpTestServer", +) { + static readonly layer = ( + factory: () => McpServer, + options?: McpTestServerOptions, + ): Layer.Layer => + Layer.effect(McpTestServerLayer, serveMcpServer(factory, options)); + + static readonly layerWithOAuth = ( + factory: () => McpServer, + options?: Omit & { + readonly scopes?: readonly string[]; + readonly wwwAuthenticate?: string; + }, + ): Layer.Layer => + Layer.effect(McpTestServerLayer, serveMcpServerWithOAuth(factory, options)); +} + +export const makeGreetingMcpServer = ( + options: { + readonly name?: string; + readonly version?: string; + readonly toolName?: string; + readonly toolDescription?: string; + readonly text?: string; + } = {}, +) => { + const server = new McpServer( + { + name: options.name ?? "executor-test-mcp", + version: options.version ?? "1.0.0", + }, + { capabilities: {} }, ); + + server.registerTool( + options.toolName ?? "simple_echo", + { + description: options.toolDescription ?? "Echoes from the executor MCP test server", + inputSchema: {}, + }, + async () => ({ + content: [{ type: "text" as const, text: options.text ?? "mcp-ok" }], + }), + ); + + return server; +}; + +export const makeEchoMcpServer = ( + options: { + readonly name?: string; + readonly version?: string; + readonly toolName?: string; + readonly toolDescription?: string; + readonly inputName?: "name" | "value" | "marker"; + readonly text?: (value: string) => string; + } = {}, +) => { + const inputName = options.inputName ?? "value"; + const server = new McpServer( + { + name: options.name ?? "executor-echo-mcp", + version: options.version ?? "1.0.0", + }, + { capabilities: {} }, + ); + + server.registerTool( + options.toolName ?? "echo", + { + description: options.toolDescription ?? "Echoes a string value", + inputSchema: { [inputName]: z.string() }, + }, + async (input) => ({ + content: [ + { + type: "text" as const, + text: options.text ? options.text(input[inputName]) : input[inputName], + }, + ], + }), + ); + + return server; +}; + +export const makeElicitationMcpServer = () => { + const server = new McpServer( + { name: "elicitation-test-server", version: "1.0.0" }, + { capabilities: {} }, + ); + + server.registerTool( + "gated_echo", + { + description: "Asks for approval before echoing a value", + inputSchema: { value: z.string() }, + }, + async ({ value }: { value: string }) => { + const response = await server.server.elicitInput({ + mode: "form", + message: `Approve echo for "${value}"?`, + requestedSchema: { + type: "object", + properties: { + approved: { type: "boolean", title: "Approve" }, + }, + required: ["approved"], + }, + }); + + if (response.action !== "accept" || !response.content || response.content.approved !== true) { + return { + content: [{ type: "text" as const, text: `denied:${value}` }], + }; + } + + return { + content: [{ type: "text" as const, text: `approved:${value}` }], + }; + }, + ); + + server.registerTool( + "simple_echo", + { + description: "Echoes a value without elicitation", + inputSchema: { value: z.string() }, + }, + async ({ value }: { value: string }) => ({ + content: [{ type: "text" as const, text: value }], + }), + ); + + return server; +}; + +export const makeAnnotationsMcpServer = () => { + const server = new McpServer( + { name: "annotations-test-server", version: "1.0.0" }, + { capabilities: {} }, + ); + + server.registerTool( + "delete", + { + description: "A destructive tool", + inputSchema: { id: z.string() }, + annotations: { destructiveHint: true }, + }, + async () => ({ content: [] }), + ); + + server.registerTool( + "delete_titled", + { + description: "A destructive tool with a title annotation", + inputSchema: { id: z.string() }, + annotations: { destructiveHint: true, title: "Delete dataset" }, + }, + async () => ({ content: [] }), + ); + + server.registerTool( + "list", + { + description: "A read-only tool", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => ({ content: [] }), + ); + + server.registerTool( + "ping", + { description: "An unannotated tool", inputSchema: {} }, + async () => ({ content: [] }), + ); + + return server; +}; diff --git a/packages/plugins/onepassword/src/sdk/plugin.test.ts b/packages/plugins/onepassword/src/sdk/plugin.test.ts index 42c2328f4..eb3cba6a0 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.test.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.test.ts @@ -2,7 +2,7 @@ import { expect, layer } from "@effect/vitest"; import { Effect } from "effect"; import { ScopeId, createExecutor } from "@executor-js/sdk"; -import { makeTestExecutorLayer, TestExecutor } from "@executor-js/sdk/testing"; +import { makeTestWorkspaceLayer, TestWorkspace } from "@executor-js/sdk/testing"; import { onepasswordPlugin } from "./plugin"; import { OnePasswordConfig, DesktopAppAuth } from "./types"; @@ -10,14 +10,14 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; const plugins = [onepasswordPlugin()] as const; layer( - makeTestExecutorLayer({ + makeTestWorkspaceLayer({ plugins, }), { timeout: "15 seconds" }, )("onepassword plugin", (it) => { it.effect("registers onepassword as a secret provider", () => Effect.gen(function* () { - const { config: harnessConfig } = yield* TestExecutor; + const { config: harnessConfig } = yield* TestWorkspace; const executor = yield* createExecutor({ ...harnessConfig, plugins }); const providers = yield* executor.secrets.providers(); expect(providers).toContain("onepassword"); @@ -26,7 +26,7 @@ layer( it.effect("configure / getConfig / removeConfig round-trip via blob store", () => Effect.gen(function* () { - const { config: harnessConfig } = yield* TestExecutor; + const { config: harnessConfig } = yield* TestWorkspace; const executor = yield* createExecutor({ ...harnessConfig, plugins }); const initial = yield* executor.onepassword.getConfig(); @@ -56,7 +56,7 @@ layer( it.effect("status reports not-configured before configure", () => Effect.gen(function* () { - const { config: harnessConfig } = yield* TestExecutor; + const { config: harnessConfig } = yield* TestWorkspace; const executor = yield* createExecutor({ ...harnessConfig, plugins }); const status = yield* executor.onepassword.status(); expect(status.connected).toBe(false); diff --git a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts index d54044f0c..82aaeb424 100644 --- a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts @@ -6,23 +6,10 @@ // then resolves the bearer at invoke time. // --------------------------------------------------------------------------- -import { expect, layer } from "@effect/vitest"; -import { Effect, Layer, Ref, Schema } from "effect"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { - FetchHttpClient, - HttpRouter, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; import { ConnectionId, @@ -35,8 +22,11 @@ import { type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { serveTestHttpApp } from "@executor-js/sdk/testing"; +import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; @@ -64,7 +54,6 @@ const ItemsGroup = HttpApiGroup.make("items").add( ); const TestApi = HttpApi.make("testApi").add(ItemsGroup); -const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => handlers.handle("echoHeaders", () => @@ -77,65 +66,18 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => ), ); -const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); - -const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), -); - -type TokenCall = { - readonly grantType: string | null; - readonly clientId: string | null; - readonly clientSecret: string | null; - readonly scope: string | null; -}; - -const serveClientCredentialsTokenEndpoint = (args: { - readonly accessTokens: readonly string[]; - readonly expiresIn?: number; -}) => - Effect.gen(function* () { - const calls = yield* Ref.make([]); - let callIndex = 0; - const server = yield* serveTestHttpApp((request) => - Effect.gen(function* () { - const params = new URLSearchParams(yield* request.text); - yield* Ref.update(calls, (all) => [ - ...all, - { - grantType: params.get("grant_type"), - clientId: params.get("client_id"), - clientSecret: params.get("client_secret"), - scope: params.get("scope"), - }, - ]); - const token = - args.accessTokens[Math.min(callIndex, args.accessTokens.length - 1)] ?? "unknown"; - callIndex += 1; - const body: Record = { - access_token: token, - token_type: "Bearer", - }; - if (typeof args.expiresIn === "number") body.expires_in = args.expiresIn; - return HttpServerResponse.jsonUnsafe(body); - }).pipe( - Effect.catch(() => - Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })), - ), - ), - ); - - return { - tokenUrl: server.url("/token"), - calls: Ref.get(calls), - } as const; - }); +const tokenEndpointRequests = ( + requests: readonly { readonly path: string; readonly body: string }[], +) => + requests + .filter((request) => request.path === "/token") + .map((request) => new URLSearchParams(request.body)); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { +describe("OpenAPI client_credentials OAuth", () => { it.effect("startOAuth exchanges tokens inline and makes them usable at invoke time", () => Effect.gen(function* () { const secretStore = new Map(); @@ -156,14 +98,10 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { secretProviders: [memoryProvider], })); const clientLayer = FetchHttpClient.layer; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (!("port" in address)) { - return yield* new OpenApiClientCredentialsTestSetupError({ - message: "Test server must bind to TCP", - }); - } - const baseUrl = `http://127.0.0.1:${address.port}`; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + }); const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -213,8 +151,9 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { }), ); - const tokenEndpoint = yield* serveClientCredentialsTokenEndpoint({ - accessTokens: ["alice-token-1"], + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "client-abc", + defaultClientSecret: "secret-xyz", }); // ------------------------------------------------------------ @@ -224,15 +163,15 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { // ------------------------------------------------------------ const connectionId = "openapi-oauth2-app-petstore"; const started = yield* userExec.oauth.start({ - endpoint: tokenEndpoint.tokenUrl, - redirectUrl: tokenEndpoint.tokenUrl, + endpoint: oauth.tokenEndpoint, + redirectUrl: oauth.tokenEndpoint, connectionId, tokenScope: String(userScope.id), pluginId: "openapi", identityLabel: "Petstore OAuth", strategy: { kind: "client-credentials", - tokenEndpoint: tokenEndpoint.tokenUrl, + tokenEndpoint: oauth.tokenEndpoint, clientIdSecretId: "petstore_client_id", clientSecretSecretId: "petstore_client_secret", scopes: ["data"], @@ -249,7 +188,7 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { kind: "oauth2", securitySchemeName: "oauth2", flow: "clientCredentials", - tokenUrl: tokenEndpoint.tokenUrl, + tokenUrl: oauth.tokenEndpoint, authorizationUrl: null, clientIdSlot: "oauth2:oauth2:client-id", clientSecretSlot: "oauth2:oauth2:client-secret", @@ -259,20 +198,18 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { expect(completedConnection.connectionId).toBe(connectionId); // Token endpoint call is RFC 6749 §4.4 compliant. - const calls = yield* tokenEndpoint.calls; + const calls = tokenEndpointRequests(yield* oauth.requests); expect(calls).toHaveLength(1); - expect(calls[0]!.grantType).toBe("client_credentials"); - expect(calls[0]!.clientId).toBe("client-abc"); - expect(calls[0]!.clientSecret).toBe("secret-xyz"); - expect(calls[0]!.scope).toBe("data"); + expect(calls[0]!.get("grant_type")).toBe("client_credentials"); + expect(calls[0]!.get("client_id")).toBe("client-abc"); + expect(calls[0]!.get("client_secret")).toBe("secret-xyz"); + expect(calls[0]!.get("scope")).toBe("data"); // Add the source with source-owned OAuth structure, then bind the // per-user connection into the configured slot. - yield* userExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(userExec, openApiServer, { scope: userScope.id, namespace: "petstore", - baseUrl, oauth2, }); yield* userExec.openapi.setSourceBinding( @@ -298,7 +235,9 @@ layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => { error: unknown; }; expect(result.error).toBeNull(); - expect(result.data?.authorization).toBe("Bearer alice-token-1"); + const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(bearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); // The connection lives at the innermost (user) scope, which // preserves per-user credential resolution: if each user has diff --git a/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts b/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts index 7eb848f35..a8aad6055 100644 --- a/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts +++ b/packages/plugins/openapi/src/sdk/form-urlencoded-body.test.ts @@ -1,22 +1,22 @@ // --------------------------------------------------------------------------- // Regression test for non-JSON request-body serialization. // -// Before the fix, the invoke path only had two branches — JSON, or +// Before the fix, the invoke path only had two branches: JSON, or // `String(bodyValue)` with whatever content-type the spec declared. For an -// object body that meant shipping the literal string `[object Object]` -// with `Content-Type: application/x-www-form-urlencoded`, which servers -// reject or hold open waiting for valid framing. -// -// Now we dispatch on content-type: form-urlencoded → bodyUrlParams, -// multipart → bodyFormDataRecord, string passthrough for pre-serialized -// bodies, JSON.stringify as a last-resort fallback (never `[object Object]`). +// object body that meant shipping the literal string `[object Object]` with +// `Content-Type: application/x-www-form-urlencoded`. // --------------------------------------------------------------------------- import { expect, layer } from "@effect/vitest"; -import { Effect } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, +} from "effect/unstable/httpapi"; import { createExecutor, @@ -24,7 +24,11 @@ import { type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; -import { makeTestExecutorLayer, TestExecutor } from "@executor-js/sdk/testing"; +import { makeTestWorkspaceLayer, TestWorkspace } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; @@ -57,73 +61,40 @@ type Captured = { body: string; }; -const startEchoServer = () => - Effect.acquireRelease( - Effect.callback<{ baseUrl: string; captured: Captured; close: () => void }>((resume) => { - const captured: Captured = { contentType: "", body: "" }; - const server = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on("data", (c: Buffer) => chunks.push(c)); - req.on("end", () => { - captured.contentType = req.headers["content-type"] ?? ""; - captured.body = Buffer.concat(chunks).toString("utf8"); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - }); - }); - server.listen(0, "127.0.0.1", () => { - const port = (server.address() as AddressInfo).port; - resume( - Effect.succeed({ - baseUrl: `http://127.0.0.1:${port}`, - captured, - close: () => server.close(), - }), - ); - }); - }), - (s) => Effect.sync(() => s.close()), - ); +const FormPayload = Schema.Struct({ + name: Schema.String, + email: Schema.String, +}).pipe(HttpApiSchema.asFormUrlEncoded()); +const Ok = Schema.Struct({ ok: Schema.Boolean }); -const formSpec = JSON.stringify({ - openapi: "3.0.0", - info: { title: "FormTest", version: "1.0.0" }, - paths: { - "/submit": { - post: { - operationId: "submit", - tags: ["forms"], - requestBody: { - required: true, - content: { - "application/x-www-form-urlencoded": { - schema: { - type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "ok", - content: { - "application/json": { - schema: { - type: "object", - properties: { ok: { type: "boolean" } }, - }, - }, - }, - }, - }, - }, - }, - }, -}); +const FormsGroup = HttpApiGroup.make("forms").add( + HttpApiEndpoint.post("submit", "/submit", { + payload: FormPayload, + success: Ok, + }), +); + +const FormApi = HttpApi.make("formTest").add(FormsGroup); + +const startEchoServer = () => + Effect.gen(function* () { + const captured: Captured = { contentType: "", body: "" }; + const FormsLive = HttpApiBuilder.group(FormApi, "forms", (handlers) => + handlers.handleRaw("submit", () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + captured.contentType = request.headers["content-type"] ?? ""; + captured.body = yield* request.text.pipe(Effect.catch(() => Effect.succeed(""))); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }), + ), + ); + const server = yield* serveOpenApiHttpApiTestServer({ + api: FormApi, + handlersLayer: FormsLive, + }); + return { server, captured }; + }); const plugins = [ openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), @@ -131,22 +102,20 @@ const plugins = [ ] as const; layer( - makeTestExecutorLayer({ + makeTestWorkspaceLayer({ plugins, }), { timeout: "15 seconds" }, )("OpenAPI non-JSON request body serialization", (it) => { it.effect("form-urlencoded object body is properly encoded (no '[object Object]')", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); - const { config } = yield* TestExecutor; + const { server, captured } = yield* startEchoServer(); + const { config } = yield* TestWorkspace; const executor = yield* createExecutor({ ...config, plugins }); - yield* executor.openapi.addSpec({ - spec: formSpec, + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "form", - baseUrl, }); yield* executor.tools.invoke( diff --git a/packages/plugins/openapi/src/sdk/index.test.ts b/packages/plugins/openapi/src/sdk/index.test.ts index 6d0ac72dd..7fabcd37f 100644 --- a/packages/plugins/openapi/src/sdk/index.test.ts +++ b/packages/plugins/openapi/src/sdk/index.test.ts @@ -41,6 +41,36 @@ const PetstoreApi = HttpApi.make("petstore").add(PetstoreGroup); // Generate OpenAPI spec from the Effect API definition const spec = OpenApi.fromApi(PetstoreApi); +type TestOpenApiServer = { + readonly url: string; + readonly description?: string; + readonly variables?: Record< + string, + { + readonly default: string; + readonly enum?: [string, ...string[]]; + readonly description?: string; + } + >; +}; + +const pingSpecWithServers = (title: string, servers: readonly TestOpenApiServer[]) => + OpenApi.fromApi( + HttpApi.make("serverVariablesTest") + .add( + HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title, + version: "1.0.0", + servers, + }), + ), + ); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -165,26 +195,19 @@ describe("OpenAPI plugin", () => { it.effect("extracts server variables with enum and description", () => Effect.gen(function* () { - const specWithServerVars = { - openapi: "3.0.0", - info: { title: "Sentry", version: "1.0.0" }, - servers: [ - { - url: "https://{region}.sentry.io", - description: "Regional endpoint", - variables: { - region: { - default: "us", - description: "The data-storage-location for an organization", - enum: ["us", "de"], - }, + const specWithServerVars = pingSpecWithServers("Sentry", [ + { + url: "https://{region}.sentry.io", + description: "Regional endpoint", + variables: { + region: { + default: "us", + description: "The data-storage-location for an organization", + enum: ["us", "de"], }, }, - ], - paths: { - "/ping": { get: { responses: { "200": { description: "ok" } } } }, }, - }; + ]); // @effect-diagnostics-next-line preferSchemaOverJson:off const doc = yield* parse(JSON.stringify(specWithServerVars)); const result = yield* extract(doc); @@ -212,27 +235,20 @@ describe("OpenAPI plugin", () => { // --------------------------------------------------------------------------- describe("extract — server variables", () => { - const specWithServerVars = { - openapi: "3.0.0", - info: { title: "Test", version: "1.0.0" }, - servers: [ - { - url: "https://{region}.example.com/{basePath}", - description: "Regional endpoint", - variables: { - region: { - default: "us", - enum: ["us", "eu", "ap"], - description: "Data region", - }, - basePath: { default: "v1" }, + const specWithServerVars = pingSpecWithServers("Test", [ + { + url: "https://{region}.example.com/{basePath}", + description: "Regional endpoint", + variables: { + region: { + default: "us", + enum: ["us", "eu", "ap"], + description: "Data region", }, + basePath: { default: "v1" }, }, - ], - paths: { - "/ping": { get: { responses: { "200": { description: "ok" } } } }, }, - }; + ]); it.effect("preserves enum, default, and description for server variables", () => Effect.gen(function* () { diff --git a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts index 5ce063dcd..9ad28e255 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts @@ -17,17 +17,10 @@ // source rows per tenant. // --------------------------------------------------------------------------- -import { expect, layer } from "@effect/vitest"; -import { Effect, Layer, Schema } from "effect"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { HttpClient, HttpRouter, HttpServerRequest } from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; import { createExecutor, @@ -41,6 +34,11 @@ import { type SecretProvider, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + makeOpenApiTestSourceConfig, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; import { ConfiguredHeaderBinding, OpenApiSourceBindingInput } from "./types"; @@ -63,7 +61,6 @@ const ProjectsGroup = HttpApiGroup.make("projects").add( ); const VercelApi = HttpApi.make("vercelApi").add(ProjectsGroup); -const specJson = JSON.stringify(OpenApi.fromApi(VercelApi)); const ProjectsGroupLive = HttpApiBuilder.group(VercelApi, "projects", (handlers) => handlers.handle("list", () => @@ -78,18 +75,11 @@ const ProjectsGroupLive = HttpApiBuilder.group(VercelApi, "projects", (handlers) ), ); -const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive)); - -const TestLayer = HttpRouter.serve(ApiLive, { - disableListenLog: true, - disableLogger: true, -}).pipe(Layer.provideMerge(NodeHttpServer.layerTest)); - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { +describe("OpenAPI multi-scope bearer (Vercel-style)", () => { it.effect("admin-added source; each user's per-scope token wins on invocation", () => Effect.gen(function* () { // Scope-partitioning in-memory provider. The composite key is @@ -114,9 +104,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const baseUrl = ""; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -168,11 +160,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { // stored source declares a credential slot, not a concrete // credential. Each user will bind their own secret to that slot. // ------------------------------------------------------------- - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl, headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -359,9 +349,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const baseUrl = ""; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -404,11 +396,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl, headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -499,9 +489,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const baseUrl = ""; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -533,11 +525,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl, headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -634,11 +624,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { ); yield* adminExec.openapi.removeSpec("vercel", String(orgScope.id)); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl, headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -674,8 +662,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -706,17 +697,18 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, - scope: String(orgScope.id), - namespace: "vercel", - baseUrl: "https://api.vercel.example", - }); + yield* adminExec.openapi.addSpec( + makeOpenApiTestSourceConfig(openApiServer, { + scope: String(orgScope.id), + namespace: "vercel", + baseUrl: "https://api.vercel.example", + }), + ); - yield* aliceExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(aliceExec, openApiServer, { scope: String(aliceScope.id), namespace: "vercel", + baseUrl: null, }); const source = yield* aliceExec.openapi.getSource("vercel", String(aliceScope.id)); @@ -747,9 +739,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const baseUrl = ""; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -780,11 +774,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl, headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -793,10 +785,10 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { }), }, }); - yield* aliceExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(aliceExec, openApiServer, { scope: String(aliceScope.id), namespace: "vercel", + baseUrl: null, }); yield* aliceExec.secrets.set( @@ -849,8 +841,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -881,11 +876,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl: "", headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", @@ -951,8 +944,11 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { secretProviders: [memoryProvider], })); - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: VercelApi, + handlersLayer: ProjectsGroupLive, + }); + const clientLayer = FetchHttpClient.layer; const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -983,11 +979,9 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { onElicitation: "accept-all", }); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "vercel", - baseUrl: "", headers: { Authorization: ConfiguredHeaderBinding.make({ kind: "binding", diff --git a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts index 736d8065d..6badab146 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts @@ -8,23 +8,10 @@ // user-facing `secrets.list()` automatically. // --------------------------------------------------------------------------- -import { expect, layer } from "@effect/vitest"; -import { Data, Effect, Layer, Predicate, Ref, Schema } from "effect"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { - FetchHttpClient, - HttpRouter, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Predicate, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; import { ConnectionId, @@ -37,8 +24,11 @@ import { type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { serveTestHttpApp } from "@executor-js/sdk/testing"; +import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; @@ -82,7 +72,6 @@ const ItemsGroup = HttpApiGroup.make("items").add( ); const TestApi = HttpApi.make("testApi").add(ItemsGroup); -const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => handlers.handle("echoHeaders", () => @@ -95,45 +84,18 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => ), ); -const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); - -const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), -); - -const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => - HttpServerResponse.jsonUnsafe(body, { status }); - -const serveTokenEndpoint = ( - handle: (params: URLSearchParams) => HttpServerResponse.HttpServerResponse, +const tokenEndpointRequests = ( + requests: readonly { readonly path: string; readonly body: string }[], ) => - Effect.gen(function* () { - const clientIds = yield* Ref.make([]); - const server = yield* serveTestHttpApp((request) => - Effect.gen(function* () { - const params = new URLSearchParams(yield* request.text); - const clientId = params.get("client_id"); - if (clientId) { - yield* Ref.update(clientIds, (all) => [...all, clientId]); - } - return handle(params); - }).pipe( - Effect.catch(() => - Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })), - ), - ), - ); - return { - tokenUrl: server.url("/token"), - clientIds: Ref.get(clientIds), - } as const; - }); + requests + .filter((request) => request.path === "/token") + .map((request) => new URLSearchParams(request.body)); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { +describe("OpenAPI multi-scope OAuth", () => { it.effect("per-user Connections coexist with a shared org-level client credential", () => Effect.gen(function* () { const secretStore = new Map(); @@ -154,12 +116,10 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { secretProviders: [memoryProvider], })); const clientLayer = FetchHttpClient.layer; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (!Predicate.isTagged(address, "TcpAddress")) { - return yield* new TestInvariantError({ message: "test server must bind to TCP" }); - } - const baseUrl = `http://127.0.0.1:${address.port}`; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + }); const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -226,29 +186,17 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { // 2. Each user runs startOAuth + centralized OAuth completion to mint a // per-user Connection. // ------------------------------------------------------------- - const tokenEndpoint = yield* serveTokenEndpoint((params) => { - const code = params.get("code") ?? ""; - const tokenByCode: Record = { - "code-alice": "alice-token", - "code-bob": "bob-token", - }; - const token = tokenByCode[code]; - if (!token) { - return json(400, { error: "invalid_grant", code }); - } - return json(200, { - access_token: token, - token_type: "Bearer", - refresh_token: `${token}-refresh`, - }); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "client-abc", + defaultClientSecret: "secret-xyz", }); const startInputFor = (user: string, scope: ScopeId) => ({ sourceId: "petstore", displayName: `Petstore (${user})`, securitySchemeName: "oauth2", - authorizationUrl: "https://auth.example.com/authorize", - tokenUrl: tokenEndpoint.tokenUrl, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, redirectUrl: "https://app.example.com/oauth/callback", clientIdSecretId: "petstore_client_id", clientSecretSecretId: "petstore_client_secret", @@ -294,13 +242,19 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { }); } + const aliceCallback = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: aliceStart.authorizationUrl, + }); + const bobCallback = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: bobStart.authorizationUrl, + }); const aliceAuth = yield* aliceExec.oauth.complete({ state: aliceStart.sessionId, - code: "code-alice", + code: aliceCallback.code, }); const bobAuth = yield* bobExec.oauth.complete({ state: bobStart.sessionId, - code: "code-bob", + code: bobCallback.code, }); // With the stable-id fix both users derive the same row id @@ -311,8 +265,8 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { expect(aliceAuth.connectionId).toBe(bobAuth.connectionId); const oauth2 = makeOauth2SourceConfig({ flow: "authorizationCode", - tokenUrl: tokenEndpoint.tokenUrl, - authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: oauth.tokenEndpoint, + authorizationUrl: oauth.authorizationEndpoint, scopes: ["read"], }); @@ -320,11 +274,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { // 3. Each user adds the spec with source-owned OAuth structure, // then binds their own connection into the configured slot. // ------------------------------------------------------------- - yield* aliceExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(aliceExec, openApiServer, { scope: String(aliceScope.id), namespace: "petstore", - baseUrl, oauth2, }); yield* aliceExec.openapi.setSourceBinding( @@ -336,11 +288,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, }), ); - yield* bobExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(bobExec, openApiServer, { scope: String(bobScope.id), namespace: "petstore", - baseUrl, oauth2, }); yield* bobExec.openapi.setSourceBinding( @@ -363,7 +313,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(aliceResult.error).toBeNull(); - expect(aliceResult.data?.authorization).toBe("Bearer alice-token"); + const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(aliceBearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); const bobResult = (yield* bobExec.tools.invoke( "petstore.items.echoHeaders", @@ -371,7 +323,10 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(bobResult.error).toBeNull(); - expect(bobResult.data?.authorization).toBe("Bearer bob-token"); + const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(bobBearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); + expect(bobBearer).not.toBe(aliceBearer); // ------------------------------------------------------------- // 5. Each user's Connection is scoped to them; admin sees none. @@ -436,12 +391,10 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { secretProviders: [memoryProvider], })); const clientLayer = FetchHttpClient.layer; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (!Predicate.isTagged(address, "TcpAddress")) { - return yield* new TestInvariantError({ message: "test server must bind to TCP" }); - } - const baseUrl = `http://127.0.0.1:${address.port}`; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + }); const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -522,23 +475,19 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { }), ); - // client_credentials token endpoint stub. Issues a token that - // encodes which client_id was used, so we can assert each - // user's row ends up with a token minted from *their own* - // credential resolution. - const tokenEndpoint = yield* serveTokenEndpoint((params) => { - const clientId = params.get("client_id") ?? "unknown"; - return json(200, { - access_token: `token-for-${clientId}`, - token_type: "Bearer", - }); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "org-client", + defaultClientSecret: "org-secret", + clients: { + "alice-client": "alice-secret", + }, }); const startInput = { connectionId: "shared-petstore-oauth", displayName: "Petstore", securitySchemeName: "oauth2", - tokenUrl: tokenEndpoint.tokenUrl, + tokenUrl: oauth.tokenEndpoint, clientIdSecretId: "client_id", clientSecretSecretId: "client_secret", scopes: ["read"], @@ -572,7 +521,7 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { const oauth2 = makeOauth2SourceConfig({ flow: "clientCredentials", - tokenUrl: tokenEndpoint.tokenUrl, + tokenUrl: oauth.tokenEndpoint, authorizationUrl: null, scopes: startInput.scopes, }); @@ -582,11 +531,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { // creds and writes the connection at org, then the connection binding // is explicitly attached to the source slot. const adminAuth = yield* startClientCredentials(adminExec, orgScope.id, startInput); - yield* adminExec.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(adminExec, openApiServer, { scope: String(orgScope.id), namespace: "petstore", - baseUrl, oauth2, }); yield* adminExec.openapi.setSourceBinding( @@ -654,7 +601,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { // (3) Scope-stacked secret resolution produced per-user tokens. // The exchange call Alice made used her shadowed value; Bob's // fell through to the org default. - const tokenCalls = yield* tokenEndpoint.clientIds; + const tokenCalls = tokenEndpointRequests(yield* oauth.requests) + .map((request) => request.get("client_id")) + .filter(Predicate.isNotNull); expect(tokenCalls).toContain("alice-client"); expect(tokenCalls.filter((v) => v === "org-client").length).toBeGreaterThan(0); @@ -667,7 +616,9 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(aliceResult.error).toBeNull(); - expect(aliceResult.data?.authorization).toBe("Bearer token-for-alice-client"); + const aliceBearer = aliceResult.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(aliceBearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(aliceBearer!)).toBe(true); const bobResult = (yield* bobExec.tools.invoke( "petstore.items.echoHeaders", @@ -675,7 +626,10 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { autoApprove, )) as { data: { authorization?: string } | null; error: unknown }; expect(bobResult.error).toBeNull(); - expect(bobResult.data?.authorization).toBe("Bearer token-for-org-client"); + const bobBearer = bobResult.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(bobBearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(bobBearer!)).toBe(true); + expect(bobBearer).not.toBe(aliceBearer); // (5) Alice's sign-in is idempotent per-user — a repeat click // refreshes her one row instead of piling on orphans. diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 9f40fea4b..17b44dfd7 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -1,9 +1,9 @@ // --------------------------------------------------------------------------- // Dispatch tests for non-JSON request bodies. // -// Each case spins up a tiny http server, declares a POST endpoint in a -// minimal OpenAPI spec with the content type under test, and asserts both -// the wire-level content type and body shape the plugin actually sent. +// Each case spins up an Effect HttpApi-backed test server, derives the +// OpenAPI spec from that API, and asserts both the wire-level content type +// and body shape the plugin actually sent. // // The scenarios mirror what real specs commonly carry — multipart uploads // (files + scalar fields), XML bodies declared as pre-serialized strings, @@ -12,9 +12,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; +import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + HttpApiSchema, +} from "effect/unstable/httpapi"; import { createExecutor, @@ -23,6 +28,11 @@ import { type SecretProvider, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + makeOpenApiTestSourceConfig, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; @@ -61,73 +71,96 @@ type Captured = { body: Buffer; }; -const startEchoServer = () => - Effect.acquireRelease( - Effect.callback<{ baseUrl: string; captured: Captured; close: () => void }>((resume) => { - const captured: Captured = { contentType: "", body: Buffer.alloc(0) }; - const server = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on("data", (c: Buffer) => chunks.push(c)); - req.on("end", () => { - captured.contentType = req.headers["content-type"] ?? ""; - captured.body = Buffer.concat(chunks); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - }); - }); - server.listen(0, "127.0.0.1", () => { - const port = (server.address() as AddressInfo).port; - resume( - Effect.succeed({ - baseUrl: `http://127.0.0.1:${port}`, - captured, - close: () => server.close(), - }), - ); - }); - }), - (s) => Effect.sync(() => s.close()), - ); +const Ok = Schema.Struct({ ok: Schema.Boolean }); + +const startEchoServer = (options: { + readonly name?: string; + readonly path?: `/${string}`; + readonly payload: Schema.Top | readonly Schema.Top[]; + readonly transformSpec?: (spec: Record) => Record; +}) => + Effect.gen(function* () { + const captured: Captured = { contentType: "", body: Buffer.alloc(0) }; + const endpointName = options.name ?? "submit"; + const path = options.path ?? "/submit"; + const group = HttpApiGroup.make("body").add( + HttpApiEndpoint.post(endpointName, path, { + payload: options.payload, + success: Ok, + }), + ); + const api = HttpApi.make(`bodyTest_${endpointName}`).add(group); + const handlersLayer = HttpApiBuilder.group(api, "body", (handlers) => + handlers.handleRaw(endpointName, () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + captured.contentType = request.headers["content-type"] ?? ""; + const body = yield* request.arrayBuffer.pipe( + Effect.catch(() => Effect.succeed(new ArrayBuffer(0))), + ); + captured.body = Buffer.from(body); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }), + ), + ); + const server = yield* serveOpenApiHttpApiTestServer({ + api, + handlersLayer, + transformSpec: options.transformSpec, + }); + return { server, captured }; + }); -const makeSpec = (contentType: string) => - JSON.stringify({ - openapi: "3.0.0", - info: { title: "NonJsonTest", version: "1.0.0" }, - paths: { - "/submit": { - post: { - operationId: "submit", - tags: ["body"], - requestBody: { - required: true, - content: { - [contentType]: { - schema: { type: "object" }, - }, - }, - }, - responses: { - "200": { - description: "ok", - content: { - "application/json": { - schema: { - type: "object", - properties: { ok: { type: "boolean" } }, - }, - }, - }, - }, - }, - }, +const ObjectBody = Schema.Struct({ + name: Schema.optional(Schema.String), + flag: Schema.optional(Schema.Boolean), + count: Schema.optional(Schema.Number), +}); + +const JsonNameObject = Schema.Struct({ name: Schema.String }); + +const contentFor = (contentType: string) => ({ + [contentType]: { + schema: { type: "object" }, + }, +}); + +const replaceRequestBodyContent = + ( + path: string, + operation: string, + content: Record, + encoding?: Record, + ) => + (spec: Record): Record => { + const paths = { ...(spec.paths as Record) }; + const pathItem = { ...(paths[path] as Record) }; + const operationSpec = { ...(pathItem[operation] as Record) }; + const requestBody = { ...(operationSpec.requestBody as Record) }; + pathItem[operation] = { + ...operationSpec, + requestBody: { + ...requestBody, + content: encoding + ? Object.fromEntries( + Object.entries(content).map(([key, value]) => [ + key, + { ...(value as Record), encoding }, + ]), + ) + : content, }, - }, - }); + }; + paths[path] = pathItem; + return { ...spec, paths }; + }; describe("OpenAPI non-JSON request body dispatch", () => { it.effect("multipart/form-data: object body is encoded as real multipart", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: ObjectBody.pipe(HttpApiSchema.asMultipart()), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -138,11 +171,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec("multipart/form-data"), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "mp", - baseUrl, }); yield* executor.tools.invoke( @@ -166,7 +197,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("application/xml: string body passes through with xml content-type", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/xml" })), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -177,11 +210,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec("application/xml"), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "xml", - baseUrl, }); const xml = 'Acme'; @@ -194,7 +225,10 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("text/xml: object body is JSON-stringified (never '[object Object]')", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: JsonNameObject, + transformSpec: replaceRequestBodyContent("/submit", "post", contentFor("text/xml")), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -205,11 +239,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec("text/xml"), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "tx", - baseUrl, }); yield* executor.tools.invoke("tx.body.submit", { body: { name: "Acme" } }, autoApprove); @@ -223,7 +255,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("text/plain: string body passes through with text/plain", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: Schema.String.pipe(HttpApiSchema.asText()), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -234,11 +268,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec("text/plain"), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "tp", - baseUrl, }); yield* executor.tools.invoke("tp.body.submit", { body: "hello, world" }, autoApprove); @@ -250,7 +282,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("application/octet-stream: Uint8Array passes through as bytes", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -261,11 +295,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec("application/octet-stream"), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "bin", - baseUrl, }); const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02]); @@ -283,36 +315,16 @@ describe("OpenAPI non-JSON request body dispatch", () => { // and the caller can override via `args.contentType`. // ------------------------------------------------------------------------- - const multiContentSpec = JSON.stringify({ - openapi: "3.0.0", - info: { title: "MultiContentTest", version: "1.0.0" }, - paths: { - "/submit": { - post: { - operationId: "submit", - tags: ["body"], - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { type: "object" }, - }, - "application/json": { - schema: { type: "object" }, - }, - }, - }, - responses: { - "200": { description: "ok" }, - }, - }, - }, - }, - }); + const multiContentPayload = [ + ObjectBody.pipe(HttpApiSchema.asMultipart()), + JsonNameObject, + ] as const; it.effect("multi-content: defaults to first-declared (not JSON-first)", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: multiContentPayload, + }); const executor = yield* createExecutor( makeTestConfig({ @@ -323,11 +335,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: multiContentSpec, + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "mc", - baseUrl, }); yield* executor.tools.invoke("mc.body.submit", { body: { name: "Acme" } }, autoApprove); @@ -340,7 +350,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("multi-content: caller can override via args.contentType", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: multiContentPayload, + }); const executor = yield* createExecutor( makeTestConfig({ @@ -351,11 +363,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: multiContentSpec, + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "mc2", - baseUrl, }); yield* executor.tools.invoke( @@ -373,6 +383,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("multi-content: tool input schema exposes contentType enum", () => Effect.gen(function* () { + const { server } = yield* startEchoServer({ + payload: multiContentPayload, + }); const executor = yield* createExecutor( makeTestConfig({ plugins: [ @@ -382,12 +395,13 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: multiContentSpec, - scope: TEST_SCOPE, - namespace: "mc3", - baseUrl: "https://example.com", - }); + yield* executor.openapi.addSpec( + makeOpenApiTestSourceConfig(server, { + scope: TEST_SCOPE, + namespace: "mc3", + baseUrl: "https://example.com", + }), + ); const tools = yield* executor.tools.list(); const submit = tools.find((t) => t.id === "mc3.body.submit"); @@ -413,37 +427,21 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("multipart encoding.contentType: JSON metadata part has typed header", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); - - const spec = JSON.stringify({ - openapi: "3.0.0", - info: { title: "MultipartEncodingTest", version: "1.0.0" }, - paths: { - "/upload": { - post: { - operationId: "upload", - tags: ["body"], - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - properties: { - metadata: { type: "object" }, - filename: { type: "string" }, - }, - }, - encoding: { - metadata: { contentType: "application/json" }, - }, - }, - }, - }, - responses: { "200": { description: "ok" } }, - }, + const { server, captured } = yield* startEchoServer({ + name: "upload", + path: "/upload", + payload: Schema.Struct({ + metadata: Schema.Record(Schema.String, Schema.Unknown), + filename: Schema.String, + }).pipe(HttpApiSchema.asMultipart()), + transformSpec: replaceRequestBodyContent( + "/upload", + "post", + contentFor("multipart/form-data"), + { + metadata: { contentType: "application/json" }, }, - }, + ), }); const executor = yield* createExecutor( @@ -455,11 +453,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec, + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "mpe", - baseUrl, }); yield* executor.tools.invoke( @@ -490,33 +486,19 @@ describe("OpenAPI non-JSON request body dispatch", () => { // objects with style:deepObject use bracket notation. // ------------------------------------------------------------------------- - const formStyleSpec = (encoding: Record) => - JSON.stringify({ - openapi: "3.0.0", - info: { title: "FormStyleTest", version: "1.0.0" }, - paths: { - "/submit": { - post: { - operationId: "submit", - tags: ["body"], - requestBody: { - required: true, - content: { - "application/x-www-form-urlencoded": { - schema: { type: "object" }, - encoding, - }, - }, - }, - responses: { "200": { description: "ok" } }, - }, - }, - }, - }); - it.effect("form-urlencoded explode:false: arrays comma-join", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: ObjectBody.pipe(HttpApiSchema.asFormUrlEncoded()), + transformSpec: replaceRequestBodyContent( + "/submit", + "post", + contentFor("application/x-www-form-urlencoded"), + { + tags: { style: "form", explode: false }, + }, + ), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -527,13 +509,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: formStyleSpec({ - tags: { style: "form", explode: false }, - }), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "fe", - baseUrl, }); yield* executor.tools.invoke( @@ -553,7 +531,17 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("form-urlencoded deepObject: nested keys use bracket notation", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: ObjectBody.pipe(HttpApiSchema.asFormUrlEncoded()), + transformSpec: replaceRequestBodyContent( + "/submit", + "post", + contentFor("application/x-www-form-urlencoded"), + { + filter: { style: "deepObject", explode: true }, + }, + ), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -564,13 +552,9 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ - spec: formStyleSpec({ - filter: { style: "deepObject", explode: true }, - }), + yield* addOpenApiTestSource(executor, server, { scope: TEST_SCOPE, namespace: "fd", - baseUrl, }); yield* executor.tools.invoke( @@ -588,7 +572,15 @@ describe("OpenAPI non-JSON request body dispatch", () => { it.effect("form-urlencoded default: arrays use form+explode=true (repeat key)", () => Effect.gen(function* () { - const { baseUrl, captured } = yield* startEchoServer(); + const { server, captured } = yield* startEchoServer({ + payload: ObjectBody.pipe(HttpApiSchema.asFormUrlEncoded()), + transformSpec: replaceRequestBodyContent( + "/submit", + "post", + contentFor("application/x-www-form-urlencoded"), + {}, + ), + }); const executor = yield* createExecutor( makeTestConfig({ @@ -599,12 +591,10 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); - yield* executor.openapi.addSpec({ + yield* addOpenApiTestSource(executor, server, { // No encoding → OAS3 defaults: style=form, explode=true. - spec: formStyleSpec({}), scope: TEST_SCOPE, namespace: "fdx", - baseUrl, }); yield* executor.tools.invoke( diff --git a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts index 4af6f4004..45ffbf994 100644 --- a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +++ b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts @@ -12,23 +12,10 @@ // `ConnectionReauthRequiredError` so the UI can prompt sign-in. // --------------------------------------------------------------------------- -import { expect, layer } from "@effect/vitest"; -import { Effect, Layer, Predicate, Ref, Schema } from "effect"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { - FetchHttpClient, - HttpRouter, - HttpServer, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http"; import { ConnectionId, @@ -44,8 +31,11 @@ import { type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { serveTestHttpApp } from "@executor-js/sdk/testing"; +import { makeTestConfig, serveOAuthTestServer } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; @@ -67,7 +57,6 @@ const ItemsGroup = HttpApiGroup.make("items").add( ); const TestApi = HttpApi.make("testApi").add(ItemsGroup); -const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => handlers.handle("echoHeaders", () => @@ -80,44 +69,6 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => ), ); -const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); - -const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), -); - -// --------------------------------------------------------------------------- -// Token-endpoint mock. Callers supply a handler that sees the parsed body -// (grant_type, refresh_token, ...) and returns either an RFC 6749 success -// response or an error envelope. `calls` records every hit for assertions. -// --------------------------------------------------------------------------- - -type TokenCall = { - readonly body: URLSearchParams; -}; - -const serveTokenEndpoint = ( - handler: (body: URLSearchParams) => HttpServerResponse.HttpServerResponse, -) => - Effect.gen(function* () { - const calls = yield* Ref.make([]); - const server = yield* serveTestHttpApp((request) => - Effect.gen(function* () { - const body = new URLSearchParams(yield* request.text); - yield* Ref.update(calls, (all) => [...all, { body }]); - return handler(body); - }).pipe( - Effect.catch(() => - Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })), - ), - ), - ); - return { - tokenUrl: server.url("/token"), - calls: Ref.get(calls), - } as const; - }); - // --------------------------------------------------------------------------- // Fixture builder. Wires up a single-scope executor with an in-memory // secrets provider, the openApi plugin pointed at a live HttpClient, and @@ -144,13 +95,10 @@ const makeExecutor = () => secretProviders: [memoryProvider], })); const clientLayer = FetchHttpClient.layer; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (!Predicate.isTagged("TcpAddress")(address)) { - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address - return yield* Effect.die("test server must bind to TCP"); - } - const baseUrl = `http://127.0.0.1:${address.port}`; + const openApiServer = yield* serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + }); const plugins = [ openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin(), @@ -189,7 +137,7 @@ const makeExecutor = () => }), ); - return { executor, scopeId, baseUrl }; + return { executor, scopeId, openApiServer }; }); type EffectSuccess = T extends Effect.Effect ? A : never; @@ -204,6 +152,7 @@ const seedExpiredConnection = ( scopeId: ScopeId, connectionId: string, tokenUrl: string, + refreshToken: string, ) => Effect.gen(function* () { yield* executor.connections.create( @@ -220,7 +169,7 @@ const seedExpiredConnection = ( refreshToken: TokenMaterial.make({ secretId: SecretId.make(`${connectionId}.refresh_token`), name: "Refresh", - value: "refresh-v1", + value: refreshToken, }), expiresAt: Date.now() - 10_000, oauthScope: "read", @@ -265,35 +214,41 @@ const bindOAuthConnection = ( }), ); +const refreshTokenRequests = ( + requests: readonly { readonly path: string; readonly body: string }[], +) => + requests + .filter((request) => request.path === "/token") + .map((request) => new URLSearchParams(request.body)) + .filter((body) => body.get("grant_type") === "refresh_token"); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -layer(TestLayer)("OpenAPI oauth refresh", (it) => { +describe("OpenAPI oauth refresh", () => { it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () => Effect.gen(function* () { - const { executor, scopeId, baseUrl } = yield* makeExecutor(); - const tokenEndpoint = yield* serveTokenEndpoint(() => - HttpServerResponse.jsonUnsafe({ - access_token: "fresh-access-v2", - token_type: "Bearer", - refresh_token: "refresh-v2", - expires_in: 3600, - }), - ); + const { executor, scopeId, openApiServer } = yield* makeExecutor(); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "abc", + defaultClientSecret: "shhh", + }); + const initialTokens = yield* oauth.completeAuthorizationCodeTokenFlow(); + expect(initialTokens.refreshToken).toBeDefined(); + yield* oauth.clearRequests; const auth = yield* seedExpiredConnection( executor, scopeId, "conn-refresh-ok", - tokenEndpoint.tokenUrl, + oauth.tokenEndpoint, + initialTokens.refreshToken!, ); - yield* executor.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(executor, openApiServer, { scope: String(scopeId), namespace: "petstore", - baseUrl, oauth2: auth, }); yield* bindOAuthConnection(executor, scopeId, "conn-refresh-ok", auth); @@ -307,11 +262,13 @@ layer(TestLayer)("OpenAPI oauth refresh", (it) => { expect(result.error).toBeNull(); // Proves the refresh landed: invoke carried the fresh token, // not the expired one we seeded. - expect(result.data?.authorization).toBe("Bearer fresh-access-v2"); - const calls = yield* tokenEndpoint.calls; + expect(result.data?.authorization).not.toBe("Bearer expired-access-v1"); + const bearer = result.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(bearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); + const calls = refreshTokenRequests(yield* oauth.requests); expect(calls).toHaveLength(1); - expect(calls[0]!.body.get("grant_type")).toBe("refresh_token"); - expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1"); + expect(calls[0]!.get("refresh_token")).toBe(initialTokens.refreshToken); // Connection row is patched with the new expiry so the next // invoke in-window doesn't trip a second refresh. @@ -324,28 +281,26 @@ layer(TestLayer)("OpenAPI oauth refresh", (it) => { it.effect("concurrent invokes with an expired token issue exactly one refresh", () => Effect.gen(function* () { - const { executor, scopeId, baseUrl } = yield* makeExecutor(); - const tokenEndpoint = yield* serveTokenEndpoint(() => - HttpServerResponse.jsonUnsafe({ - access_token: "fresh-access-v2", - token_type: "Bearer", - refresh_token: "refresh-v2", - expires_in: 3600, - }), - ); + const { executor, scopeId, openApiServer } = yield* makeExecutor(); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "abc", + defaultClientSecret: "shhh", + }); + const initialTokens = yield* oauth.completeAuthorizationCodeTokenFlow(); + expect(initialTokens.refreshToken).toBeDefined(); + yield* oauth.clearRequests; const auth = yield* seedExpiredConnection( executor, scopeId, "conn-refresh-concurrent", - tokenEndpoint.tokenUrl, + oauth.tokenEndpoint, + initialTokens.refreshToken!, ); - yield* executor.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(executor, openApiServer, { scope: String(scopeId), namespace: "petstore", - baseUrl, oauth2: auth, }); yield* bindOAuthConnection(executor, scopeId, "conn-refresh-concurrent", auth); @@ -363,41 +318,38 @@ layer(TestLayer)("OpenAPI oauth refresh", (it) => { error: unknown; }; expect(res.error).toBeNull(); - expect(res.data?.authorization).toBe("Bearer fresh-access-v2"); + const bearer = res.data?.authorization?.replace(/^Bearer\s+/i, ""); + expect(bearer).toBeDefined(); + expect(yield* oauth.acceptsAccessToken(bearer!)).toBe(true); } // Critical assertion: the SDK's dedup collapses every parallel // invoke into one call to the token endpoint. Anything more // means we're hammering the AS under load. - const calls = yield* tokenEndpoint.calls; + const calls = refreshTokenRequests(yield* oauth.requests); expect(calls).toHaveLength(1); }), ); it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () => Effect.gen(function* () { - const { executor, scopeId, baseUrl } = yield* makeExecutor(); - const tokenEndpoint = yield* serveTokenEndpoint(() => - HttpServerResponse.jsonUnsafe( - { - error: "invalid_grant", - error_description: "Refresh token revoked", - }, - { status: 400 }, - ), - ); + const { executor, scopeId, openApiServer } = yield* makeExecutor(); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "abc", + defaultClientSecret: "shhh", + supportRefresh: false, + }); const auth = yield* seedExpiredConnection( executor, scopeId, "conn-refresh-dead", - tokenEndpoint.tokenUrl, + oauth.tokenEndpoint, + "refresh-v1", ); - yield* executor.openapi.addSpec({ - spec: specJson, + yield* addOpenApiTestSource(executor, openApiServer, { scope: String(scopeId), namespace: "petstore", - baseUrl, oauth2: auth, }); yield* bindOAuthConnection(executor, scopeId, "conn-refresh-dead", auth); @@ -415,7 +367,7 @@ layer(TestLayer)("OpenAPI oauth refresh", (it) => { ), ); expect(flipped.provider).toBe(OAUTH2_PROVIDER_KEY); - expect(flipped.message).toMatch(/OAuth refresh failed: .*revoked/i); + expect(flipped.message).toMatch(/OAuth refresh failed: .*Unknown refresh token/i); }), ); }); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index fb80e18e1..101b4d679 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -1,16 +1,7 @@ -import { expect, layer } from "@effect/vitest"; -import { Effect, Layer, Predicate, Schema } from "effect"; -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiGroup, - OpenApi, -} from "effect/unstable/httpapi"; -import { HttpClient, HttpRouter, HttpServerRequest } from "effect/unstable/http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import http from "node:http"; -import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Schema } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { createExecutor, @@ -24,14 +15,17 @@ import { type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { memorySecretsPlugin } from "@executor-js/sdk/testing"; +import { makeTestConfig, memorySecretsPlugin } from "@executor-js/sdk/testing"; import type { ConfigFileSink } from "@executor-js/config"; const TEST_SCOPE = "test-scope"; import { openApiPlugin } from "./plugin"; import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; -import { makeOpenApiTestServer } from "../testing"; +import { + addOpenApiTestSource, + makeOpenApiHttpApiTestSourceConfig, + serveOpenApiHttpApiTestServer, +} from "../testing"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -87,7 +81,11 @@ const ItemsGroup = HttpApiGroup.make("items") success: Item, }), ) - .add(HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders })) + .add( + HttpApiEndpoint.get("echoHeaders", "/echo-headers", { + success: EchoHeaders, + }), + ) .add( HttpApiEndpoint.get("queryRows", "/records/rows/:entryTypeId", { params: Schema.Struct({ entryTypeId: Schema.String }), @@ -98,8 +96,20 @@ const ItemsGroup = HttpApiGroup.make("items") const TestApi = HttpApi.make("testApi").add(ItemsGroup); -const spec = OpenApi.fromApi(TestApi); -const specJson = JSON.stringify(spec); +type TestApiSourceOptions = Omit< + Parameters[1], + "scope" +> & { + readonly scope?: string; +}; + +const testApiSourceConfig = (options: TestApiSourceOptions = {}) => + makeOpenApiHttpApiTestSourceConfig(TestApi, { + scope: TEST_SCOPE, + ...options, + }); + +const testApiSpec = () => testApiSourceConfig().spec; // --------------------------------------------------------------------------- // Implement handlers @@ -142,80 +152,69 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => ), ); -// --------------------------------------------------------------------------- -// Test layer: real server on port 0 + HttpClient pointing at it -// --------------------------------------------------------------------------- - -const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); - -const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), -); +const servePluginTestApi = () => + serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + }); const serveSpecRequiringHeader = () => { const state = { requests: 0, lastToken: null as string | null }; - const server = http.createServer((req, res) => { - state.requests++; - state.lastToken = req.headers["x-spec-token"]?.toString() ?? null; - if (state.lastToken !== "org-token") { - res.writeHead(401, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "missing token" })); - return; - } - res.writeHead(200, { "content-type": "application/json" }); - res.end(specJson); - }); - - return new Promise<{ - readonly specUrl: string; - readonly requestCount: () => number; - readonly lastToken: () => string | null; - readonly close: () => Promise; - }>((resolve) => { - server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as AddressInfo; - resolve({ - specUrl: `http://127.0.0.1:${port}/spec.json`, - requestCount: () => state.requests, - lastToken: () => state.lastToken, - close: () => - new Promise((closeResolve) => { - server.close(() => closeResolve()); - }), - }); - }); - }); + return serveOpenApiHttpApiTestServer({ + api: TestApi, + handlersLayer: ItemsGroupLive, + transformSpec: (spec) => { + const { servers: _servers, ...rest } = spec; + return rest; + }, + guardSpecRequest: (request) => + Effect.sync(() => { + state.requests++; + state.lastToken = request.headers["x-spec-token"] ?? null; + if (state.lastToken !== "org-token") { + return HttpServerResponse.jsonUnsafe({ error: "missing token" }, { status: 401 }); + } + return null; + }), + }).pipe( + Effect.map((server) => ({ + specUrl: server.specUrl, + requestCount: () => state.requests, + lastToken: () => state.lastToken, + })), + ); }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -layer(TestLayer)("OpenAPI Plugin", (it) => { +describe("OpenAPI Plugin", () => { it.effect("previewSpec returns metadata and header presets", () => - Effect.gen(function* () { - const server = yield* makeOpenApiTestServer({ spec }); - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin({ httpClientLayer: server.httpClientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: server.httpClientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - const preview = yield* executor.openapi.previewSpec(server.specJson); + const preview = yield* executor.openapi.previewSpec(server.specJson); - expect(preview.operationCount).toBeGreaterThanOrEqual(2); - expect(preview.servers).toBeDefined(); - }), + expect(preview.operationCount).toBeGreaterThanOrEqual(2); + expect(preview.servers).toBeDefined(); + }), + ), ); it.effect("registers static openapi executor tools", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -235,8 +234,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("lists executor as the static runtime source", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -258,8 +256,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("invokes static previewSpec through executor.tools.invoke", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -272,7 +269,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { const result = (yield* executor.tools.invoke( "executor.openapi.previewSpec", - { spec: specJson }, + { spec: testApiSpec() }, autoApprove, )) as { operationCount: number }; @@ -302,8 +299,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("invokes static addSource through executor.tools.invoke", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const userScope = ScopeId.make("static-user"); const orgScope = ScopeId.make("static-org"); @@ -322,7 +318,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { const result = (yield* executor.tools.invoke( "executor.openapi.addSource", - { scope: String(orgScope), spec: specJson, namespace: "runtime" }, + testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), autoApprove, )) as { sourceId: string; toolCount: number }; @@ -344,8 +340,10 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { const declined = yield* executor.tools .invoke( "executor.openapi.addSource", - { scope: TEST_SCOPE, spec: specJson, namespace: "runtime_declined" }, - { onElicitation: () => Effect.succeed({ action: "decline" as const }) }, + testApiSourceConfig({ namespace: "runtime_declined" }), + { + onElicitation: () => Effect.succeed({ action: "decline" as const }), + }, ) .pipe(Effect.flip); @@ -359,15 +357,18 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("adds an org source whose direct credentials are owned by the user scope", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const userScope = ScopeId.make("openapi-user"); const orgScope = ScopeId.make("openapi-org"); const executor = yield* createExecutor( makeTestConfig({ scopes: [ - Scope.make({ id: userScope, name: "user", createdAt: new Date() }), + Scope.make({ + id: userScope, + name: "user", + createdAt: new Date(), + }), Scope.make({ id: orgScope, name: "org", createdAt: new Date() }), ], plugins: [ @@ -386,13 +387,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - const input = { - spec: specJson, + const input = testApiSourceConfig({ scope: String(orgScope), namespace: "org_direct_user_credential", queryParams: { token: { secretId: "user-query-token" } }, credentialTargetScope: String(userScope), - }; + }); yield* executor.openapi.addSpec(input); @@ -404,15 +404,17 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { expect(bindings[0]).toMatchObject({ scopeId: userScope, slot: "query_param:token", - value: { kind: "secret", secretId: SecretId.make("user-query-token") }, + value: { + kind: "secret", + secretId: SecretId.make("user-query-token"), + }, }); }), ); it.effect("updateSource removes bindings for credential slots no longer present", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -432,16 +434,16 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "stale_binding", - baseUrl: "", - credentialTargetScope: TEST_SCOPE, - headers: { - "X-Old": { secretId: "old-token" }, - }, - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "stale_binding", + baseUrl: "", + credentialTargetScope: TEST_SCOPE, + headers: { + "X-Old": { secretId: "old-token" }, + }, + }), + ); yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { headers: {}, @@ -454,8 +456,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("updateSource removes stale OAuth2 bindings when the OAuth template changes", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -486,13 +487,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { connectionSlot: "oauth2:old:connection", scopes: ["read"], }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "stale_oauth", - baseUrl: "", - oauth2: oldOAuth, - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "stale_oauth", + baseUrl: "", + oauth2: oldOAuth, + }), + ); yield* executor.openapi.setSourceBinding( OpenApiSourceBindingInput.make({ sourceId: "stale_oauth", @@ -523,51 +524,54 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { ); it.effect("resolves secret-backed headers at invocation time", () => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin({ httpClientLayer: clientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("test-api-token"), - scope: ScopeId.make(TEST_SCOPE), - name: "Test API Token", - value: "secret-value-123", - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const clientLayer = FetchHttpClient.layer; + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "authed", - baseUrl: "", - credentialTargetScope: TEST_SCOPE, - headers: { - Authorization: { secretId: "test-api-token", prefix: "Bearer " }, - "X-Static": "hello", - }, - }); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("test-api-token"), + scope: ScopeId.make(TEST_SCOPE), + name: "Test API Token", + value: "secret-value-123", + }), + ); - const result = (yield* executor.tools.invoke( - "authed.items.echoHeaders", - {}, - autoApprove, - )) as { data: { authorization?: string; "x-static"?: string } | null; error: unknown }; + yield* addOpenApiTestSource(executor, server, { + scope: TEST_SCOPE, + namespace: "authed", + credentialTargetScope: TEST_SCOPE, + headers: { + Authorization: { secretId: "test-api-token", prefix: "Bearer " }, + "X-Static": "hello", + }, + }); - expect(result.error).toBeNull(); - const data = result.data!; - expect(data.authorization).toBe("Bearer secret-value-123"); - expect(data["x-static"]).toBe("hello"); - }), + const result = (yield* executor.tools.invoke( + "authed.items.echoHeaders", + {}, + autoApprove, + )) as { + data: { authorization?: string; "x-static"?: string } | null; + error: unknown; + }; + + expect(result.error).toBeNull(); + const data = result.data!; + expect(data.authorization).toBe("Bearer secret-value-123"); + expect(data["x-static"]).toBe("hello"); + }), + ), ); it.effect("addSpec without credentialTargetScope defaults to the source's scope", () => @@ -577,8 +581,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { // "credentialTargetScope is required when adding direct OpenAPI // credentials" the moment the daemon started. Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -598,15 +601,18 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "default_target_scope", - baseUrl: "", - headers: { - Authorization: { secretId: "config-sync-token", prefix: "Bearer " }, - }, - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "default_target_scope", + baseUrl: "", + headers: { + Authorization: { + secretId: "config-sync-token", + prefix: "Bearer ", + }, + }, + }), + ); const bindings = yield* executor.openapi.listSourceBindings( "default_target_scope", @@ -616,86 +622,91 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { expect(bindings[0]).toMatchObject({ scopeId: ScopeId.make(TEST_SCOPE), slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("config-sync-token") }, + value: { + kind: "secret", + secretId: SecretId.make("config-sync-token"), + }, }); }), ); it.effect("fails clearly when a secret is missing", () => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const secretStore = new Map(); - const key = (scope: string, id: string) => `${scope}\u0000${id}`; - const provider: SecretProvider = { - key: "memory", - writable: true, - get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), - set: (id, value, scope) => - Effect.sync(() => { - secretStore.set(key(scope, id), value); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const clientLayer = FetchHttpClient.layer; + const secretStore = new Map(); + const key = (scope: string, id: string) => `${scope}\u0000${id}`; + const provider: SecretProvider = { + key: "memory", + writable: true, + get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), + set: (id, value, scope) => + Effect.sync(() => { + secretStore.set(key(scope, id), value); + }), + delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))), + }; + const staleSecretPlugin = definePlugin(() => ({ + id: "stale-secret" as const, + storage: () => ({}), + secretProviders: [provider], + })); + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + staleSecretPlugin(), + ] as const, }), - delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))), - }; - const staleSecretPlugin = definePlugin(() => ({ - id: "stale-secret" as const, - storage: () => ({}), - secretProviders: [provider], - })); - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [openApiPlugin({ httpClientLayer: clientLayer }), staleSecretPlugin()] as const, - }), - ); - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("missing-token"), - scope: ScopeId.make(TEST_SCOPE), - name: "Missing token", - value: "initial-value", - }), - ); + ); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("missing-token"), + scope: ScopeId.make(TEST_SCOPE), + name: "Missing token", + value: "initial-value", + }), + ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "noauth", - baseUrl: "", - headers: { - Authorization: ConfiguredHeaderBinding.make({ - kind: "binding", + yield* addOpenApiTestSource(executor, server, { + scope: TEST_SCOPE, + namespace: "noauth", + headers: { + Authorization: ConfiguredHeaderBinding.make({ + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }), + }, + }); + yield* executor.openapi.setSourceBinding( + OpenApiSourceBindingInput.make({ + sourceId: "noauth", + sourceScope: ScopeId.make(TEST_SCOPE), + scope: ScopeId.make(TEST_SCOPE), slot: "header:authorization", - prefix: "Bearer ", + value: { kind: "secret", secretId: SecretId.make("missing-token") }, }), - }, - }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "noauth", - sourceScope: ScopeId.make(TEST_SCOPE), - scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("missing-token") }, - }), - ); - secretStore.delete(key(TEST_SCOPE, "missing-token")); + ); + secretStore.delete(key(TEST_SCOPE, "missing-token")); - const error = yield* Effect.flip( - executor.tools.invoke("noauth.items.listItems", {}, autoApprove), - ); + const error = yield* Effect.flip( + executor.tools.invoke("noauth.items.listItems", {}, autoApprove), + ); - expect(Predicate.isTagged(error, "ToolInvocationError")).toBe(true); - expect(error).toMatchObject({ - message: expect.stringContaining("missing-token"), - }); - }), + expect(Predicate.isTagged(error, "ToolInvocationError")).toBe(true); + expect(error).toMatchObject({ + message: expect.stringContaining("missing-token"), + }); + }), + ), ); it.effect("registers tools from an OpenAPI spec", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ plugins: [ @@ -705,12 +716,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - const result = yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "test", - baseUrl: "", - }); + const result = yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "test", + baseUrl: "", + }), + ); expect(result.toolCount).toBeGreaterThanOrEqual(2); @@ -722,108 +733,107 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { ); it.effect("invokes listItems", () => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin({ httpClientLayer: clientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const clientLayer = FetchHttpClient.layer; + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "test", - baseUrl: "", - }); + yield* addOpenApiTestSource(executor, server, { + scope: TEST_SCOPE, + namespace: "test", + }); - const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as { - data: unknown; - error: unknown; - }; - expect(result.error).toBeNull(); - expect(result.data).toEqual(ITEMS); - }), + const result = (yield* executor.tools.invoke("test.items.listItems", {}, autoApprove)) as { + data: unknown; + error: unknown; + }; + expect(result.error).toBeNull(); + expect(result.data).toEqual(ITEMS); + }), + ), ); it.effect("invokes getItem with path parameter", () => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin({ httpClientLayer: clientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const clientLayer = FetchHttpClient.layer; + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "test", - baseUrl: "", - }); + yield* addOpenApiTestSource(executor, server, { + scope: TEST_SCOPE, + namespace: "test", + }); - const result = (yield* executor.tools.invoke( - "test.items.getItem", - { itemId: "2" }, - autoApprove, - )) as { data: unknown; error: unknown }; - expect(result.error).toBeNull(); - expect(result.data).toEqual({ id: 2, name: "Gadget" }); - }), + const result = (yield* executor.tools.invoke( + "test.items.getItem", + { itemId: "2" }, + autoApprove, + )) as { data: unknown; error: unknown }; + expect(result.error).toBeNull(); + expect(result.data).toEqual({ id: 2, name: "Gadget" }); + }), + ), ); it.effect("surfaces structured validation errors from OpenAPI tool calls", () => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [ - openApiPlugin({ httpClientLayer: clientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const clientLayer = FetchHttpClient.layer; + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: clientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "records", - baseUrl: "", - }); + yield* addOpenApiTestSource(executor, server, { + scope: TEST_SCOPE, + namespace: "records", + }); - const result = (yield* executor.tools.invoke( - "records.items.queryRows", - { - entryTypeId: "18538", - query: JSON.stringify([{ DisplayName: "Example" }]), - limit: 10, - skip: 0, - }, - autoApprove, - )) as { data: unknown; error: unknown }; + const result = (yield* executor.tools.invoke( + "records.items.queryRows", + { + entryTypeId: "18538", + query: JSON.stringify([{ DisplayName: "Example" }]), + limit: 10, + skip: 0, + }, + autoApprove, + )) as { data: unknown; error: unknown }; - expect(result.data).toBeNull(); - expect(result.error).toEqual( - expect.objectContaining({ - message: 'Field with name "DisplayName" does not exist', - }), - ); - }), + expect(result.data).toBeNull(); + expect(result.error).toEqual( + expect.objectContaining({ + message: 'Field with name "DisplayName" does not exist', + }), + ); + }), + ), ); it.effect("removeSpec cleans up registered tools", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ plugins: [ @@ -833,12 +843,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "removable", - baseUrl: "", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "removable", + baseUrl: "", + }), + ); expect((yield* executor.tools.list()).length).toBeGreaterThan(2); @@ -852,8 +862,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("executor.sources.remove writes back to configFile (engine-level remove)", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const removeCalls: string[] = []; const upsertCalls: string[] = []; @@ -877,15 +886,18 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "removable", - baseUrl: "", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "removable", + baseUrl: "", + }), + ); expect(upsertCalls).toEqual(["removable"]); - yield* executor.sources.remove({ id: "removable", targetScope: TEST_SCOPE }); + yield* executor.sources.remove({ + id: "removable", + targetScope: TEST_SCOPE, + }); expect(removeCalls).toEqual(["removable"]); }), @@ -897,8 +909,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { // throw StorageError("source does not exist"), which surfaced to the // browser as a 500. A removed source has no bindings — return []. Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ plugins: [ @@ -908,12 +919,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "removable", - baseUrl: "", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "removable", + baseUrl: "", + }), + ); yield* executor.openapi.removeSpec("removable", TEST_SCOPE); const bindings = yield* executor.openapi.listSourceBindings("removable", TEST_SCOPE); @@ -938,8 +949,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("shadowed addSpec does not wipe the outer-scope source", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -952,21 +962,23 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { ); // Org-level base source - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(ORG_SCOPE), - namespace: "shared", - baseUrl: "", - name: "Org Source", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(ORG_SCOPE), + namespace: "shared", + baseUrl: "", + name: "Org Source", + }), + ); // Per-user shadow with the same namespace - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(USER_SCOPE), - namespace: "shared", - name: "User Source", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(USER_SCOPE), + namespace: "shared", + name: "User Source", + }), + ); const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE)); const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE)); @@ -982,8 +994,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("getSource resolves inherited config without listing every OpenAPI source", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const config = makeTestConfig({ scopes: stackedScopes, plugins: [openApiPlugin({ httpClientLayer: clientLayer }), memorySecretsPlugin()] as const, @@ -995,19 +1006,21 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { db: recordFumaQueries(config.db, queryCalls), }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(ORG_SCOPE), - namespace: "shared", - baseUrl: "https://org.example.com", - name: "Org Source", - }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(USER_SCOPE), - namespace: "shared", - name: "User Source", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(ORG_SCOPE), + namespace: "shared", + baseUrl: "https://org.example.com", + name: "Org Source", + }), + ); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(USER_SCOPE), + namespace: "shared", + name: "User Source", + }), + ); queryCalls.length = 0; const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE)); @@ -1021,8 +1034,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("removeSpec on user shadow leaves the org row intact", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -1034,20 +1046,22 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(ORG_SCOPE), - namespace: "shared", - baseUrl: "", - name: "Org Source", - }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(USER_SCOPE), - namespace: "shared", - baseUrl: "", - name: "User Source", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(ORG_SCOPE), + namespace: "shared", + baseUrl: "", + name: "Org Source", + }), + ); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(USER_SCOPE), + namespace: "shared", + baseUrl: "", + name: "User Source", + }), + ); yield* executor.openapi.removeSpec("shared", String(USER_SCOPE)); @@ -1061,8 +1075,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("updateSource on user shadow cannot override the inherited base URL", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -1074,19 +1087,21 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(ORG_SCOPE), - namespace: "shared", - baseUrl: "https://org.example.com", - name: "Org Source", - }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(USER_SCOPE), - namespace: "shared", - name: "User Source", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(ORG_SCOPE), + namespace: "shared", + baseUrl: "https://org.example.com", + name: "Org Source", + }), + ); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(USER_SCOPE), + namespace: "shared", + name: "User Source", + }), + ); const updateResult = yield* executor.openapi .updateSource("shared", String(USER_SCOPE), { @@ -1112,55 +1127,57 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { ); it.effect("addSpec on user shadow cannot override the inherited base URL", () => - Effect.gen(function* () { - const server = yield* makeOpenApiTestServer({ spec }); - const executor = yield* createExecutor( - makeTestConfig({ - scopes: stackedScopes, - plugins: [ - openApiPlugin({ httpClientLayer: server.httpClientLayer }), - memorySecretsPlugin(), - ] as const, - }), - ); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("org-api-token"), - scope: ORG_SCOPE, - name: "Org API token", - value: "org-secret", - }), - ); + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const executor = yield* createExecutor( + makeTestConfig({ + scopes: stackedScopes, + plugins: [ + openApiPlugin({ httpClientLayer: server.httpClientLayer }), + memorySecretsPlugin(), + ] as const, + }), + ); - yield* executor.openapi.addSpec({ - spec: server.specJson, - scope: String(ORG_SCOPE), - namespace: "shadow_auth", - baseUrl: "https://org.example.com", - credentialTargetScope: String(ORG_SCOPE), - headers: { - Authorization: { secretId: "org-api-token", prefix: "Bearer " }, - }, - }); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("org-api-token"), + scope: ORG_SCOPE, + name: "Org API token", + value: "org-secret", + }), + ); - const addResult = yield* executor.openapi - .addSpec({ + yield* executor.openapi.addSpec({ spec: server.specJson, - scope: String(USER_SCOPE), + scope: String(ORG_SCOPE), namespace: "shadow_auth", - baseUrl: server.baseUrl, - name: "User Shadow", - }) - .pipe( - Effect.match({ - onFailure: (error) => error, - onSuccess: () => null, - }), - ); + baseUrl: "https://org.example.com", + credentialTargetScope: String(ORG_SCOPE), + headers: { + Authorization: { secretId: "org-api-token", prefix: "Bearer " }, + }, + }); - expect(addResult).toMatchObject({ _tag: "OpenApiOAuthError" }); - }), + const addResult = yield* executor.openapi + .addSpec({ + spec: server.specJson, + scope: String(USER_SCOPE), + namespace: "shadow_auth", + baseUrl: server.baseUrl, + name: "User Shadow", + }) + .pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => null, + }), + ); + + expect(addResult).toMatchObject({ _tag: "OpenApiOAuthError" }); + }), + ), ); it.effect( @@ -1168,10 +1185,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { () => Effect.scoped( Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => serveSpecRequiringHeader()), - (server) => Effect.promise(() => server.close()), - ); + const server = yield* serveSpecRequiringHeader(); const config = makeTestConfig({ scopes: stackedScopes, plugins: [openApiPlugin(), memorySecretsPlugin()] as const, @@ -1199,12 +1213,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }, }, }); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: String(USER_SCOPE), - namespace: "shared_spec_fetch", - name: "User Shadow", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + scope: String(USER_SCOPE), + namespace: "shared_spec_fetch", + name: "User Shadow", + }), + ); const userRowsBefore = yield* Effect.promise(() => db.findMany("openapi_source_spec_fetch_header", { @@ -1251,8 +1266,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { it.effect("addSpec persists OAuth2 source slots with no live connection yet", () => Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); + const clientLayer = FetchHttpClient.layer; const executor = yield* createExecutor( makeTestConfig({ @@ -1287,13 +1301,13 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { scopes: ["read:items"], }); - const result = yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "deferred", - baseUrl: "https://api.example.com", - oauth2: deferredAuth, - }); + const result = yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "deferred", + baseUrl: "https://api.example.com", + oauth2: deferredAuth, + }), + ); expect(result.toolCount).toBeGreaterThan(0); @@ -1309,7 +1323,10 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { sourceScope: ScopeId.make(TEST_SCOPE), scope: ScopeId.make(TEST_SCOPE), slot: stored!.config.oauth2!.clientIdSlot, - value: { kind: "secret", secretId: SecretId.make("acme-client-id") }, + value: { + kind: "secret", + secretId: SecretId.make("acme-client-id"), + }, }), ); @@ -1370,14 +1387,14 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { ); // Add a source whose query params are canonicalized to a credential slot. - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "with_secret", - baseUrl: "http://example.com", - credentialTargetScope: TEST_SCOPE, - queryParams: { token: { secretId: "api-key" } }, - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "with_secret", + baseUrl: "http://example.com", + credentialTargetScope: TEST_SCOPE, + queryParams: { token: { secretId: "api-key" } }, + }), + ); // Configure a slot binding pointing at the same secret. yield* executor.openapi.setSourceBinding( @@ -1415,12 +1432,12 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { }), ); - yield* executor.openapi.addSpec({ - spec: specJson, - scope: TEST_SCOPE, - namespace: "ref", - baseUrl: "http://example.com", - }); + yield* executor.openapi.addSpec( + testApiSourceConfig({ + namespace: "ref", + baseUrl: "http://example.com", + }), + ); yield* executor.openapi.setSourceBinding( OpenApiSourceBindingInput.make({ sourceId: "ref", diff --git a/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts b/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts index c0b2d613a..8d3b563f1 100644 --- a/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +++ b/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts @@ -8,28 +8,43 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Option, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { previewSpec as previewSpecRaw } from "./preview"; const previewSpec = (input: string) => previewSpecRaw(input).pipe(Effect.provide(FetchHttpClient.layer)); +const PreviewGroup = HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), +); + +const PreviewApi = HttpApi.make("previewOauth2Test") + .add(PreviewGroup) + .annotateMerge( + OpenApi.annotations({ + title: "Test API", + version: "1.0.0", + servers: [{ url: "https://api.example.com" }], + }), + ); + const minimalSpec = ( securitySchemes: Record, components: Record = {}, -) => ({ - openapi: "3.0.0", - info: { title: "Test API", version: "1.0.0" }, - servers: [{ url: "https://api.example.com" }], - paths: { - "/ping": { - get: { responses: { "200": { description: "ok" } } }, - }, - }, - components: { ...components, securitySchemes }, -}); +) => + OpenApi.fromApi( + PreviewApi.annotateMerge( + OpenApi.annotations({ + transform: (spec) => ({ + ...spec, + components: { ...components, securitySchemes }, + }), + }), + ), + ); describe("previewSpec OAuth2 extraction", () => { it.effect("extracts authorizationCode flow with URLs, scopes, refreshUrl", () => @@ -139,37 +154,17 @@ describe("previewSpec OAuth2 extraction", () => { it.effect("resolves security schemes defined via $ref", () => Effect.gen(function* () { - const spec = minimalSpec( - { - api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, - }, - { - securitySchemes: { - _api_token_impl: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - description: "Internal token scheme", - }, - }, - }, - ); - // Note: the outer securitySchemes at `components.securitySchemes` is - // what previewSpec reads; the `_api_token_impl` shim inside - // components.securitySchemes allows $ref resolution via the resolver. - // The test spec above is slightly awkward because we have to nest both - // under the same key — adjust by merging. - spec.components = { - securitySchemes: { - api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, - _api_token_impl: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - description: "Internal token scheme", - }, + const spec = minimalSpec({ + api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, + _api_token_impl: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Internal token scheme", }, - }; + }); + // Note: `api_token` should resolve through the sibling + // `_api_token_impl` scheme in `components.securitySchemes`. const preview = yield* previewSpec(JSON.stringify(spec)); // Both keys are present, but the `api_token` entry should resolve to @@ -181,7 +176,6 @@ describe("previewSpec OAuth2 extraction", () => { expect(Option.getOrElse(apiToken!.bearerFormat, () => "")).toBe("JWT"); }), ); - it.effect("captures openIdConnectUrl for openIdConnect schemes", () => Effect.gen(function* () { const spec = minimalSpec({ diff --git a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts index 54af458a5..4d9740c2b 100644 --- a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts +++ b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts @@ -11,8 +11,17 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { Effect, Exit, Schema } from "effect"; +import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + OpenApi, +} from "effect/unstable/httpapi"; +// The socket-drop and slow-response cases exercise Node transport behavior +// that Effect's in-memory HTTP test server intentionally abstracts away. import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; @@ -23,6 +32,12 @@ import { type SecretProvider, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; +import { + makeOpenApiHttpApiTestSourceConfig, + makeOpenApiTestSourceConfig, + type OpenApiTestServerShape, + serveOpenApiHttpApiTestServer, +} from "../testing"; import { openApiPlugin } from "./plugin"; @@ -53,34 +68,41 @@ const memorySecretsPlugin = definePlugin(() => ({ type ResponseScript = (req: { url: string; method: string; - headers: Record; + headers: Readonly>; }) => { status?: number; headers?: Record; - body?: string | Buffer; - // If true, server destroys the socket mid-response without sending body. - drop?: boolean; + body?: string; }; const startScriptedServer = (script: ResponseScript) => + serveOpenApiHttpApiTestServer({ + api: FailureApi, + handlersLayer: HttpApiBuilder.group(FailureApi, "things", (handlers) => + handlers.handle("listThings", () => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const result = script({ + url: request.url, + method: request.method, + headers: request.headers, + }); + return HttpServerResponse.text(result.body ?? '{"ok":true}', { + status: result.status ?? 200, + headers: result.headers ?? { "content-type": "application/json" }, + }); + }), + ), + ), + }); + +const startDroppingServer = () => Effect.acquireRelease( Effect.callback<{ baseUrl: string; close: () => void }>((resume) => { - const server = createServer((req, res) => { - const url = req.url ?? "/"; - const result = script({ url, method: req.method ?? "GET", headers: req.headers }); - if (result.drop) { - // Send headers then forcibly destroy the socket to simulate a - // real-world connection drop mid-body. - res.writeHead(result.status ?? 200, result.headers ?? {}); - res.write("partial"); - req.socket.destroy(); - return; - } - res.writeHead( - result.status ?? 200, - result.headers ?? { "content-type": "application/json" }, - ); - res.end(result.body ?? '{"ok":true}'); + const server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write("partial"); + res.destroy(); }); server.listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; @@ -95,30 +117,15 @@ const startScriptedServer = (script: ResponseScript) => (s) => Effect.sync(() => s.close()), ); -const makeSpec = () => - JSON.stringify({ - openapi: "3.0.0", - info: { title: "FailuresTest", version: "1.0.0" }, - paths: { - "/things": { - get: { - operationId: "listThings", - tags: ["things"], - responses: { - "200": { - description: "ok", - content: { - "application/json": { - schema: { type: "array", items: { type: "object" } }, - }, - }, - }, - default: { description: "error" }, - }, - }, - }, - }, - }); +const ThingsGroup = HttpApiGroup.make("things").add( + HttpApiEndpoint.get("listThings", "/things", { + success: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), + }), +); + +const FailureApi = HttpApi.make("failuresTest") + .add(ThingsGroup) + .annotateMerge(OpenApi.annotations({ title: "FailuresTest", version: "1.0.0" })); const buildExecutor = (baseUrl: string) => Effect.gen(function* () { @@ -130,12 +137,32 @@ const buildExecutor = (baseUrl: string) => ] as const, }), ); - yield* executor.openapi.addSpec({ - spec: makeSpec(), - scope: TEST_SCOPE, - namespace: "f", - baseUrl, - }); + yield* executor.openapi.addSpec( + makeOpenApiHttpApiTestSourceConfig(FailureApi, { + scope: TEST_SCOPE, + namespace: "f", + baseUrl, + }), + ); + return executor; + }); + +const buildExecutorForOpenApiServer = (server: OpenApiTestServerShape) => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), + memorySecretsPlugin(), + ] as const, + }), + ); + yield* executor.openapi.addSpec( + makeOpenApiTestSourceConfig(server, { + scope: TEST_SCOPE, + namespace: "f", + }), + ); return executor; }); @@ -147,12 +174,12 @@ describe("OpenAPI upstream failure modes", () => { // failure is acceptable; what isn't is a silent successful return. it.effect("upstream 500 surfaces via the error envelope (not silent success)", () => Effect.gen(function* () { - const { baseUrl } = yield* startScriptedServer(() => ({ + const server = yield* startScriptedServer(() => ({ status: 500, headers: { "content-type": "application/json" }, body: '{"error":{"code":"internal","message":"db timeout"}}', })); - const executor = yield* buildExecutor(baseUrl); + const executor = yield* buildExecutorForOpenApiServer(server); const exit = yield* executor.tools .invoke("f.things.listThings", {}, autoApprove) @@ -176,12 +203,12 @@ describe("OpenAPI upstream failure modes", () => { it.effect("upstream 4xx surfaces structured error body", () => Effect.gen(function* () { - const { baseUrl } = yield* startScriptedServer(() => ({ + const server = yield* startScriptedServer(() => ({ status: 422, headers: { "content-type": "application/json" }, body: '{"error":{"field":"name","reason":"too_short"}}', })); - const executor = yield* buildExecutor(baseUrl); + const executor = yield* buildExecutorForOpenApiServer(server); const exit = yield* executor.tools .invoke("f.things.listThings", {}, autoApprove) @@ -198,12 +225,12 @@ describe("OpenAPI upstream failure modes", () => { it.effect("upstream returns malformed JSON despite Content-Type: application/json", () => Effect.gen(function* () { - const { baseUrl } = yield* startScriptedServer(() => ({ + const server = yield* startScriptedServer(() => ({ status: 200, headers: { "content-type": "application/json" }, body: "not json at all <<<<", })); - const executor = yield* buildExecutor(baseUrl); + const executor = yield* buildExecutorForOpenApiServer(server); // Whatever happens, the test asserts it doesn't produce a defect or // hang — either the plugin returns a value (raw text / passthrough) @@ -221,11 +248,7 @@ describe("OpenAPI upstream failure modes", () => { it.effect("upstream connection drop mid-response surfaces as a failure", () => Effect.gen(function* () { - const { baseUrl } = yield* startScriptedServer(() => ({ - status: 200, - headers: { "content-type": "application/json" }, - drop: true, - })); + const { baseUrl } = yield* startDroppingServer(); const executor = yield* buildExecutor(baseUrl); const exit = yield* executor.tools @@ -238,12 +261,12 @@ describe("OpenAPI upstream failure modes", () => { it.effect("upstream returns wrong content-type (HTML for a JSON op)", () => Effect.gen(function* () { - const { baseUrl } = yield* startScriptedServer(() => ({ + const server = yield* startScriptedServer(() => ({ status: 200, headers: { "content-type": "text/html" }, body: "Service Unavailable", })); - const executor = yield* buildExecutor(baseUrl); + const executor = yield* buildExecutorForOpenApiServer(server); const exit = yield* executor.tools .invoke("f.things.listThings", {}, autoApprove) diff --git a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts index 340a9fd8c..54a10f1be 100644 --- a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts +++ b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts @@ -1,5 +1,6 @@ import { expect, layer } from "@effect/vitest"; import { Effect } from "effect"; +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { ConnectionId, @@ -14,20 +15,22 @@ import { SetSecretInput, definePlugin, } from "@executor-js/sdk"; -import { makeTestExecutorLayer, TestExecutor } from "@executor-js/sdk/testing"; +import { makeTestWorkspaceLayer, TestWorkspace } from "@executor-js/sdk/testing"; +import { + addOpenApiTestSource, + serveOpenApiHttpApiTestServer, +} from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; import { OpenApiSourceBindingInput } from "./types"; -const specJson = JSON.stringify({ - openapi: "3.0.0", - info: { title: "Scoped Usage", version: "1.0.0" }, - paths: { - "/ping": { - get: { operationId: "ping", responses: { "200": { description: "ok" } } }, - }, - }, -}); +const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping"), +); +const UsageApi = HttpApi.make("usageScopeIsolation").add(PingGroup); +const UsageGroupLive = HttpApiBuilder.group(UsageApi, "default", (handlers) => + handlers.handle("ping", () => Effect.void), +); const memorySecretsPlugin = definePlugin(() => { const store = new Map(); @@ -74,116 +77,124 @@ const orgB = Scope.make({ }); const plugins = [memorySecretsPlugin(), connectionProviderPlugin(), openApiPlugin()] as const; -layer(makeTestExecutorLayer({ scopes: [orgA], plugins }), { timeout: "15 seconds" })( +layer(makeTestWorkspaceLayer({ scopes: [orgA], plugins }), { timeout: "15 seconds" })( "OpenAPI usage scope isolation", (it) => { it.effect("secrets.usages does not expose binding rows outside the scope stack", () => - Effect.gen(function* () { - const { config } = yield* TestExecutor; - const orgAExec = yield* createExecutor({ ...config, scopes: [orgA], plugins }); - const orgBExec = yield* createExecutor({ ...config, scopes: [orgB], plugins }); - const secretId = SecretId.make("org-a-api-key"); - - yield* orgAExec.secrets.set( - SetSecretInput.make({ - id: secretId, - scope: orgA.id, - name: "Org A API Key", - value: "secret", - provider: "memory", - }), - ); - yield* orgBExec.secrets.set( - SetSecretInput.make({ - id: secretId, - scope: orgB.id, - name: "Org B API Key", - value: "different-secret", - provider: "memory", - }), - ); - yield* orgAExec.openapi.addSpec({ - spec: specJson, - scope: String(orgA.id), - namespace: "secret_private_source", - baseUrl: "http://example.com", - }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "secret_private_source", - sourceScope: orgA.id, - scope: orgA.id, - slot: "header:authorization", - value: { kind: "secret", secretId }, - }), - ); - - const usages = yield* orgBExec.secrets.usages(secretId); - expect(usages).toEqual([]); - }), + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOpenApiHttpApiTestServer({ + api: UsageApi, + handlersLayer: UsageGroupLive, + }); + const { config } = yield* TestWorkspace; + const orgAExec = yield* createExecutor({ ...config, scopes: [orgA], plugins }); + const orgBExec = yield* createExecutor({ ...config, scopes: [orgB], plugins }); + const secretId = SecretId.make("org-a-api-key"); + + yield* orgAExec.secrets.set( + SetSecretInput.make({ + id: secretId, + scope: orgA.id, + name: "Org A API Key", + value: "secret", + provider: "memory", + }), + ); + yield* orgBExec.secrets.set( + SetSecretInput.make({ + id: secretId, + scope: orgB.id, + name: "Org B API Key", + value: "different-secret", + provider: "memory", + }), + ); + yield* addOpenApiTestSource(orgAExec, server, { + scope: String(orgA.id), + namespace: "secret_private_source", + }); + yield* orgAExec.openapi.setSourceBinding( + OpenApiSourceBindingInput.make({ + sourceId: "secret_private_source", + sourceScope: orgA.id, + scope: orgA.id, + slot: "header:authorization", + value: { kind: "secret", secretId }, + }), + ); + + const usages = yield* orgBExec.secrets.usages(secretId); + expect(usages).toEqual([]); + }), + ), ); it.effect("connections.usages does not expose binding rows outside the scope stack", () => - Effect.gen(function* () { - const { config } = yield* TestExecutor; - const orgAExec = yield* createExecutor({ ...config, scopes: [orgA], plugins }); - const orgBExec = yield* createExecutor({ ...config, scopes: [orgB], plugins }); - const connectionId = ConnectionId.make("org-a-connection"); - - yield* orgAExec.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: orgA.id, - provider: "test-oauth", - identityLabel: "Org A connection", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("org-a-connection-access"), - name: "Org A access", - value: "access", + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOpenApiHttpApiTestServer({ + api: UsageApi, + handlersLayer: UsageGroupLive, + }); + const { config } = yield* TestWorkspace; + const orgAExec = yield* createExecutor({ ...config, scopes: [orgA], plugins }); + const orgBExec = yield* createExecutor({ ...config, scopes: [orgB], plugins }); + const connectionId = ConnectionId.make("org-a-connection"); + + yield* orgAExec.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: orgA.id, + provider: "test-oauth", + identityLabel: "Org A connection", + accessToken: TokenMaterial.make({ + secretId: SecretId.make("org-a-connection-access"), + name: "Org A access", + value: "access", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - yield* orgBExec.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: orgB.id, - provider: "test-oauth", - identityLabel: "Org B connection", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("org-b-connection-access"), - name: "Org B access", - value: "access", + ); + yield* orgBExec.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: orgB.id, + provider: "test-oauth", + identityLabel: "Org B connection", + accessToken: TokenMaterial.make({ + secretId: SecretId.make("org-b-connection-access"), + name: "Org B access", + value: "access", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - yield* orgAExec.openapi.addSpec({ - spec: specJson, - scope: String(orgA.id), - namespace: "connection_private_source", - baseUrl: "http://example.com", - }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "connection_private_source", - sourceScope: orgA.id, - scope: orgA.id, - slot: "oauth:connection", - value: { kind: "connection", connectionId }, - }), - ); - - const usages = yield* orgBExec.connections.usages(connectionId); - expect(usages).toEqual([]); - }), + ); + + yield* addOpenApiTestSource(orgAExec, server, { + scope: String(orgA.id), + namespace: "connection_private_source", + }); + yield* orgAExec.openapi.setSourceBinding( + OpenApiSourceBindingInput.make({ + sourceId: "connection_private_source", + sourceScope: orgA.id, + scope: orgA.id, + slot: "oauth:connection", + value: { kind: "connection", connectionId }, + }), + ); + + const usages = yield* orgBExec.connections.usages(connectionId); + expect(usages).toEqual([]); + }), + ), ); }, ); diff --git a/packages/plugins/openapi/src/testing.test.ts b/packages/plugins/openapi/src/testing.test.ts new file mode 100644 index 000000000..892385e12 --- /dev/null +++ b/packages/plugins/openapi/src/testing.test.ts @@ -0,0 +1,52 @@ +import { expect, layer } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { OpenApiEchoTestServer } from "./testing"; + +const TestLayer = OpenApiEchoTestServer.layerWithOAuth().pipe( + Layer.provideMerge(OAuthTestServer.layer()), +); + +const ItemsResponse = Schema.Array( + Schema.Struct({ + id: Schema.Number, + name: Schema.String, + }), +); +const decodeItemsResponse = Schema.decodeUnknownEffect(ItemsResponse); + +layer(TestLayer, { timeout: "15 seconds" })("OpenAPI testing fixtures", (it) => { + it.effect("serves an OAuth-protected HttpApi-backed OpenAPI echo server", () => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + const server = yield* OpenApiEchoTestServer; + + const unauthorized = yield* HttpClient.execute(HttpClientRequest.get("/items")).pipe( + Effect.provide(server.httpClientLayer), + ); + expect(unauthorized.status).toBe(401); + + const token = yield* oauth.completeAuthorizationCodeTokenFlow({ + resource: server.baseUrl, + scopes: ["read"], + }); + const authorized = yield* HttpClient.execute( + HttpClientRequest.get("/items").pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${token.accessToken}`), + ), + ).pipe(Effect.provide(server.httpClientLayer)); + + expect(authorized.status).toBe(200); + const items = yield* authorized.json.pipe(Effect.flatMap(decodeItemsResponse)); + expect(items).toEqual([ + { id: 1, name: "Widget" }, + { id: 2, name: "Gadget" }, + ]); + + const requests = yield* server.requests; + expect(requests.map((request) => request.path)).toEqual(["/items", "/items"]); + }), + ); +}); diff --git a/packages/plugins/openapi/src/testing/index.ts b/packages/plugins/openapi/src/testing/index.ts index cdab74eb7..fbae84dc4 100644 --- a/packages/plugins/openapi/src/testing/index.ts +++ b/packages/plugins/openapi/src/testing/index.ts @@ -1,5 +1,20 @@ -import { Context, Data, Effect, Layer, Predicate, Schema } from "effect"; -import { HttpClient, HttpServer } from "effect/unstable/http"; +import { Context, Data, Effect, Layer, Predicate, Ref, Schema, Scope } from "effect"; +import { + HttpClient, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiGroup, + OpenApi, +} from "effect/unstable/httpapi"; +import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; +import type { ScopeId } from "@executor-js/sdk/core"; +import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; export class OpenApiTestServerAddressError extends Data.TaggedError( "OpenApiTestServerAddressError", @@ -11,76 +26,595 @@ export class OpenApiTestServerSpecError extends Data.TaggedError("OpenApiTestSer readonly cause: unknown; }> {} -export interface OpenApiTestServerOptions { - readonly spec: unknown; -} - export interface OpenApiTestServerShape { readonly baseUrl: string; readonly specJson: string; readonly httpClientLayer: Layer.Layer; } -const decodeJsonSpec = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +export interface OpenApiHttpApiTestServerOptions { + readonly api: HttpApi.Any; + readonly handlersLayer: Layer.Layer; + readonly specPath?: `/${string}`; + readonly transformSpec?: (spec: Record) => Record; + readonly captureSpecRequest?: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly guardSpecRequest?: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} + +export interface OpenApiHttpApiTestServerShape extends OpenApiTestServerShape { + readonly specUrl: string; +} + +export interface MutableOpenApiSpecTestServerShape extends OpenApiTestServerShape { + readonly specUrl: string; + readonly setApi: (api: HttpApi.Any) => Effect.Effect; + readonly requestCount: Effect.Effect; +} + +export interface OpenApiTestRequest { + readonly method: string; + readonly url: string; + readonly path: string; + readonly headers: Readonly>; + readonly body: string; +} + +export interface OpenApiEchoTestServerOptions { + readonly transformSpec?: (spec: Record) => Record; + readonly oauth2?: { + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly scopes?: Readonly>; + readonly validateAuthorization?: (authorization: string | null) => Effect.Effect; + readonly wwwAuthenticate?: string; + }; +} + +export interface OpenApiEchoTestServerShape extends OpenApiTestServerShape { + readonly specUrl: string; + readonly requests: Effect.Effect; + readonly clearRequests: Effect.Effect; +} + +export type OpenApiTestSourceOptions = Omit & { + readonly baseUrl?: string | null; +}; + +export type OpenApiHttpApiTestSourceOptions = Omit & { + readonly specBaseUrl?: string; + readonly transformSpec?: (spec: Record) => Record; +}; + +export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit< + OpenApiHttpApiTestSourceOptions, + "scope" | "credentialTargetScope" +> & { + readonly targetScope: ScopeId; + readonly credentialTargetScope?: ScopeId; +}; + +export type OpenApiTestSourceExecutor = { + readonly openapi: Pick; +}; + +export interface OpenApiTestSpecOptions { + readonly baseUrl?: string; + readonly transformSpec?: (spec: Record) => Record; +} + +export const makeOpenApiTestSpecJson = ( + api: HttpApi.Any, + options: OpenApiTestSpecOptions = {}, +): string => { + const annotations = OpenApi.annotations({ + ...(options.baseUrl !== undefined ? { servers: [{ url: options.baseUrl }] } : {}), + transform: options.transformSpec, + }); + const annotated = (api as HttpApi.AnyWithProps).annotateMerge(annotations); + return JSON.stringify(OpenApi.fromApi(annotated)); +}; + +export const makeOpenApiTestSourceConfig = ( + server: OpenApiTestServerShape, + options: OpenApiTestSourceOptions, +): OpenApiSpecConfig => { + const { baseUrl, ...rest } = options; + return { + ...rest, + spec: server.specJson, + ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? server.baseUrl }), + }; +}; + +export const addOpenApiTestSource = ( + executor: OpenApiTestSourceExecutor, + server: OpenApiTestServerShape, + options: OpenApiTestSourceOptions, +) => executor.openapi.addSpec(makeOpenApiTestSourceConfig(server, options)); + +export const makeOpenApiHttpApiTestSourceConfig = ( + api: HttpApi.Any, + options: OpenApiHttpApiTestSourceOptions, +): OpenApiSpecConfig => { + const { specBaseUrl, transformSpec, ...config } = options; + return { + ...config, + spec: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }), + }; +}; + +export const addOpenApiHttpApiTestSource = ( + executor: OpenApiTestSourceExecutor, + api: HttpApi.Any, + options: OpenApiHttpApiTestSourceOptions, +) => executor.openapi.addSpec(makeOpenApiHttpApiTestSourceConfig(api, options)); + +export const makeOpenApiHttpApiTestAddSpecPayload = ( + api: HttpApi.Any, + options: OpenApiHttpApiTestAddSpecPayloadOptions, +) => { + const { targetScope, credentialTargetScope, ...sourceOptions } = options; + const config = makeOpenApiHttpApiTestSourceConfig(api, { + ...sourceOptions, + scope: String(targetScope), + ...(credentialTargetScope !== undefined + ? { credentialTargetScope: String(credentialTargetScope) } + : {}), + }); + return { + targetScope, + spec: config.spec, + namespace: config.namespace, + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + ...(config.queryParams !== undefined ? { queryParams: config.queryParams } : {}), + ...(config.oauth2 !== undefined ? { oauth2: config.oauth2 } : {}), + ...(credentialTargetScope !== undefined ? { credentialTargetScope } : {}), + ...(config.specFetchCredentials !== undefined + ? { specFetchCredentials: config.specFetchCredentials } + : {}), + }; +}; + +export const makeOpenApiHttpApiTestSpecPayload = ( + api: HttpApi.Any, + options: OpenApiTestSpecOptions = {}, +) => ({ + spec: makeOpenApiTestSpecJson(api, options), +}); const isJsonObject = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); -export const openApiSpecJsonWithServer = ( - spec: unknown, +const OpenApiEchoItem = Schema.Struct({ + id: Schema.Number, + name: Schema.String, +}); + +const OpenApiEchoHeaders = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-static": Schema.optional(Schema.String), +}); + +const OpenApiEchoMessage = Schema.Struct({ + message: Schema.String, + suffix: Schema.optional(Schema.String), + path: Schema.String, +}); + +const OpenApiEchoItemsGroup = HttpApiGroup.make("items") + .add( + HttpApiEndpoint.get("listItems", "/items", { + success: Schema.Array(OpenApiEchoItem), + }), + ) + .add( + HttpApiEndpoint.get("echoHeaders", "/echo-headers", { + success: OpenApiEchoHeaders, + }), + ); + +const OpenApiEchoGroup = HttpApiGroup.make("echo").add( + HttpApiEndpoint.get("echoMessage", "/echo/:message", { + params: Schema.Struct({ message: Schema.String }), + query: Schema.Struct({ suffix: Schema.optional(Schema.String) }), + success: OpenApiEchoMessage, + }), +); + +const OpenApiEchoApi = HttpApi.make("executorOpenApiTest") + .add(OpenApiEchoItemsGroup) + .add(OpenApiEchoGroup) + .annotateMerge( + OpenApi.annotations({ + title: "Executor OpenAPI Test Server", + version: "1.0.0", + }), + ); + +const openApiSpecJsonFromHttpApi = ( + api: HttpApi.Any, baseUrl: string, + transformSpec?: (spec: Record) => Record, ): Effect.Effect => + Effect.try({ + try: () => makeOpenApiTestSpecJson(api, { baseUrl, transformSpec }), + catch: (cause) => new OpenApiTestServerSpecError({ cause }), + }); + +export const serveOpenApiHttpApiTestServer = ( + options: OpenApiHttpApiTestServerOptions, +): Effect.Effect< + OpenApiHttpApiTestServerShape, + OpenApiTestServerAddressError | OpenApiTestServerSpecError, + Scope.Scope +> => Effect.gen(function* () { - const parsed = - typeof spec === "string" - ? yield* decodeJsonSpec(spec).pipe( - Effect.mapError((cause) => new OpenApiTestServerSpecError({ cause })), - ) - : spec; - const withServer = isJsonObject(parsed) - ? { - ...parsed, - servers: [{ url: baseUrl }], - } - : parsed; - return yield* Effect.try({ - try: () => JSON.stringify(withServer), - catch: (cause) => new OpenApiTestServerSpecError({ cause }), + const specPath = options.specPath ?? "/spec.json"; + let specJson = ""; + const SpecRoute = HttpRouter.addAll([ + HttpRouter.route( + "GET", + specPath, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (options.captureSpecRequest) { + yield* options.captureSpecRequest(request); + } + const guardResponse = options.guardSpecRequest + ? yield* options.guardSpecRequest(request) + : null; + if (guardResponse) return guardResponse; + return HttpServerResponse.text(specJson, { + status: 200, + contentType: "application/json", + }); + }), + ), + ]); + const ApiLive = HttpApiBuilder.layer(options.api as HttpApi.AnyWithProps).pipe( + Layer.provide(options.handlersLayer), + ); + const ServerLayer = HttpRouter.serve(Layer.mergeAll(ApiLive, SpecRoute), { + disableListenLog: true, + disableLogger: true, }); + const server = yield* serveTestHttpServerLayer(ServerLayer).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "TestHttpServerAddressError") + ? new OpenApiTestServerAddressError({ address: error.address }) + : new OpenApiTestServerSpecError({ cause: error.cause }), + ), + ); + specJson = yield* openApiSpecJsonFromHttpApi( + options.api, + server.baseUrl, + options.transformSpec, + ); + + return { + baseUrl: server.baseUrl, + specUrl: server.url(specPath), + specJson, + httpClientLayer: server.httpClientLayer, + }; }); -export const makeOpenApiTestServer = ( - options: OpenApiTestServerOptions, -): Effect.Effect< - OpenApiTestServerShape, +export class OpenApiHttpApiTestServer extends Context.Service< + OpenApiHttpApiTestServer, + OpenApiHttpApiTestServerShape +>()("@executor-js/plugin-openapi/testing/OpenApiHttpApiTestServer") { + static readonly layer = (options: OpenApiHttpApiTestServerOptions) => + Layer.effect(OpenApiHttpApiTestServer, serveOpenApiHttpApiTestServer(options)); +} + +export const serveMutableOpenApiSpecTestServer = (options: { + readonly initialApi: HttpApi.Any; + readonly specPath?: `/${string}`; + readonly transformSpec?: (spec: Record) => Record; +}): Effect.Effect< + MutableOpenApiSpecTestServerShape, OpenApiTestServerAddressError | OpenApiTestServerSpecError, - HttpClient.HttpClient | HttpServer.HttpServer + Scope.Scope > => Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (!Predicate.isTagged(address, "TcpAddress")) { - return yield* new OpenApiTestServerAddressError({ address }); - } + const specPath = options.specPath ?? "/spec.json"; + const specJson = yield* Ref.make(""); + const requests = yield* Ref.make(0); + const SpecRoute = HttpRouter.addAll([ + HttpRouter.route( + "GET", + specPath, + Effect.gen(function* () { + yield* Ref.update(requests, (count) => count + 1); + const current = yield* Ref.get(specJson); + return HttpServerResponse.text(current, { + status: 200, + contentType: "application/json", + }); + }), + ), + ]); + const server = yield* serveTestHttpServerLayer( + HttpRouter.serve(SpecRoute, { + disableListenLog: true, + disableLogger: true, + }), + ).pipe( + Effect.mapError((error) => + Predicate.isTagged(error, "TestHttpServerAddressError") + ? new OpenApiTestServerAddressError({ address: error.address }) + : new OpenApiTestServerSpecError({ cause: error.cause }), + ), + ); + const renderSpec = (api: HttpApi.Any) => + openApiSpecJsonFromHttpApi(api, server.baseUrl, options.transformSpec); + yield* Ref.set(specJson, yield* renderSpec(options.initialApi)); + + return { + baseUrl: server.baseUrl, + specUrl: server.url(specPath), + specJson: yield* Ref.get(specJson), + httpClientLayer: server.httpClientLayer, + setApi: (api) => renderSpec(api).pipe(Effect.flatMap((next) => Ref.set(specJson, next))), + requestCount: Ref.get(requests), + }; + }); - const client = yield* HttpClient.HttpClient; - const baseUrl = `http://127.0.0.1:${address.port}`; - const specJson = yield* openApiSpecJsonWithServer(options.spec, baseUrl); +const openApiOperationMethods = new Set([ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", +]); + +const withOAuth2Security = + (oauth2: NonNullable) => + (spec: Record): Record => { + const scopes = oauth2.scopes ?? { read: "Read test resources" }; + const security = [{ oauth2: Object.keys(scopes) }]; + const paths = isJsonObject(spec.paths) + ? Object.fromEntries( + Object.entries(spec.paths).map(([path, pathItem]) => [ + path, + isJsonObject(pathItem) + ? Object.fromEntries( + Object.entries(pathItem).map(([method, operation]) => [ + method, + openApiOperationMethods.has(method) && isJsonObject(operation) + ? { ...operation, security } + : operation, + ]), + ) + : pathItem, + ]), + ) + : spec.paths; + const components = isJsonObject(spec.components) ? spec.components : {}; + const securitySchemes = isJsonObject(components.securitySchemes) + ? components.securitySchemes + : {}; return { - baseUrl, + ...spec, + paths, + components: { + ...components, + securitySchemes: { + ...securitySchemes, + oauth2: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth2.authorizationUrl, + tokenUrl: oauth2.tokenUrl, + scopes, + }, + }, + }, + }, + }, + }; + }; + +const composeSpecTransforms = + ( + ...transforms: readonly ( + | ((spec: Record) => Record) + | undefined + )[] + ) => + (spec: Record): Record => + transforms.reduce((current, transform) => (transform ? transform(current) : current), spec); + +const recordOpenApiRequest = ( + requests: Ref.Ref, + request: HttpServerRequest.HttpServerRequest, +) => + Effect.gen(function* () { + const url = new URL(request.url, "http://executor.test"); + const body = yield* request.text.pipe(Effect.catch(() => Effect.succeed(""))); + yield* Ref.update(requests, (all) => [ + ...all, + { + method: request.method, + url: request.url, + path: url.pathname, + headers: request.headers, + body, + }, + ]); + return request; + }); + +const captureOpenApiRequest = (requests: Ref.Ref) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return yield* recordOpenApiRequest(requests, request); + }); + +const openApiUnauthorizedResponse = ( + options: OpenApiEchoTestServerOptions, + authorization: string | null, +): Effect.Effect => + options.oauth2?.validateAuthorization + ? options.oauth2.validateAuthorization(authorization).pipe( + Effect.map((accepted) => + accepted + ? null + : HttpServerResponse.jsonUnsafe( + { error: "invalid_token" }, + options.oauth2?.wwwAuthenticate + ? { + status: 401, + headers: { + "www-authenticate": options.oauth2.wwwAuthenticate, + }, + } + : { status: 401 }, + ), + ), + ) + : Effect.succeed(null); + +const makeOpenApiEchoItemsGroupLive = ( + requests: Ref.Ref, + options: OpenApiEchoTestServerOptions, +) => + HttpApiBuilder.group(OpenApiEchoApi, "items", (handlers) => + handlers + .handle("listItems", () => + Effect.gen(function* () { + const request = yield* captureOpenApiRequest(requests); + const unauthorized = yield* openApiUnauthorizedResponse( + options, + request.headers.authorization ?? null, + ); + if (unauthorized) return unauthorized; + return [ + OpenApiEchoItem.make({ id: 1, name: "Widget" }), + OpenApiEchoItem.make({ id: 2, name: "Gadget" }), + ]; + }), + ) + .handle("echoHeaders", () => + Effect.gen(function* () { + const request = yield* captureOpenApiRequest(requests); + const unauthorized = yield* openApiUnauthorizedResponse( + options, + request.headers.authorization ?? null, + ); + if (unauthorized) return unauthorized; + return OpenApiEchoHeaders.make({ + authorization: request.headers.authorization, + "x-static": request.headers["x-static"], + }); + }), + ), + ); + +const makeOpenApiEchoGroupLive = ( + requests: Ref.Ref, + options: OpenApiEchoTestServerOptions, +) => + HttpApiBuilder.group(OpenApiEchoApi, "echo", (handlers) => + handlers.handle("echoMessage", ({ params, query }) => + Effect.gen(function* () { + const request = yield* captureOpenApiRequest(requests); + const unauthorized = yield* openApiUnauthorizedResponse( + options, + request.headers.authorization ?? null, + ); + if (unauthorized) return unauthorized; + const path = `/echo/${encodeURIComponent(params.message)}`; + return OpenApiEchoMessage.make({ + message: params.message, + ...(query.suffix ? { suffix: query.suffix } : {}), + path, + }); + }), + ), + ); + +export const serveOpenApiEchoTestServer = ( + options: OpenApiEchoTestServerOptions = {}, +): Effect.Effect< + OpenApiEchoTestServerShape, + OpenApiTestServerAddressError | OpenApiTestServerSpecError, + Scope.Scope +> => + Effect.gen(function* () { + const requests = yield* Ref.make([]); + let specJson = ""; + const server = yield* serveOpenApiHttpApiTestServer({ + api: OpenApiEchoApi, + handlersLayer: Layer.mergeAll( + makeOpenApiEchoItemsGroupLive(requests, options), + makeOpenApiEchoGroupLive(requests, options), + ), + transformSpec: composeSpecTransforms( + options.oauth2 ? withOAuth2Security(options.oauth2) : undefined, + options.transformSpec, + ), + captureSpecRequest: (request) => recordOpenApiRequest(requests, request).pipe(Effect.asVoid), + }); + specJson = server.specJson; + + return { + baseUrl: server.baseUrl, + specUrl: server.specUrl, specJson, - httpClientLayer: Layer.succeed(HttpClient.HttpClient, client), + httpClientLayer: server.httpClientLayer, + requests: Ref.get(requests), + clearRequests: Ref.set(requests, []), }; }); -export class OpenApiTestServer extends Context.Service()( - "@executor-js/plugin-openapi/testing/OpenApiTestServer", -) { - static readonly layer = (options: OpenApiTestServerOptions) => - Layer.effect(OpenApiTestServer, makeOpenApiTestServer(options)); +export const serveOpenApiEchoTestServerWithOAuth = ( + options: Omit & { + readonly scopes?: Readonly>; + readonly wwwAuthenticate?: string; + } = {}, +) => + Effect.gen(function* () { + const oauth = yield* OAuthTestServer; + return yield* serveOpenApiEchoTestServer({ + transformSpec: options.transformSpec, + oauth2: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: options.scopes, + validateAuthorization: oauth.acceptsAuthorizationHeader, + wwwAuthenticate: options.wwwAuthenticate, + }, + }); + }); + +export class OpenApiEchoTestServer extends Context.Service< + OpenApiEchoTestServer, + OpenApiEchoTestServerShape +>()("@executor-js/plugin-openapi/testing/OpenApiEchoTestServer") { + static readonly layer = (options?: OpenApiEchoTestServerOptions) => + Layer.effect(OpenApiEchoTestServer, serveOpenApiEchoTestServer(options)); + + static readonly layerWithOAuth = ( + options?: Omit & { + readonly scopes?: Readonly>; + readonly wwwAuthenticate?: string; + }, + ) => Layer.effect(OpenApiEchoTestServer, serveOpenApiEchoTestServerWithOAuth(options)); } export const TestLayers = { - server: OpenApiTestServer.layer, + httpApi: OpenApiHttpApiTestServer.layer, + echo: OpenApiEchoTestServer.layer, + echoWithOAuth: OpenApiEchoTestServer.layerWithOAuth, };