// --------------------------------------------------------------------------- // Local app × OAuth — real HTTP end-to-end (v2) // --------------------------------------------------------------------------- // // Drives the real LocalApi (core + mcp groups) against a real in-process OAuth // test server. Every layer between the test and the SDK is real: // // test → HttpApiClient → in-process webHandler → LocalApi // → OAuthHandlers → executor.oauth.{probe,createClient,start} // → OAuthTestServer (AS metadata, protected-resource metadata, DCR, // /authorize → login, /token) // // v2: OAuth is a credential mechanism on the core surface (`executor.oauth`), // not a plugin-specific MCP handoff. `probe` (RFC 8414 / OIDC discovery), // `createClient`, and `start`/`complete` (milestone 2) are all implemented. This // test asserts the live discovery path AND that `start` returns an authorization // redirect (PKCE + correlation state) over the real HTTP boundary. // // Single workspace: local binds one tenant per project (`${folder}-${hash}`) // plus a fixed subject, so owner: "org" connections file at the tenant. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; 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 } from "effect"; import { addGroup, observabilityMiddleware } from "@executor-js/api"; import { CoreHandlers, ExecutionEngineService, ExecutorService, collectTables, } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, Subject, Tenant, 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"; import { ErrorCaptureLive } from "./observability"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; // Shape of the test API: core (incl. the oauth group) + mcp group, with // InternalError surfaced at the top level so `observabilityMiddleware` can land // its typed-error bridge on every endpoint. const TestApi = addGroup(McpGroup); type TestApiShape = typeof TestApi extends HttpApi.HttpApi ? HttpApiClient.Client : never; // --------------------------------------------------------------------------- // In-process local API harness — tmpdir SQLite + minimal plugin set. // --------------------------------------------------------------------------- const TEST_BASE_URL = "http://local.test"; interface Harness { readonly fetch: typeof globalThis.fetch; readonly dispose: () => Promise; } const startHarness = async (tmpDir: string): Promise => { const plugins = [ mcpPlugin({ dangerouslyAllowStdioMCP: false }), fileSecretsPlugin({ directory: tmpDir }), ] as const; const sqlite = await createSqliteFumaDb({ tables: collectTables(), namespace: "executor_local_test", path: join(tmpDir, "data.db"), }); const executor = await Effect.runPromise( createExecutor({ tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — required now that the localhost default is // gone; the local daemon serves `/api/oauth/callback` on the web origin. redirectUri: "http://localhost:4788/api/oauth/callback", }), ); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), }); const TestObservability = observabilityMiddleware(TestApi); const TestApiBase = HttpApiBuilder.layer(TestApi).pipe( Layer.provide(CoreHandlers), Layer.provide(McpHandlers), Layer.provide(TestObservability), Layer.provide(ErrorCaptureLive), ); const pluginExtensions = Layer.succeed(McpExtensionService)(executor.mcp); const { handler: webHandler, dispose: disposeHandler } = HttpRouter.toWebHandler( TestApiBase.pipe( Layer.provideMerge(pluginExtensions), Layer.provideMerge(Layer.succeed(ExecutorService)(executor)), Layer.provideMerge(Layer.succeed(ExecutionEngineService)(engine)), Layer.provideMerge(HttpServer.layerServices), Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), ), ); return { fetch: ((input: RequestInfo | URL, init?: RequestInit) => webHandler( input instanceof Request ? input : new Request(input, init), )) as typeof globalThis.fetch, dispose: async () => { await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); await Effect.runPromise( Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), ); await sqlite.close(); }, }; }; // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- let tmpDir: string; let harness: Harness; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), "executor-local-mcp-")); harness = await startHarness(tmpDir); }); afterAll(async () => { await harness.dispose(); rmSync(tmpDir, { recursive: true, force: true }); }); // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- describe("local oauth (real OAuth discovery + stubbed start)", () => { it.effect( "probe discovers the authorization server; start returns an authorization redirect", () => Effect.scoped( Effect.gen(function* () { const oauth = yield* serveOAuthTestServer(); const clientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), ); 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; // probe — real RFC 8414 / OIDC discovery against the test server. const probed = yield* run((client) => client.oauth.probe({ payload: { url: oauth.mcpResourceUrl } }), ); expect(probed.authorizationUrl).toBe(oauth.authorizationEndpoint); expect(probed.tokenUrl).toBe(oauth.tokenEndpoint); // createClient — register an owner-scoped OAuth app for the start flow. const slug = `mcp-oauth2-${randomBytes(4).toString("hex")}`; const created = yield* run((client) => client.oauth.createClient({ payload: { owner: "org", slug: OAuthClientSlug.make(slug), authorizationUrl: oauth.authorizationEndpoint, tokenUrl: oauth.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }, }), ); expect(String(created.client)).toBe(slug); // start — milestone 2 wired: authorization_code returns a redirect to // the authorization server (with PKCE + a correlation state). const started = yield* run((client) => client.oauth.start({ payload: { client: OAuthClientSlug.make(slug), clientOwner: "org", owner: "org", name: ConnectionName.make("main"), integration: IntegrationSlug.make("mcp_remote"), template: AuthTemplateSlug.make("oauth"), }, }), ); expect(started.status).toBe("redirect"); const redirect = started as Extract; expect(redirect.authorizationUrl).toContain(oauth.authorizationEndpoint); expect(redirect.authorizationUrl).toContain( encodeURIComponent("http://localhost:4788/api/oauth/callback"), ); expect(redirect.state).toBeTruthy(); }), ), 30_000, ); });