Adapt the server integration test to the new standalone OAuth endpoints that store secrets without creating a source. Add workspace execution environment tests for GraphQL tool loading and invocation. Update auth service and control-plane runtime tests for the refactored APIs.
Unified · Split
| @@ -802,7 +802,7 @@ describe("local-executor-server", () => { · 801 unmodified lines | ||
| 802 | 802 | }), |
| 803 | 803 | ); |
| 804 | 804 | |
| 805 | − it.scoped("starts MCP OAuth from the sources API and completes the popup callback flow", () => | |
| 805 | + it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => | |
| 806 | 806 | Effect.gen(function* () { |
| 807 | 807 | const oauthServer = yield* Effect.acquireRelease( |
| 808 | 808 | Effect.promise(() => startOAuthProtectedMcpServer()), |
| @@ -820,16 +820,15 @@ describe("local-executor-server", () => { · 11 unmodified lines | ||
| 820 | 820 | const installation = yield* bootstrapClient.local.installation({}); |
| 821 | 821 | |
| 822 | 822 | const startResponse = yield* Effect.promise(() => |
| 823 | − fetch(`${server.baseUrl}/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/mcp/oauth/start`, { | |
| 823 | + fetch(`${server.baseUrl}/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/oauth/source-auth/start`, { | |
| 824 | 824 | method: "POST", |
| 825 | 825 | headers: { |
| 826 | 826 | "content-type": "application/json", |
| 827 | 827 | "x-executor-account-id": installation.accountId, |
| 828 | 828 | }, |
| 829 | 829 | body: JSON.stringify({ |
| 830 | + provider: "mcp", | |
| 830 | 831 | endpoint: oauthServer.endpoint, |
| 831 | − name: "Axiom", | |
| 832 | − namespace: "axiom", | |
| 833 | 832 | transport: "auto", |
| 834 | 833 | }), |
| 835 | 834 | }), |
| @@ -837,29 +836,26 @@ describe("local-executor-server", () => { · 1 unmodified line | ||
| 837 | 836 | |
| 838 | 837 | assertTrue(startResponse.ok); |
| 839 | 838 | const started: { |
| 840 | − kind: "connected" | "oauth_required"; | |
| 841 | − source: { id: string; status: string; auth: { kind: string } }; | |
| 842 | − authorizationUrl?: string; | |
| 839 | + sessionId: string; | |
| 840 | + authorizationUrl: string; | |
| 843 | 841 | } = yield* Effect.promise(() => startResponse.json() as Promise<{ |
| 844 | − kind: "connected" | "oauth_required"; | |
| 845 | − source: { id: string; status: string; auth: { kind: string } }; | |
| 846 | − authorizationUrl?: string; | |
| 842 | + sessionId: string; | |
| 843 | + authorizationUrl: string; | |
| 847 | 844 | }>); |
| 848 | 845 | |
| 849 | − expect(started.kind).toBe("oauth_required"); | |
| 850 | − expect(started.source.status).toBe("auth_required"); | |
| 846 | + expect(started.sessionId).toBeTruthy(); | |
| 851 | 847 | expect(started.authorizationUrl).toBeDefined(); |
| 852 | 848 | |
| 853 | 849 | const callbackResponse = yield* Effect.promise(() => |
| 854 | − fetch(started.authorizationUrl!, { | |
| 850 | + fetch(started.authorizationUrl, { | |
| 855 | 851 | redirect: "follow", |
| 856 | 852 | }), |
| 857 | 853 | ); |
| 858 | 854 | |
| 859 | 855 | assertTrue(callbackResponse.ok); |
| 860 | 856 | const callbackHtml = yield* Effect.promise(() => callbackResponse.text()); |
| 861 | − expect(callbackHtml).toContain("Source connected:"); | |
| 862 | − expect(callbackHtml).toContain("executor-v3:source-oauth-result"); | |
| 857 | + expect(callbackHtml).toContain("OAuth connected"); | |
| 858 | + expect(callbackHtml).toContain("executor-v3:oauth-result"); | |
| 863 | 859 | |
| 864 | 860 | const client = yield* createControlPlaneClient({ |
| 865 | 861 | baseUrl: server.baseUrl, |
| @@ -870,12 +866,12 @@ describe("local-executor-server", () => { · 4 unmodified lines | ||
| 870 | 866 | workspaceId: installation.workspaceId, |
| 871 | 867 | }, |
| 872 | 868 | }); |
| 873 | − const source = sources.find((candidate) => candidate.id === started.source.id); | |
| 874 | 869 | |
| 875 | − assertTrue(Boolean(source)); | |
| 870 | + expect(sources).toHaveLength(0); | |
| 876 | 871 | |
| 877 | − expect(source?.status).toBe("connected"); | |
| 878 | − expect(source?.auth.kind).toBe("oauth2"); | |
| 872 | + const secrets = yield* bootstrapClient.local.listSecrets({}); | |
| 873 | + expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true); | |
| 874 | + expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true); | |
| 879 | 875 | }), |
| 880 | 876 | ); |
| 881 | 877 | |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { createServer } from "node:http"; | |
| 2 | + | |
| 1 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 4 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| @@ -25,6 +27,80 @@ const makeRuntime = Effect.acquireRelease( · 21 unmodified lines | ||
| 25 | 27 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 26 | 28 | ); |
| 27 | 29 | |
| 30 | +type OpenApiSpecServer = { | |
| 31 | + baseUrl: string; | |
| 32 | + specUrl: string; | |
| 33 | + close: () => Promise<void>; | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const makeOpenApiSpecServer = Effect.acquireRelease( | |
| 37 | + Effect.promise<OpenApiSpecServer>( | |
| 38 | + () => | |
| 39 | + new Promise<OpenApiSpecServer>((resolve, reject) => { | |
| 40 | + const openApiDocument = JSON.stringify({ | |
| 41 | + openapi: "3.0.3", | |
| 42 | + info: { | |
| 43 | + title: "GitHub Test API", | |
| 44 | + version: "1.0.0", | |
| 45 | + }, | |
| 46 | + paths: { | |
| 47 | + "/repos/{owner}/{repo}": { | |
| 48 | + get: { | |
| 49 | + operationId: "repos/get-repo", | |
| 50 | + tags: ["repos"], | |
| 51 | + summary: "Get a repository", | |
| 52 | + responses: { | |
| 53 | + 200: { | |
| 54 | + description: "ok", | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | + }, | |
| 58 | + }, | |
| 59 | + }, | |
| 60 | + }); | |
| 61 | + | |
| 62 | + const server = createServer((request, response) => { | |
| 63 | + if (request.url !== "/openapi.json") { | |
| 64 | + response.statusCode = 404; | |
| 65 | + response.end(); | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + | |
| 69 | + response.statusCode = 200; | |
| 70 | + response.setHeader("content-type", "application/json"); | |
| 71 | + response.end(openApiDocument); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + server.once("error", reject); | |
| 75 | + server.listen(0, "127.0.0.1", () => { | |
| 76 | + const address = server.address(); | |
| 77 | + if (!address || typeof address === "string") { | |
| 78 | + reject(new Error("Failed to bind OpenAPI runtime test server")); | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + | |
| 82 | + const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 83 | + resolve({ | |
| 84 | + baseUrl, | |
| 85 | + specUrl: `${baseUrl}/openapi.json`, | |
| 86 | + close: () => | |
| 87 | + new Promise<void>((closeResolve, closeReject) => { | |
| 88 | + server.close((error) => { | |
| 89 | + if (error) { | |
| 90 | + closeReject(error); | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + | |
| 94 | + closeResolve(); | |
| 95 | + }); | |
| 96 | + }), | |
| 97 | + }); | |
| 98 | + }); | |
| 99 | + }), | |
| 100 | + ), | |
| 101 | + (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | |
| 102 | +); | |
| 103 | + | |
| 28 | 104 | const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => |
| 29 | 105 | Effect.either(effect).pipe( |
| 30 | 106 | Effect.flatMap((result) => |
| @@ -38,6 +114,7 @@ describe("control-plane-runtime", () => { · 7 unmodified lines | ||
| 38 | 114 | it.scoped("supports full CRUD flow over HTTP API", () => |
| 39 | 115 | Effect.gen(function* () { |
| 40 | 116 | const runtime = yield* makeRuntime; |
| 117 | + const openApiServer = yield* makeOpenApiSpecServer; | |
| 41 | 118 | |
| 42 | 119 | const createOrg = yield* withControlPlaneClient( |
| 43 | 120 | { runtime, accountId: "acc_1" }, |
| @@ -69,8 +146,8 @@ describe("control-plane-runtime", () => { · 25 unmodified lines | ||
| 69 | 146 | payload: { |
| 70 | 147 | name: "Github", |
| 71 | 148 | kind: "openapi", |
| 72 | − endpoint: "https://api.github.com", | |
| 73 | − specUrl: "https://api.github.com/openapi.json", | |
| 149 | + endpoint: openApiServer.baseUrl, | |
| 150 | + specUrl: openApiServer.specUrl, | |
| 74 | 151 | auth: { |
| 75 | 152 | kind: "none", |
| 76 | 153 | }, |
| @@ -115,6 +192,62 @@ describe("control-plane-runtime", () => { · 38 unmodified lines | ||
| 115 | 192 | }), |
| 116 | 193 | ); |
| 117 | 194 | |
| 195 | + it.scoped("discovers and connects an OpenAPI source through the HTTP API", () => | |
| 196 | + Effect.gen(function* () { | |
| 197 | + const runtime = yield* makeRuntime; | |
| 198 | + const openApiServer = yield* makeOpenApiSpecServer; | |
| 199 | + | |
| 200 | + const organization = yield* withControlPlaneClient( | |
| 201 | + { runtime, accountId: "acc_discover" }, | |
| 202 | + (client) => | |
| 203 | + client.organizations.create({ | |
| 204 | + payload: { name: "Discovery Org" }, | |
| 205 | + }), | |
| 206 | + ); | |
| 207 | + const workspace = yield* withControlPlaneClient( | |
| 208 | + { runtime, accountId: "acc_discover" }, | |
| 209 | + (client) => | |
| 210 | + client.workspaces.create({ | |
| 211 | + path: { organizationId: organization.id }, | |
| 212 | + payload: { name: "Discovery Workspace" }, | |
| 213 | + }), | |
| 214 | + ); | |
| 215 | + | |
| 216 | + const discovered = yield* withControlPlaneClient( | |
| 217 | + { runtime, accountId: "acc_discover" }, | |
| 218 | + (client) => | |
| 219 | + client.sources.discover({ | |
| 220 | + payload: { | |
| 221 | + url: openApiServer.specUrl, | |
| 222 | + }, | |
| 223 | + }), | |
| 224 | + ); | |
| 225 | + expect(discovered.detectedKind).toBe("openapi"); | |
| 226 | + expect(discovered.specUrl).toBe(openApiServer.specUrl); | |
| 227 | + expect(discovered.endpoint).toBe(openApiServer.baseUrl); | |
| 228 | + | |
| 229 | + const connected = yield* withControlPlaneClient( | |
| 230 | + { runtime, accountId: "acc_discover" }, | |
| 231 | + (client) => | |
| 232 | + client.sources.connect({ | |
| 233 | + path: { workspaceId: workspace.id }, | |
| 234 | + payload: { | |
| 235 | + kind: "openapi", | |
| 236 | + endpoint: discovered.endpoint, | |
| 237 | + specUrl: discovered.specUrl ?? openApiServer.specUrl, | |
| 238 | + name: discovered.name, | |
| 239 | + namespace: discovered.namespace, | |
| 240 | + auth: { kind: "none" }, | |
| 241 | + }, | |
| 242 | + }), | |
| 243 | + ); | |
| 244 | + expect(connected.kind).toBe("connected"); | |
| 245 | + expect(connected.source.kind).toBe("openapi"); | |
| 246 | + expect(connected.source.status).toBe("connected"); | |
| 247 | + expect(connected.source.specUrl).toBe(openApiServer.specUrl); | |
| 248 | + }), | |
| 249 | + ); | |
| 250 | + | |
| 118 | 251 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 119 | 252 | Effect.gen(function* () { |
| 120 | 253 | const runtime = yield* makeRuntime; |
| @@ -4,7 +4,7 @@ import type { Source } from "#schema"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | createTerminalSourceAuthSessionPatch, |
| 7 | − shouldPromptForOpenApiCredentialSetup, | |
| 7 | + shouldPromptForHttpCredentialSetup, | |
| 8 | 8 | } from "./source-auth-service"; |
| 9 | 9 | |
| 10 | 10 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| @@ -81,9 +81,9 @@ describe("source-auth-service", () => { · 70 unmodified lines | ||
| 81 | 81 | }); |
| 82 | 82 | }); |
| 83 | 83 | |
| 84 | − it("reuses existing non-none OpenAPI auth without prompting again", () => { | |
| 84 | + it("reuses existing non-none HTTP source auth without prompting again", () => { | |
| 85 | 85 | expect( |
| 86 | − shouldPromptForOpenApiCredentialSetup({ | |
| 86 | + shouldPromptForHttpCredentialSetup({ | |
| 87 | 87 | existing: makeExistingOpenApiSource({ |
| 88 | 88 | kind: "bearer", |
| 89 | 89 | headerName: "Authorization", |
| @@ -97,7 +97,7 @@ describe("source-auth-service", () => { · 7 unmodified lines | ||
| 97 | 97 | ).toBe(false); |
| 98 | 98 | |
| 99 | 99 | expect( |
| 100 | − shouldPromptForOpenApiCredentialSetup({ | |
| 100 | + shouldPromptForHttpCredentialSetup({ | |
| 101 | 101 | existing: makeExistingOpenApiSource({ |
| 102 | 102 | kind: "oauth2", |
| 103 | 103 | headerName: "Authorization", |
| @@ -115,11 +115,29 @@ describe("source-auth-service", () => { · 11 unmodified lines | ||
| 115 | 115 | ).toBe(false); |
| 116 | 116 | |
| 117 | 117 | expect( |
| 118 | − shouldPromptForOpenApiCredentialSetup({ | |
| 118 | + shouldPromptForHttpCredentialSetup({ | |
| 119 | 119 | existing: makeExistingOpenApiSource({ |
| 120 | 120 | kind: "none", |
| 121 | 121 | }), |
| 122 | 122 | }), |
| 123 | 123 | ).toBe(true); |
| 124 | + | |
| 125 | + expect( | |
| 126 | + shouldPromptForHttpCredentialSetup({ | |
| 127 | + existing: { | |
| 128 | + ...makeExistingOpenApiSource({ | |
| 129 | + kind: "bearer", | |
| 130 | + headerName: "Authorization", | |
| 131 | + prefix: "Bearer ", | |
| 132 | + token: { | |
| 133 | + providerId: "postgres", | |
| 134 | + handle: "sec_graphql", | |
| 135 | + }, | |
| 136 | + }), | |
| 137 | + kind: "graphql", | |
| 138 | + specUrl: null, | |
| 139 | + }, | |
| 140 | + }), | |
| 141 | + ).toBe(false); | |
| 124 | 142 | }); |
| 125 | 143 | }); |
| @@ -0,0 +1,151 @@ | ||
| 1 | +import { | |
| 2 | + createServer, | |
| 3 | + type IncomingMessage, | |
| 4 | + type ServerResponse, | |
| 5 | +} from "node:http"; | |
| 6 | + | |
| 7 | +import { describe, expect, it } from "vitest"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | + | |
| 10 | +import { discoverSource } from "./source-discovery"; | |
| 11 | + | |
| 12 | +type TestServer = { | |
| 13 | + url: string; | |
| 14 | + close: () => Promise<void>; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const withServer = async ( | |
| 18 | + handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void, | |
| 19 | +): Promise<TestServer> => { | |
| 20 | + const server = createServer(handler); | |
| 21 | + | |
| 22 | + await new Promise<void>((resolve, reject) => { | |
| 23 | + server.once("error", reject); | |
| 24 | + server.listen(0, "127.0.0.1", () => resolve()); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + const address = server.address(); | |
| 28 | + if (!address || typeof address === "string") { | |
| 29 | + throw new Error("Failed to resolve test server address"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + return { | |
| 33 | + url: `http://127.0.0.1:${address.port}`, | |
| 34 | + close: () => | |
| 35 | + new Promise<void>((resolve, reject) => { | |
| 36 | + server.close((error) => { | |
| 37 | + if (error) { | |
| 38 | + reject(error); | |
| 39 | + return; | |
| 40 | + } | |
| 41 | + resolve(); | |
| 42 | + }); | |
| 43 | + }), | |
| 44 | + }; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +describe("source-discovery", () => { | |
| 48 | + it("detects OpenAPI and infers bearer auth from security schemes", async () => { | |
| 49 | + const server = await withServer((request, response) => { | |
| 50 | + if (request.url !== "/openapi.json") { | |
| 51 | + response.statusCode = 404; | |
| 52 | + response.end(); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + | |
| 56 | + if (request.headers.authorization !== "Bearer top-secret") { | |
| 57 | + response.statusCode = 401; | |
| 58 | + response.setHeader("www-authenticate", 'Bearer realm="spec"'); | |
| 59 | + response.end("Unauthorized"); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + | |
| 63 | + response.statusCode = 200; | |
| 64 | + response.setHeader("content-type", "application/json"); | |
| 65 | + response.end(JSON.stringify({ | |
| 66 | + openapi: "3.0.3", | |
| 67 | + info: { | |
| 68 | + title: "Secure Example API", | |
| 69 | + version: "1.0.0", | |
| 70 | + }, | |
| 71 | + servers: [{ url: `${server.url}/api` }], | |
| 72 | + components: { | |
| 73 | + securitySchemes: { | |
| 74 | + bearerAuth: { | |
| 75 | + type: "http", | |
| 76 | + scheme: "bearer", | |
| 77 | + }, | |
| 78 | + }, | |
| 79 | + }, | |
| 80 | + security: [{ bearerAuth: [] }], | |
| 81 | + paths: { | |
| 82 | + "/widgets": { | |
| 83 | + get: { | |
| 84 | + operationId: "widgets/list", | |
| 85 | + responses: { | |
| 86 | + 200: { | |
| 87 | + description: "ok", | |
| 88 | + }, | |
| 89 | + }, | |
| 90 | + }, | |
| 91 | + }, | |
| 92 | + }, | |
| 93 | + })); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + try { | |
| 97 | + const result = await Effect.runPromise(discoverSource({ | |
| 98 | + url: `${server.url}/openapi.json`, | |
| 99 | + probeAuth: { | |
| 100 | + kind: "bearer", | |
| 101 | + token: "top-secret", | |
| 102 | + }, | |
| 103 | + })); | |
| 104 | + | |
| 105 | + expect(result.detectedKind).toBe("openapi"); | |
| 106 | + expect(result.authInference.suggestedKind).toBe("bearer"); | |
| 107 | + expect(result.authInference.supported).toBe(true); | |
| 108 | + expect(result.authInference.headerName).toBe("Authorization"); | |
| 109 | + expect(result.specUrl).toBe(`${server.url}/openapi.json`); | |
| 110 | + expect(result.endpoint).toBe(`${server.url}/api`); | |
| 111 | + expect(result.toolCount).toBe(1); | |
| 112 | + } finally { | |
| 113 | + await server.close(); | |
| 114 | + } | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it("detects GraphQL from successful introspection", async () => { | |
| 118 | + const server = await withServer((request, response) => { | |
| 119 | + if (request.url !== "/graphql" || request.method !== "POST") { | |
| 120 | + response.statusCode = 404; | |
| 121 | + response.end(); | |
| 122 | + return; | |
| 123 | + } | |
| 124 | + | |
| 125 | + response.statusCode = 200; | |
| 126 | + response.setHeader("content-type", "application/json"); | |
| 127 | + response.end(JSON.stringify({ | |
| 128 | + data: { | |
| 129 | + __schema: { | |
| 130 | + queryType: { | |
| 131 | + name: "Query", | |
| 132 | + }, | |
| 133 | + }, | |
| 134 | + }, | |
| 135 | + })); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + try { | |
| 139 | + const result = await Effect.runPromise(discoverSource({ | |
| 140 | + url: `${server.url}/graphql`, | |
| 141 | + })); | |
| 142 | + | |
| 143 | + expect(result.detectedKind).toBe("graphql"); | |
| 144 | + expect(result.confidence).toBe("high"); | |
| 145 | + expect(result.authInference.suggestedKind).toBe("none"); | |
| 146 | + expect(result.specUrl).toBeNull(); | |
| 147 | + } finally { | |
| 148 | + await server.close(); | |
| 149 | + } | |
| 150 | + }); | |
| 151 | +}); | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | +import { createServer } from "node:http"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | HttpApi, |
| @@ -13,6 +14,18 @@ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js · 8 unmodified lines | ||
| 13 | 14 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 14 | 15 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 15 | 16 | import { describe, expect, it } from "@effect/vitest"; |
| 17 | +import { | |
| 18 | + graphql, | |
| 19 | + GraphQLBoolean, | |
| 20 | + GraphQLID, | |
| 21 | + GraphQLInputObjectType, | |
| 22 | + GraphQLInt, | |
| 23 | + GraphQLList, | |
| 24 | + GraphQLNonNull, | |
| 25 | + GraphQLObjectType, | |
| 26 | + GraphQLSchema, | |
| 27 | + GraphQLString, | |
| 28 | +} from "graphql"; | |
| 16 | 29 | import { |
| 17 | 30 | AccountIdSchema, |
| 18 | 31 | CredentialIdSchema, |
| @@ -59,6 +72,202 @@ type OpenApiSpecServer = { · 40 unmodified lines | ||
| 59 | 72 | close: () => Promise<void>; |
| 60 | 73 | }; |
| 61 | 74 | |
| 75 | +type GraphqlServer = { | |
| 76 | + endpoint: string; | |
| 77 | + seenAuthHeaders: Array<string | null>; | |
| 78 | + close: () => Promise<void>; | |
| 79 | +}; | |
| 80 | + | |
| 81 | +const GraphqlUserType = new GraphQLObjectType({ | |
| 82 | + name: "User", | |
| 83 | + fields: { | |
| 84 | + id: { | |
| 85 | + type: new GraphQLNonNull(GraphQLID), | |
| 86 | + }, | |
| 87 | + name: { | |
| 88 | + type: new GraphQLNonNull(GraphQLString), | |
| 89 | + }, | |
| 90 | + }, | |
| 91 | +}); | |
| 92 | + | |
| 93 | +const GraphqlTeamType = new GraphQLObjectType({ | |
| 94 | + name: "Team", | |
| 95 | + fields: { | |
| 96 | + id: { | |
| 97 | + type: new GraphQLNonNull(GraphQLID), | |
| 98 | + }, | |
| 99 | + key: { | |
| 100 | + type: new GraphQLNonNull(GraphQLString), | |
| 101 | + }, | |
| 102 | + name: { | |
| 103 | + type: new GraphQLNonNull(GraphQLString), | |
| 104 | + }, | |
| 105 | + }, | |
| 106 | +}); | |
| 107 | + | |
| 108 | +const GraphqlIssueType = new GraphQLObjectType({ | |
| 109 | + name: "Issue", | |
| 110 | + fields: { | |
| 111 | + id: { | |
| 112 | + type: new GraphQLNonNull(GraphQLID), | |
| 113 | + }, | |
| 114 | + identifier: { | |
| 115 | + type: new GraphQLNonNull(GraphQLString), | |
| 116 | + }, | |
| 117 | + title: { | |
| 118 | + type: new GraphQLNonNull(GraphQLString), | |
| 119 | + }, | |
| 120 | + team: { | |
| 121 | + type: new GraphQLNonNull(GraphqlTeamType), | |
| 122 | + }, | |
| 123 | + }, | |
| 124 | +}); | |
| 125 | + | |
| 126 | +const GraphqlPageInfoType = new GraphQLObjectType({ | |
| 127 | + name: "PageInfo", | |
| 128 | + fields: { | |
| 129 | + hasNextPage: { | |
| 130 | + type: new GraphQLNonNull(GraphQLBoolean), | |
| 131 | + }, | |
| 132 | + endCursor: { | |
| 133 | + type: GraphQLString, | |
| 134 | + }, | |
| 135 | + }, | |
| 136 | +}); | |
| 137 | + | |
| 138 | +const GraphqlIssueConnectionType = new GraphQLObjectType({ | |
| 139 | + name: "IssueConnection", | |
| 140 | + fields: { | |
| 141 | + nodes: { | |
| 142 | + type: new GraphQLNonNull( | |
| 143 | + new GraphQLList(new GraphQLNonNull(GraphqlIssueType)), | |
| 144 | + ), | |
| 145 | + }, | |
| 146 | + pageInfo: { | |
| 147 | + type: new GraphQLNonNull(GraphqlPageInfoType), | |
| 148 | + }, | |
| 149 | + }, | |
| 150 | +}); | |
| 151 | + | |
| 152 | +const GraphqlCreateIssueInputType = new GraphQLInputObjectType({ | |
| 153 | + name: "CreateIssueInput", | |
| 154 | + fields: { | |
| 155 | + title: { | |
| 156 | + type: new GraphQLNonNull(GraphQLString), | |
| 157 | + }, | |
| 158 | + teamId: { | |
| 159 | + type: GraphQLID, | |
| 160 | + }, | |
| 161 | + }, | |
| 162 | +}); | |
| 163 | + | |
| 164 | +const GraphqlQueryType = new GraphQLObjectType({ | |
| 165 | + name: "Query", | |
| 166 | + fields: { | |
| 167 | + viewer: { | |
| 168 | + type: new GraphQLNonNull(GraphqlUserType), | |
| 169 | + description: "The current user", | |
| 170 | + resolve: () => ({ | |
| 171 | + id: "usr_123", | |
| 172 | + name: "Rhys", | |
| 173 | + }), | |
| 174 | + }, | |
| 175 | + issue: { | |
| 176 | + type: GraphqlIssueType, | |
| 177 | + description: "Find a single issue by id", | |
| 178 | + args: { | |
| 179 | + id: { | |
| 180 | + type: new GraphQLNonNull(GraphQLID), | |
| 181 | + }, | |
| 182 | + }, | |
| 183 | + resolve: (_source, args: { id: string }) => ({ | |
| 184 | + id: args.id, | |
| 185 | + identifier: "ENG-123", | |
| 186 | + title: "Investigate GraphQL support", | |
| 187 | + team: { | |
| 188 | + id: "team_eng", | |
| 189 | + key: "ENG", | |
| 190 | + name: "Engineering", | |
| 191 | + }, | |
| 192 | + }), | |
| 193 | + }, | |
| 194 | + issues: { | |
| 195 | + type: new GraphQLNonNull(GraphqlIssueConnectionType), | |
| 196 | + description: "List issues for the workspace", | |
| 197 | + args: { | |
| 198 | + first: { | |
| 199 | + type: GraphQLInt, | |
| 200 | + }, | |
| 201 | + query: { | |
| 202 | + type: GraphQLString, | |
| 203 | + }, | |
| 204 | + }, | |
| 205 | + resolve: () => ({ | |
| 206 | + nodes: [ | |
| 207 | + { | |
| 208 | + id: "iss_1", | |
| 209 | + identifier: "ENG-123", | |
| 210 | + title: "Investigate GraphQL support", | |
| 211 | + team: { | |
| 212 | + id: "team_eng", | |
| 213 | + key: "ENG", | |
| 214 | + name: "Engineering", | |
| 215 | + }, | |
| 216 | + }, | |
| 217 | + { | |
| 218 | + id: "iss_2", | |
| 219 | + identifier: "ENG-124", | |
| 220 | + title: "Ship GraphQL field tools", | |
| 221 | + team: { | |
| 222 | + id: "team_eng", | |
| 223 | + key: "ENG", | |
| 224 | + name: "Engineering", | |
| 225 | + }, | |
| 226 | + }, | |
| 227 | + ], | |
| 228 | + pageInfo: { | |
| 229 | + hasNextPage: false, | |
| 230 | + endCursor: "iss_2", | |
| 231 | + }, | |
| 232 | + }), | |
| 233 | + }, | |
| 234 | + }, | |
| 235 | +}); | |
| 236 | + | |
| 237 | +const GraphqlMutationType = new GraphQLObjectType({ | |
| 238 | + name: "Mutation", | |
| 239 | + fields: { | |
| 240 | + noop: { | |
| 241 | + type: GraphQLString, | |
| 242 | + resolve: () => "ok", | |
| 243 | + }, | |
| 244 | + createIssue: { | |
| 245 | + type: new GraphQLNonNull(GraphqlIssueType), | |
| 246 | + description: "Create a new issue", | |
| 247 | + args: { | |
| 248 | + input: { | |
| 249 | + type: new GraphQLNonNull(GraphqlCreateIssueInputType), | |
| 250 | + }, | |
| 251 | + }, | |
| 252 | + resolve: (_source, args: { input: { title: string; teamId?: string | null } }) => ({ | |
| 253 | + id: "iss_created", | |
| 254 | + identifier: "ENG-999", | |
| 255 | + title: args.input.title, | |
| 256 | + team: { | |
| 257 | + id: args.input.teamId ?? "team_eng", | |
| 258 | + key: "ENG", | |
| 259 | + name: "Engineering", | |
| 260 | + }, | |
| 261 | + }), | |
| 262 | + }, | |
| 263 | + }, | |
| 264 | +}); | |
| 265 | + | |
| 266 | +const testGraphqlSchema = new GraphQLSchema({ | |
| 267 | + query: GraphqlQueryType, | |
| 268 | + mutation: GraphqlMutationType, | |
| 269 | +}); | |
| 270 | + | |
| 62 | 271 | const closeScope = (scope: Scope.CloseableScope) => |
| 63 | 272 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 64 | 273 | |
| @@ -340,6 +549,82 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 275 unmodified lines | ||
| 340 | 549 | close: () => Effect.runPromise(closeScope(scope)), |
| 341 | 550 | } satisfies OpenApiSpecServer; |
| 342 | 551 | }), |
| 552 | + (server) => | |
| 553 | + Effect.tryPromise({ | |
| 554 | + try: () => server.close(), | |
| 555 | + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 556 | + }).pipe(Effect.orDie), | |
| 557 | +); | |
| 558 | + | |
| 559 | +const makeGraphqlServer = Effect.acquireRelease( | |
| 560 | + Effect.promise<GraphqlServer>( | |
| 561 | + () => | |
| 562 | + new Promise<GraphqlServer>((resolve, reject) => { | |
| 563 | + const seenAuthHeaders: Array<string | null> = []; | |
| 564 | + const server = createServer(async (request, response) => { | |
| 565 | + if (request.method !== "POST") { | |
| 566 | + response.statusCode = 405; | |
| 567 | + response.end(); | |
| 568 | + return; | |
| 569 | + } | |
| 570 | + | |
| 571 | + const chunks: Array<Buffer> = []; | |
| 572 | + for await (const chunk of request) { | |
| 573 | + chunks.push(Buffer.from(chunk)); | |
| 574 | + } | |
| 575 | + | |
| 576 | + const rawBody = Buffer.concat(chunks).toString("utf8"); | |
| 577 | + const parsedBody = JSON.parse(rawBody) as Record<string, unknown>; | |
| 578 | + seenAuthHeaders.push( | |
| 579 | + typeof request.headers.authorization === "string" | |
| 580 | + ? request.headers.authorization | |
| 581 | + : null, | |
| 582 | + ); | |
| 583 | + response.statusCode = 200; | |
| 584 | + response.setHeader("content-type", "application/json"); | |
| 585 | + | |
| 586 | + const result = await graphql({ | |
| 587 | + schema: testGraphqlSchema, | |
| 588 | + source: typeof parsedBody.query === "string" ? parsedBody.query : "", | |
| 589 | + variableValues: | |
| 590 | + parsedBody.variables && typeof parsedBody.variables === "object" | |
| 591 | + ? (parsedBody.variables as Record<string, unknown>) | |
| 592 | + : undefined, | |
| 593 | + operationName: | |
| 594 | + typeof parsedBody.operationName === "string" | |
| 595 | + ? parsedBody.operationName | |
| 596 | + : undefined, | |
| 597 | + }); | |
| 598 | + | |
| 599 | + response.end(JSON.stringify(result)); | |
| 600 | + }); | |
| 601 | + | |
| 602 | + server.once("error", reject); | |
| 603 | + server.listen(0, "127.0.0.1", () => { | |
| 604 | + const address = server.address(); | |
| 605 | + if (!address || typeof address === "string") { | |
| 606 | + reject(new Error("Failed to resolve GraphQL test server address")); | |
| 607 | + return; | |
| 608 | + } | |
| 609 | + | |
| 610 | + resolve({ | |
| 611 | + endpoint: `http://127.0.0.1:${address.port}/graphql`, | |
| 612 | + seenAuthHeaders, | |
| 613 | + close: () => | |
| 614 | + new Promise<void>((closeResolve, closeReject) => { | |
| 615 | + server.close((error) => { | |
| 616 | + if (error) { | |
| 617 | + closeReject(error); | |
| 618 | + return; | |
| 619 | + } | |
| 620 | + | |
| 621 | + closeResolve(); | |
| 622 | + }); | |
| 623 | + }), | |
| 624 | + }); | |
| 625 | + }); | |
| 626 | + }), | |
| 627 | + ), | |
| 343 | 628 | (server) => |
| 344 | 629 | Effect.tryPromise({ |
| 345 | 630 | try: () => server.close(), |
| @@ -585,6 +870,8 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 239 unmodified lines | ||
| 585 | 870 | getSourceById: () => Effect.fail(new Error("not implemented in test")), |
| 586 | 871 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), |
| 587 | 872 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), |
| 873 | + startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | |
| 874 | + completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | |
| 588 | 875 | completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), |
| 589 | 876 | } as RuntimeSourceAuthService, |
| 590 | 877 | }); |
| @@ -865,6 +1152,196 @@ describe("workspace-execution-environment", () => { · 274 unmodified lines | ||
| 865 | 1152 | }), |
| 866 | 1153 | ); |
| 867 | 1154 | |
| 1155 | + it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () => | |
| 1156 | + Effect.gen(function* () { | |
| 1157 | + const graphqlServer = yield* makeGraphqlServer; | |
| 1158 | + const persistence = yield* makePersistence; | |
| 1159 | + const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | |
| 1160 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1161 | + const now = Date.now(); | |
| 1162 | + const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 1163 | + "sec_test_graphql_bearer", | |
| 1164 | + ); | |
| 1165 | + yield* persistence.rows.secretMaterials.upsert({ | |
| 1166 | + id: tokenSecretMaterialId, | |
| 1167 | + name: null, | |
| 1168 | + purpose: "auth_material", | |
| 1169 | + value: "linear_test_token", | |
| 1170 | + createdAt: now, | |
| 1171 | + updatedAt: now, | |
| 1172 | + }); | |
| 1173 | + | |
| 1174 | + const environment = yield* resolveEnvironment({ | |
| 1175 | + workspaceId, | |
| 1176 | + accountId: AccountIdSchema.make("acc_add_graphql"), | |
| 1177 | + executionId: ExecutionIdSchema.make("exec_add_graphql"), | |
| 1178 | + onElicitation: () => | |
| 1179 | + Effect.succeed({ | |
| 1180 | + action: "accept" as const, | |
| 1181 | + content: { | |
| 1182 | + authKind: "bearer", | |
| 1183 | + tokenRef: { | |
| 1184 | + providerId: "postgres", | |
| 1185 | + handle: tokenSecretMaterialId, | |
| 1186 | + }, | |
| 1187 | + }, | |
| 1188 | + }), | |
| 1189 | + }); | |
| 1190 | + | |
| 1191 | + const added = (yield* environment.toolInvoker.invoke({ | |
| 1192 | + path: "executor.sources.add", | |
| 1193 | + args: { | |
| 1194 | + kind: "graphql", | |
| 1195 | + endpoint: graphqlServer.endpoint, | |
| 1196 | + name: "Linear", | |
| 1197 | + namespace: "linear", | |
| 1198 | + }, | |
| 1199 | + context: { | |
| 1200 | + runId: "exec_add_graphql", | |
| 1201 | + }, | |
| 1202 | + })) as { | |
| 1203 | + id: SourceId; | |
| 1204 | + kind: string; | |
| 1205 | + status: string; | |
| 1206 | + auth: { | |
| 1207 | + kind: string; | |
| 1208 | + token?: { | |
| 1209 | + providerId: string; | |
| 1210 | + }; | |
| 1211 | + }; | |
| 1212 | + }; | |
| 1213 | + | |
| 1214 | + expect(added.kind).toBe("graphql"); | |
| 1215 | + expect(added.status).toBe("connected"); | |
| 1216 | + expect(added.auth.kind).toBe("bearer"); | |
| 1217 | + expect(added.auth.token?.providerId).toBe("postgres"); | |
| 1218 | + | |
| 1219 | + const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1220 | + workspaceId, | |
| 1221 | + added.id, | |
| 1222 | + ); | |
| 1223 | + expect(Option.isSome(storedSource)).toBe(true); | |
| 1224 | + if (Option.isSome(storedSource)) { | |
| 1225 | + expect(storedSource.value.sourceDocumentText).toContain('"__schema"'); | |
| 1226 | + } | |
| 1227 | + | |
| 1228 | + const freshEnvironment = yield* resolveEnvironment({ | |
| 1229 | + workspaceId, | |
| 1230 | + accountId: AccountIdSchema.make("acc_add_graphql_fresh"), | |
| 1231 | + executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | |
| 1232 | + }); | |
| 1233 | + | |
| 1234 | + const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1235 | + path: "discover", | |
| 1236 | + args: { | |
| 1237 | + query: "linear current user viewer", | |
| 1238 | + limit: 5, | |
| 1239 | + }, | |
| 1240 | + })) as { | |
| 1241 | + bestPath: string | null; | |
| 1242 | + }; | |
| 1243 | + | |
| 1244 | + expect(discovered.bestPath).toBe("linear.viewer"); | |
| 1245 | + | |
| 1246 | + const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1247 | + path: "describe.tool", | |
| 1248 | + args: { | |
| 1249 | + path: "linear.createIssue", | |
| 1250 | + includeSchemas: true, | |
| 1251 | + }, | |
| 1252 | + })) as { | |
| 1253 | + path: string; | |
| 1254 | + inputType?: string; | |
| 1255 | + inputSchemaJson?: string; | |
| 1256 | + } | null; | |
| 1257 | + | |
| 1258 | + expect(describeCreateIssue?.path).toBe("linear.createIssue"); | |
| 1259 | + expect(describeCreateIssue?.inputType).toContain("input"); | |
| 1260 | + expect(describeCreateIssue?.inputType).toContain("title"); | |
| 1261 | + | |
| 1262 | + const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1263 | + path: "linear.viewer", | |
| 1264 | + args: {}, | |
| 1265 | + })) as { | |
| 1266 | + data: { | |
| 1267 | + id: string; | |
| 1268 | + name: string; | |
| 1269 | + __typename: string; | |
| 1270 | + }; | |
| 1271 | + errors: unknown[]; | |
| 1272 | + }; | |
| 1273 | + | |
| 1274 | + expect(viewerResult).toMatchObject({ | |
| 1275 | + data: { | |
| 1276 | + id: "usr_123", | |
| 1277 | + name: "Rhys", | |
| 1278 | + __typename: "User", | |
| 1279 | + }, | |
| 1280 | + errors: [], | |
| 1281 | + }); | |
| 1282 | + | |
| 1283 | + const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1284 | + path: "linear.createIssue", | |
| 1285 | + args: { | |
| 1286 | + input: { | |
| 1287 | + title: "Ship GraphQL field tools", | |
| 1288 | + }, | |
| 1289 | + }, | |
| 1290 | + })) as { | |
| 1291 | + data: { | |
| 1292 | + id: string; | |
| 1293 | + identifier: string; | |
| 1294 | + title: string; | |
| 1295 | + __typename: string; | |
| 1296 | + }; | |
| 1297 | + errors: unknown[]; | |
| 1298 | + }; | |
| 1299 | + | |
| 1300 | + expect(createIssueResult).toMatchObject({ | |
| 1301 | + data: { | |
| 1302 | + id: "iss_created", | |
| 1303 | + identifier: "ENG-999", | |
| 1304 | + title: "Ship GraphQL field tools", | |
| 1305 | + __typename: "Issue", | |
| 1306 | + }, | |
| 1307 | + errors: [], | |
| 1308 | + }); | |
| 1309 | + | |
| 1310 | + const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1311 | + path: "linear.request", | |
| 1312 | + args: { | |
| 1313 | + query: "query Viewer { viewer { id name } }", | |
| 1314 | + }, | |
| 1315 | + })) as { | |
| 1316 | + status: number; | |
| 1317 | + body: { | |
| 1318 | + data: { | |
| 1319 | + viewer: { | |
| 1320 | + id: string; | |
| 1321 | + name: string; | |
| 1322 | + }; | |
| 1323 | + }; | |
| 1324 | + }; | |
| 1325 | + }; | |
| 1326 | + | |
| 1327 | + expect(rawRequestResult.status).toBe(200); | |
| 1328 | + expect(rawRequestResult.body).toEqual({ | |
| 1329 | + data: { | |
| 1330 | + viewer: { | |
| 1331 | + id: "usr_123", | |
| 1332 | + name: "Rhys", | |
| 1333 | + }, | |
| 1334 | + }, | |
| 1335 | + }); | |
| 1336 | + expect(graphqlServer.seenAuthHeaders).toEqual([ | |
| 1337 | + "Bearer linear_test_token", | |
| 1338 | + "Bearer linear_test_token", | |
| 1339 | + "Bearer linear_test_token", | |
| 1340 | + "Bearer linear_test_token", | |
| 1341 | + ]); | |
| 1342 | + }), | |
| 1343 | + ); | |
| 1344 | + | |
| 868 | 1345 | it.scoped("resolves params-backed source auth from invocation context", () => |
| 869 | 1346 | Effect.gen(function* () { |
| 870 | 1347 | const specServer = yield* makeOpenApiSpecServer; |