History

update tests for decoupled OAuth, GraphQL sources, and workspace execution

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.

Rhys Sullivan committed 8 months ago

Commit 4cb06d01 parent 956c74b5 files · +801 −26Browse files

Unified · Split

5 files
  1. apps/executor/src/server/server.test.ts +15 −19
  2. packages/control-plane/src/runtime/control-plane-runtime.test.ts +135 −2
  3. packages/control-plane/src/runtime/source-auth-service.test.ts +23 −5
  4. packages/control-plane/src/runtime/source-discovery.test.ts +151 −0
  5. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +477 −0
apps/executor/src/server/server.test.ts+15−19
@@ -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 Effect.gen(function* () {806 Effect.gen(function* () {
807 const oauthServer = yield* Effect.acquireRelease(807 const oauthServer = yield* Effect.acquireRelease(
808 Effect.promise(() => startOAuthProtectedMcpServer()),808 Effect.promise(() => startOAuthProtectedMcpServer()),
@@ -820,16 +820,15 @@ describe("local-executor-server", () => { · 11 unmodified lines
820 const installation = yield* bootstrapClient.local.installation({});820 const installation = yield* bootstrapClient.local.installation({});
821 821
822 const startResponse = yield* Effect.promise(() =>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 method: "POST",824 method: "POST",
825 headers: {825 headers: {
826 "content-type": "application/json",826 "content-type": "application/json",
827 "x-executor-account-id": installation.accountId,827 "x-executor-account-id": installation.accountId,
828 },828 },
829 body: JSON.stringify({829 body: JSON.stringify({
830 provider: "mcp",
830 endpoint: oauthServer.endpoint,831 endpoint: oauthServer.endpoint,
831 name: "Axiom",
832 namespace: "axiom",
833 transport: "auto",832 transport: "auto",
834 }),833 }),
835 }),834 }),
@@ -837,29 +836,26 @@ describe("local-executor-server", () => { · 1 unmodified line
837 836
838 assertTrue(startResponse.ok);837 assertTrue(startResponse.ok);
839 const started: {838 const started: {
840 kind: "connected" | "oauth_required";839 sessionId: string;
841 source: { id: string; status: string; auth: { kind: string } };840 authorizationUrl: string;
842 authorizationUrl?: string;
843 } = yield* Effect.promise(() => startResponse.json() as Promise<{841 } = yield* Effect.promise(() => startResponse.json() as Promise<{
844 kind: "connected" | "oauth_required";842 sessionId: string;
845 source: { id: string; status: string; auth: { kind: string } };843 authorizationUrl: string;
846 authorizationUrl?: string;
847 }>);844 }>);
848 845
849 expect(started.kind).toBe("oauth_required");846 expect(started.sessionId).toBeTruthy();
850 expect(started.source.status).toBe("auth_required");
851 expect(started.authorizationUrl).toBeDefined();847 expect(started.authorizationUrl).toBeDefined();
852 848
853 const callbackResponse = yield* Effect.promise(() =>849 const callbackResponse = yield* Effect.promise(() =>
854 fetch(started.authorizationUrl!, {850 fetch(started.authorizationUrl, {
855 redirect: "follow",851 redirect: "follow",
856 }),852 }),
857 );853 );
858 854
859 assertTrue(callbackResponse.ok);855 assertTrue(callbackResponse.ok);
860 const callbackHtml = yield* Effect.promise(() => callbackResponse.text());856 const callbackHtml = yield* Effect.promise(() => callbackResponse.text());
861 expect(callbackHtml).toContain("Source connected:");857 expect(callbackHtml).toContain("OAuth connected");
862 expect(callbackHtml).toContain("executor-v3:source-oauth-result");858 expect(callbackHtml).toContain("executor-v3:oauth-result");
863 859
864 const client = yield* createControlPlaneClient({860 const client = yield* createControlPlaneClient({
865 baseUrl: server.baseUrl,861 baseUrl: server.baseUrl,
@@ -870,12 +866,12 @@ describe("local-executor-server", () => { · 4 unmodified lines
870 workspaceId: installation.workspaceId,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");872 const secrets = yield* bootstrapClient.local.listSecrets({});
878 expect(source?.auth.kind).toBe("oauth2");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
packages/control-plane/src/runtime/control-plane-runtime.test.ts+135−2
@@ -1,3 +1,5 @@
1import { createServer } from "node:http";
2
1import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";4import { assertTrue } from "@effect/vitest/utils";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
@@ -25,6 +27,80 @@ const makeRuntime = Effect.acquireRelease( · 21 unmodified lines
25 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),27 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
26);28);
27 29
30type OpenApiSpecServer = {
31 baseUrl: string;
32 specUrl: string;
33 close: () => Promise<void>;
34};
35
36const 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
28const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>104const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>
29 Effect.either(effect).pipe(105 Effect.either(effect).pipe(
30 Effect.flatMap((result) =>106 Effect.flatMap((result) =>
@@ -38,6 +114,7 @@ describe("control-plane-runtime", () => { · 7 unmodified lines
38 it.scoped("supports full CRUD flow over HTTP API", () =>114 it.scoped("supports full CRUD flow over HTTP API", () =>
39 Effect.gen(function* () {115 Effect.gen(function* () {
40 const runtime = yield* makeRuntime;116 const runtime = yield* makeRuntime;
117 const openApiServer = yield* makeOpenApiSpecServer;
41 118
42 const createOrg = yield* withControlPlaneClient(119 const createOrg = yield* withControlPlaneClient(
43 { runtime, accountId: "acc_1" },120 { runtime, accountId: "acc_1" },
@@ -69,8 +146,8 @@ describe("control-plane-runtime", () => { · 25 unmodified lines
69 payload: {146 payload: {
70 name: "Github",147 name: "Github",
71 kind: "openapi",148 kind: "openapi",
72 endpoint: "https://api.github.com",149 endpoint: openApiServer.baseUrl,
73 specUrl: "https://api.github.com/openapi.json",150 specUrl: openApiServer.specUrl,
74 auth: {151 auth: {
75 kind: "none",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 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>251 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>
119 Effect.gen(function* () {252 Effect.gen(function* () {
120 const runtime = yield* makeRuntime;253 const runtime = yield* makeRuntime;
packages/control-plane/src/runtime/source-auth-service.test.ts+23−5
@@ -4,7 +4,7 @@ import type { Source } from "#schema"; · 3 unmodified lines
4 4
5import {5import {
6 createTerminalSourceAuthSessionPatch,6 createTerminalSourceAuthSessionPatch,
7 shouldPromptForOpenApiCredentialSetup,7 shouldPromptForHttpCredentialSetup,
8} from "./source-auth-service";8} from "./source-auth-service";
9 9
10const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({10const 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 expect(85 expect(
86 shouldPromptForOpenApiCredentialSetup({86 shouldPromptForHttpCredentialSetup({
87 existing: makeExistingOpenApiSource({87 existing: makeExistingOpenApiSource({
88 kind: "bearer",88 kind: "bearer",
89 headerName: "Authorization",89 headerName: "Authorization",
@@ -97,7 +97,7 @@ describe("source-auth-service", () => { · 7 unmodified lines
97 ).toBe(false);97 ).toBe(false);
98 98
99 expect(99 expect(
100 shouldPromptForOpenApiCredentialSetup({100 shouldPromptForHttpCredentialSetup({
101 existing: makeExistingOpenApiSource({101 existing: makeExistingOpenApiSource({
102 kind: "oauth2",102 kind: "oauth2",
103 headerName: "Authorization",103 headerName: "Authorization",
@@ -115,11 +115,29 @@ describe("source-auth-service", () => { · 11 unmodified lines
115 ).toBe(false);115 ).toBe(false);
116 116
117 expect(117 expect(
118 shouldPromptForOpenApiCredentialSetup({118 shouldPromptForHttpCredentialSetup({
119 existing: makeExistingOpenApiSource({119 existing: makeExistingOpenApiSource({
120 kind: "none",120 kind: "none",
121 }),121 }),
122 }),122 }),
123 ).toBe(true);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});
packages/control-plane/src/runtime/source-discovery.test.ts+151−0
@@ -0,0 +1,151 @@
1import {
2 createServer,
3 type IncomingMessage,
4 type ServerResponse,
5} from "node:http";
6
7import { describe, expect, it } from "vitest";
8import * as Effect from "effect/Effect";
9
10import { discoverSource } from "./source-discovery";
11
12type TestServer = {
13 url: string;
14 close: () => Promise<void>;
15};
16
17const 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
47describe("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});
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+477−0
@@ -1,4 +1,5 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { createServer } from "node:http";
2 3
3import {4import {
4 HttpApi,5 HttpApi,
@@ -13,6 +14,18 @@ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js · 8 unmodified lines
13import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";14import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";15import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15import { describe, expect, it } from "@effect/vitest";16import { describe, expect, it } from "@effect/vitest";
17import {
18 graphql,
19 GraphQLBoolean,
20 GraphQLID,
21 GraphQLInputObjectType,
22 GraphQLInt,
23 GraphQLList,
24 GraphQLNonNull,
25 GraphQLObjectType,
26 GraphQLSchema,
27 GraphQLString,
28} from "graphql";
16import {29import {
17 AccountIdSchema,30 AccountIdSchema,
18 CredentialIdSchema,31 CredentialIdSchema,
@@ -59,6 +72,202 @@ type OpenApiSpecServer = { · 40 unmodified lines
59 close: () => Promise<void>;72 close: () => Promise<void>;
60};73};
61 74
75type GraphqlServer = {
76 endpoint: string;
77 seenAuthHeaders: Array<string | null>;
78 close: () => Promise<void>;
79};
80
81const 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
93const 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
108const 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
126const 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
138const 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
152const 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
164const 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
237const 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
266const testGraphqlSchema = new GraphQLSchema({
267 query: GraphqlQueryType,
268 mutation: GraphqlMutationType,
269});
270
62const closeScope = (scope: Scope.CloseableScope) =>271const closeScope = (scope: Scope.CloseableScope) =>
63 Scope.close(scope, Exit.void).pipe(Effect.orDie);272 Scope.close(scope, Exit.void).pipe(Effect.orDie);
64 273
@@ -340,6 +549,82 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 275 unmodified lines
340 close: () => Effect.runPromise(closeScope(scope)),549 close: () => Effect.runPromise(closeScope(scope)),
341 } satisfies OpenApiSpecServer;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
559const 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 (server) =>628 (server) =>
344 Effect.tryPromise({629 Effect.tryPromise({
345 try: () => server.close(),630 try: () => server.close(),
@@ -585,6 +870,8 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 239 unmodified lines
585 getSourceById: () => Effect.fail(new Error("not implemented in test")),870 getSourceById: () => Effect.fail(new Error("not implemented in test")),
586 addExecutorSource: () => Effect.fail(new Error("not implemented in test")),871 addExecutorSource: () => Effect.fail(new Error("not implemented in test")),
587 connectMcpSource: () => Effect.fail(new Error("not implemented in test")),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 completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")),875 completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")),
589 } as RuntimeSourceAuthService,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 it.scoped("resolves params-backed source auth from invocation context", () =>1345 it.scoped("resolves params-backed source auth from invocation context", () =>
869 Effect.gen(function* () {1346 Effect.gen(function* () {
870 const specServer = yield* makeOpenApiSpecServer;1347 const specServer = yield* makeOpenApiSpecServer;