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
802802 }),
803803 );
804804
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", () =>
806806 Effect.gen(function* () {
807807 const oauthServer = yield* Effect.acquireRelease(
808808 Effect.promise(() => startOAuthProtectedMcpServer()),
@@ -820,16 +820,15 @@ describe("local-executor-server", () => { · 11 unmodified lines
820820 const installation = yield* bootstrapClient.local.installation({});
821821
822822 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`, {
824824 method: "POST",
825825 headers: {
826826 "content-type": "application/json",
827827 "x-executor-account-id": installation.accountId,
828828 },
829829 body: JSON.stringify({
830+ provider: "mcp",
830831 endpoint: oauthServer.endpoint,
831− name: "Axiom",
832− namespace: "axiom",
833832 transport: "auto",
834833 }),
835834 }),
@@ -837,29 +836,26 @@ describe("local-executor-server", () => { · 1 unmodified line
837836
838837 assertTrue(startResponse.ok);
839838 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;
843841 } = 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;
847844 }>);
848845
849− expect(started.kind).toBe("oauth_required");
850− expect(started.source.status).toBe("auth_required");
846+ expect(started.sessionId).toBeTruthy();
851847 expect(started.authorizationUrl).toBeDefined();
852848
853849 const callbackResponse = yield* Effect.promise(() =>
854− fetch(started.authorizationUrl!, {
850+ fetch(started.authorizationUrl, {
855851 redirect: "follow",
856852 }),
857853 );
858854
859855 assertTrue(callbackResponse.ok);
860856 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");
863859
864860 const client = yield* createControlPlaneClient({
865861 baseUrl: server.baseUrl,
@@ -870,12 +866,12 @@ describe("local-executor-server", () => { · 4 unmodified lines
870866 workspaceId: installation.workspaceId,
871867 },
872868 });
873− const source = sources.find((candidate) => candidate.id === started.source.id);
874869
875− assertTrue(Boolean(source));
870+ expect(sources).toHaveLength(0);
876871
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);
879875 }),
880876 );
881877
packages/control-plane/src/runtime/control-plane-runtime.test.ts+135−2
@@ -1,3 +1,5 @@
1+import { createServer } from "node:http";
2+
13 import { describe, expect, it } from "@effect/vitest";
24 import { assertTrue } from "@effect/vitest/utils";
35 import * as Effect from "effect/Effect";
@@ -25,6 +27,80 @@ const makeRuntime = Effect.acquireRelease( · 21 unmodified lines
2527 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
2628 );
2729
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+
28104 const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>
29105 Effect.either(effect).pipe(
30106 Effect.flatMap((result) =>
@@ -38,6 +114,7 @@ describe("control-plane-runtime", () => { · 7 unmodified lines
38114 it.scoped("supports full CRUD flow over HTTP API", () =>
39115 Effect.gen(function* () {
40116 const runtime = yield* makeRuntime;
117+ const openApiServer = yield* makeOpenApiSpecServer;
41118
42119 const createOrg = yield* withControlPlaneClient(
43120 { runtime, accountId: "acc_1" },
@@ -69,8 +146,8 @@ describe("control-plane-runtime", () => { · 25 unmodified lines
69146 payload: {
70147 name: "Github",
71148 kind: "openapi",
72− endpoint: "https://api.github.com",
73− specUrl: "https://api.github.com/openapi.json",
149+ endpoint: openApiServer.baseUrl,
150+ specUrl: openApiServer.specUrl,
74151 auth: {
75152 kind: "none",
76153 },
@@ -115,6 +192,62 @@ describe("control-plane-runtime", () => { · 38 unmodified lines
115192 }),
116193 );
117194
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+
118251 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>
119252 Effect.gen(function* () {
120253 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
44
55 import {
66 createTerminalSourceAuthSessionPatch,
7− shouldPromptForOpenApiCredentialSetup,
7+ shouldPromptForHttpCredentialSetup,
88 } from "./source-auth-service";
99
1010 const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
@@ -81,9 +81,9 @@ describe("source-auth-service", () => { · 70 unmodified lines
8181 });
8282 });
8383
84− it("reuses existing non-none OpenAPI auth without prompting again", () => {
84+ it("reuses existing non-none HTTP source auth without prompting again", () => {
8585 expect(
86− shouldPromptForOpenApiCredentialSetup({
86+ shouldPromptForHttpCredentialSetup({
8787 existing: makeExistingOpenApiSource({
8888 kind: "bearer",
8989 headerName: "Authorization",
@@ -97,7 +97,7 @@ describe("source-auth-service", () => { · 7 unmodified lines
9797 ).toBe(false);
9898
9999 expect(
100− shouldPromptForOpenApiCredentialSetup({
100+ shouldPromptForHttpCredentialSetup({
101101 existing: makeExistingOpenApiSource({
102102 kind: "oauth2",
103103 headerName: "Authorization",
@@ -115,11 +115,29 @@ describe("source-auth-service", () => { · 11 unmodified lines
115115 ).toBe(false);
116116
117117 expect(
118− shouldPromptForOpenApiCredentialSetup({
118+ shouldPromptForHttpCredentialSetup({
119119 existing: makeExistingOpenApiSource({
120120 kind: "none",
121121 }),
122122 }),
123123 ).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);
124142 });
125143 });
packages/control-plane/src/runtime/source-discovery.test.ts+151−0
@@ -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+});
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+477−0
@@ -1,4 +1,5 @@
11 import { randomUUID } from "node:crypto";
2+import { createServer } from "node:http";
23
34 import {
45 HttpApi,
@@ -13,6 +14,18 @@ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js · 8 unmodified lines
1314 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1415 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1516 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";
1629 import {
1730 AccountIdSchema,
1831 CredentialIdSchema,
@@ -59,6 +72,202 @@ type OpenApiSpecServer = { · 40 unmodified lines
5972 close: () => Promise<void>;
6073 };
6174
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+
62271 const closeScope = (scope: Scope.CloseableScope) =>
63272 Scope.close(scope, Exit.void).pipe(Effect.orDie);
64273
@@ -340,6 +549,82 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 275 unmodified lines
340549 close: () => Effect.runPromise(closeScope(scope)),
341550 } satisfies OpenApiSpecServer;
342551 }),
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+ ),
343628 (server) =>
344629 Effect.tryPromise({
345630 try: () => server.close(),
@@ -585,6 +870,8 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 239 unmodified lines
585870 getSourceById: () => Effect.fail(new Error("not implemented in test")),
586871 addExecutorSource: () => Effect.fail(new Error("not implemented in test")),
587872 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")),
588875 completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")),
589876 } as RuntimeSourceAuthService,
590877 });
@@ -865,6 +1152,196 @@ describe("workspace-execution-environment", () => { · 274 unmodified lines
8651152 }),
8661153 );
8671154
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+
8681345 it.scoped("resolves params-backed source auth from invocation context", () =>
8691346 Effect.gen(function* () {
8701347 const specServer = yield* makeOpenApiSpecServer;