1437 lines · 49.1 KB · TypeScript
RawDownload
import { describe, it, expect } from "@effect/vitest";import { Effect, Layer } from "effect";import {  HttpClient,  HttpClientRequest,  HttpClientResponse,  HttpServerRequest,  HttpServerResponse,} from "effect/unstable/http"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  ProviderItemId,  ProviderKey,  ToolAddress,  createExecutor,  endpointForTelemetry,} from "@executor-js/sdk";import {  makeTestConfig,  serveTestHttpApp,  memoryCredentialsPlugin,} from "@executor-js/sdk/testing"; import { graphqlPlugin } from "./plugin";import { introspect } from "./introspect";import type { IntrospectionResult } from "./introspect";import {  makeGitlab1146Schema,  makeGreetingGraphqlSchema,  serveGraphqlFailureTestServer,  serveGraphqlTestServer,  waitForRecordedRequests,} from "../testing"; // removed: v1 secret browser-handoff, credential-binding scopes, usagesForSecret/// usagesForConnection, multi-scope shadowing, and `executor.sources.*` /// `executor.secrets.*` flows — those surfaces no longer exist in the v2 model// (secrets / sources / scope stack / credential bindings are gone). Coverage is// ported to the v2 surface: integrations.register via `graphql.addIntegration`,// per-connection tool production via `connections.create` -> resolveTools, and// auth-template rendering in `invokeTool`. // ---------------------------------------------------------------------------// Mock introspection response// --------------------------------------------------------------------------- const introspectionResult: IntrospectionResult = {  __schema: {    queryType: { name: "Query" },    mutationType: { name: "Mutation" },    types: [      {        kind: "OBJECT",        name: "Query",        description: null,        fields: [          {            name: "hello",            description: "Say hello",            args: [              {                name: "name",                description: null,                type: { kind: "SCALAR", name: "String", ofType: null },                defaultValue: null,              },            ],            type: { kind: "SCALAR", name: "String", ofType: null },          },        ],        inputFields: null,        enumValues: null,      },      {        kind: "OBJECT",        name: "Mutation",        description: null,        fields: [          {            name: "setGreeting",            description: "Set greeting message",            args: [              {                name: "message",                description: null,                type: {                  kind: "NON_NULL",                  name: null,                  ofType: { kind: "SCALAR", name: "String", ofType: null },                },                defaultValue: null,              },            ],            type: { kind: "SCALAR", name: "String", ofType: null },          },        ],        inputFields: null,        enumValues: null,      },      {        kind: "SCALAR",        name: "String",        description: null,        fields: null,        inputFields: null,        enumValues: null,      },    ],  },}; const introspectionJson = JSON.stringify({ data: introspectionResult });const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); const makeExecutor = () =>  createExecutor(    makeTestConfig({ plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const }),  ); const toolAddr = (integration: string, connection: string, tool: string): ToolAddress =>  ToolAddress.make(`tools.${integration}.org.${connection}.${tool}`); const createOrgConnection = (  executor: Awaited<ReturnType<typeof makeExecutor>> extends Effect.Effect<infer A> ? A : never,  input: {    readonly integration: string;    readonly name: string;    readonly template: string;    readonly value: string;  },) =>  executor.connections.create({    owner: "org",    name: ConnectionName.make(input.name),    integration: IntegrationSlug.make(input.integration),    template: AuthTemplateSlug.make(input.template),    value: input.value,  }); describe("graphqlPlugin real protocol server", () => {  it("uses query-free endpoints for invocation attributes", () => {    expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe(      "https://api.example.test/graphql",    );  });   it.effect("includes redacted upstream text in introspection status errors", () =>    Effect.gen(function* () {      const server = yield* serveGraphqlFailureTestServer({        status: 500,        body: 'upstream failed {"access_token":"secret-value"} token=another-secret',      });       const error = yield* introspect(server.endpoint).pipe(        Effect.provide(server.httpClientLayer),        Effect.flip,      );       expect(error).toHaveProperty(        "message",        'Introspection failed with status 500: upstream failed {"access_token":"[redacted]"} token=[redacted]',      );      expect(error).not.toHaveProperty("message", expect.stringContaining("secret-value"));      expect(error).not.toHaveProperty("message", expect.stringContaining("another-secret"));    }),  );   it.effect("includes safe upstream JSON messages in introspection status errors", () =>    Effect.gen(function* () {      const server = yield* serveTestHttpApp(() =>        Effect.succeed(          HttpServerResponse.jsonUnsafe(            { message: "Resource protected by organization SSO" },            { status: 403 },          ),        ),      );       const error = yield* introspect(server.url("/graphql")).pipe(        Effect.provide(server.httpClientLayer),        Effect.flip,      );       expect(error).toHaveProperty(        "message",        "Introspection failed with status 403: Resource protected by organization SSO",      );    }),  );   it.effect("redacts secrets from upstream JSON messages in introspection status errors", () =>    Effect.gen(function* () {      const server = yield* serveTestHttpApp(() =>        Effect.succeed(          HttpServerResponse.jsonUnsafe(            { message: "Authorization: Bearer github-secret-token" },            { status: 403 },          ),        ),      );       const error = yield* introspect(server.url("/graphql")).pipe(        Effect.provide(server.httpClientLayer),        Effect.flip,      );       expect(error).toHaveProperty(        "message",        "Introspection failed with status 403: Authorization: [redacted]",      );      expect(error).not.toHaveProperty("message", expect.stringContaining("github-secret-token"));    }),  );   it.effect("accepts standard introspection responses with omitted deepest ofType", () =>    Effect.gen(function* () {      const deepType = {        kind: "NON_NULL",        name: null,        ofType: {          kind: "LIST",          name: null,          ofType: {            kind: "NON_NULL",            name: null,            ofType: {              kind: "LIST",              name: null,              ofType: {                kind: "NON_NULL",                name: null,                ofType: {                  kind: "SCALAR",                  name: "String",                },              },            },          },        },      };      const server = yield* serveTestHttpApp(() =>        Effect.succeed(          HttpServerResponse.jsonUnsafe({            data: {              __schema: {                queryType: { name: "Query" },                mutationType: null,                types: [                  {                    kind: "OBJECT",                    name: "Query",                    description: null,                    fields: [                      {                        name: "deep",                        description: null,                        args: [],                        type: deepType,                      },                    ],                    inputFields: null,                    enumValues: null,                  },                  {                    kind: "SCALAR",                    name: "String",                    description: null,                    fields: null,                    inputFields: null,                    enumValues: null,                  },                ],              },            },          }),        ),      );       const result = yield* introspect(server.url("/graphql")).pipe(        Effect.provide(server.httpClientLayer),      );       expect(result.__schema.queryType?.name).toBe("Query");    }),  );   it.effect("registers without a network call and introspects at connection-create", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* makeExecutor();       // Registering a source is a catalog statement, not a network call: with no      // pre-supplied schema, add makes zero requests and yields zero tools.      const result = yield* executor.graphql.addIntegration({        endpoint: server.endpoint,        slug: "live_graph",        name: "Live Graph",      });       expect(result).toMatchObject({ slug: "live_graph", toolCount: 0 });       const addRequests = yield* server.requests;      expect(addRequests.length).toBe(0);       // Creating a connection is where introspection happens (like MCP defers      // discovery to connect time) and materializes the per-connection tools.      yield* createOrgConnection(executor, {        integration: "live_graph",        name: "default",        template: "none",        value: "unused",      });       yield* waitForRecordedRequests(server.requests, (requests) =>        requests.some((request) => request.payload.query?.includes("__schema")),      );       const tools = yield* executor.tools.list();      expect(tools.map((tool) => String(tool.name))).toEqual(        expect.arrayContaining(["query.hello", "mutation.setGreeting"]),      );    }),  );   it.effect("uses the executor HttpClient layer for connection-time introspection", () =>    Effect.gen(function* () {      const seen: string[] = [];      const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(        HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {          seen.push(request.url);          return Effect.succeed(            HttpClientResponse.fromWeb(              request,              new Response(JSON.stringify({ data: introspectionResult }), {                status: 200,                headers: { "content-type": "application/json" },              }),            ),          );        }),      );      const config = makeTestConfig({        plugins: [memoryCredentialsPlugin(), graphqlPlugin()] as const,      });      const executor = yield* createExecutor({ ...config, httpClientLayer });       yield* executor.graphql.addIntegration({        endpoint: "https://internal.example/graphql",        slug: "guarded_graph",        name: "Guarded Graph",      });      yield* createOrgConnection(executor, {        integration: "guarded_graph",        name: "default",        template: "none",        value: "unused",      });       const tools = yield* executor.tools.list();      expect(seen).toEqual(["https://internal.example/graphql"]);      expect(tools.map((tool) => String(tool.name))).toEqual(        expect.arrayContaining(["query.hello", "mutation.setGreeting"]),      );    }),  );   it.effect("invokes a live query through an apiKey header template", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* makeExecutor();       yield* executor.graphql.addIntegration({        endpoint: server.endpoint,        slug: "live_invoke",        queryParams: { trace: "on" },        authenticationTemplate: [          { slug: "header", kind: "apikey", placements: [{ carrier: "header", name: "x-static" }] },        ],      });      yield* createOrgConnection(executor, {        integration: "live_invoke",        name: "main",        template: "header",        value: "abc",      });       // First invoke materializes operation bindings (one introspection) and runs      // the query. Drive a second invoke against the now-warm cache so the query      // request is the only thing on the wire and the assertions stay precise.      yield* executor.execute(toolAddr("live_invoke", "main", "query.hello"), { name: "Ada" });      yield* server.clearRequests;       const result = yield* executor.execute(toolAddr("live_invoke", "main", "query.hello"), {        name: "Ada",      });       expect(result).toEqual({ ok: true, data: { hello: "Hello Ada" } });       const requests = yield* server.requests;      expect(requests.length).toBe(1);      expect(requests[0]?.headers["x-static"]).toBe("abc");      expect(new URL(requests[0]!.url).searchParams.get("trace")).toBe("on");      expect(requests[0]?.payload.variables).toEqual({ name: "Ada" });    }),  );   it.effect("sends named operations derived from the field name", () =>    Effect.gen(function* () {      const server = yield* serveGreetingServer;      const executor = yield* makeExecutor();       yield* executor.graphql.addIntegration({        endpoint: server.endpoint,        slug: "named_ops",      });      yield* createOrgConnection(executor, {        integration: "named_ops",        name: "main",        template: "none",        value: "unused",      });       yield* executor.execute(toolAddr("named_ops", "main", "query.hello"), { name: "Ada" });      yield* server.clearRequests;       yield* executor.execute(toolAddr("named_ops", "main", "query.hello"), { name: "Ada" });      yield* executor.execute(toolAddr("named_ops", "main", "mutation.setGreeting"), {        message: "hi",      });       const requests = yield* server.requests;      expect(requests[0]?.payload.query).toMatch(/^query Hello\b/);      expect(requests[1]?.payload.query).toMatch(/^mutation SetGreeting\b/);    }),  );   it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () =>    Effect.gen(function* () {      const server = yield* serveTestHttpApp((request) =>        Effect.gen(function* () {          const webRequest = yield* HttpServerRequest.toWeb(request);          const body = yield* Effect.promise(() => webRequest.text());          if (body.includes("__schema")) {            return HttpServerResponse.jsonUnsafe({ data: introspectionResult });          }          return HttpServerResponse.text("temporary upstream outage", {            status: 503,            contentType: "text/plain",          });        }),      );      const executor = yield* makeExecutor();       yield* executor.graphql.addIntegration({        endpoint: server.url("/graphql"),        slug: "http_error_graph",      });      yield* createOrgConnection(executor, {        integration: "http_error_graph",        name: "main",        template: "none",        value: "unused",      });       const result = yield* executor.execute(toolAddr("http_error_graph", "main", "query.hello"), {        name: "Ada",      });       expect(result).toMatchObject({        ok: false,        error: {          code: "graphql_http_error",          status: 503,          message: "GraphQL request failed with HTTP 503",        },      });    }),  );   it.effect("classifies a 401 as connection_rejected even when the body is GraphQL-shaped", () =>    Effect.gen(function* () {      // An auth gateway may answer with a GraphQL-style errors array AND a      // 401 status. The transport status is authoritative: the failure must      // reach the agent as a credential problem, not as graphql_errors.      const server = yield* serveTestHttpApp((request) =>        Effect.gen(function* () {          const webRequest = yield* HttpServerRequest.toWeb(request);          const body = yield* Effect.promise(() => webRequest.text());          if (body.includes("__schema")) {            return HttpServerResponse.jsonUnsafe({ data: introspectionResult });          }          return HttpServerResponse.jsonUnsafe(            { errors: [{ message: "Not authenticated" }] },            { status: 401 },          );        }),      );

Lines 1–500 of 1437.