History

Add GraphQL invalid introspection repro

Rhys Sullivan committed 4 months ago

Commit 281f7281 parent fd4fb021 file · +152 −0Browse files

Unified · Split

e2e/scenarios/graphql-invalid-format-no-tools.test.ts+152−0
@@ -0,0 +1,152 @@
1+// Cross-target (browser): GraphQL source discovery can fail silently at
2+// connection-create time. The UI journey is the reported shape: add a GraphQL
3+// integration, connect an API key, see the connection saved, then land on an
4+// empty Tools tab with no visible error explaining that introspection failed.
5+//
6+// This is intentionally a passing repro of the current buggy behavior. The
7+// upstream is a local node:http server that returns HTTP 200 with a GraphQL-ish
8+// body in the wrong introspection shape: `{ "data": {} }`.
9+import { randomBytes } from "node:crypto";
10+import { createServer } from "node:http";
11+
12+import { expect } from "@effect/vitest";
13+import { Effect } from "effect";
14+import { composePluginApi } from "@executor-js/api/server";
15+import { ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+import { scenario } from "../src/scenario";
18+import { Api, Browser, Target } from "../src/services";
19+
20+const api = composePluginApi([] as const);
21+
22+const serveInvalidIntrospectionGraphqlApi = () =>
23+ Effect.acquireRelease(
24+ Effect.callback<{ readonly endpoint: string; readonly close: () => void }>((resume) => {
25+ const server = createServer((request, response) => {
26+ if (request.method === "POST" && (request.url ?? "").startsWith("/graphql")) {
27+ response.writeHead(200, { "content-type": "application/json" });
28+ response.end(JSON.stringify({ data: {} }));
29+ return;
30+ }
31+ response.writeHead(404, { "content-type": "application/json" });
32+ response.end(JSON.stringify({ error: "not_found" }));
33+ });
34+ server.listen(0, "127.0.0.1", () => {
35+ const address = server.address();
36+ const port = typeof address === "object" && address ? address.port : 0;
37+ resume(
38+ Effect.succeed({
39+ endpoint: `http://127.0.0.1:${port}/graphql`,
40+ close: () => {
41+ server.close();
42+ server.closeAllConnections();
43+ },
44+ }),
45+ );
46+ });
47+ }),
48+ (server) => Effect.sync(server.close),
49+ );
50+
51+scenario(
52+ "GraphQL · invalid introspection format creates a connected account with zero tools",
53+ {},
54+ Effect.scoped(
55+ Effect.gen(function* () {
56+ const target = yield* Target;
57+ const browser = yield* Browser;
58+ const { client } = yield* Api;
59+ const upstream = yield* serveInvalidIntrospectionGraphqlApi();
60+ const identity = yield* target.newIdentity();
61+ const apiClient = yield* client(api, identity);
62+
63+ const slug = IntegrationSlug.make(`graphql_invalid_format_${randomBytes(4).toString("hex")}`);
64+
65+ yield* Effect.ensuring(
66+ Effect.gen(function* () {
67+ yield* browser.session(identity, async ({ page, step }) => {
68+ await step("Open the Add GraphQL integration form", async () => {
69+ await page.goto("/integrations/add/graphql", { waitUntil: "networkidle" });
70+ await page.getByRole("heading", { name: "Add GraphQL integration" }).waitFor();
71+ });
72+
73+ await step(
74+ "Enter the endpoint and declare Linear-style Authorization API key auth",
75+ async () => {
76+ await page
77+ .getByPlaceholder("https://api.example.com/graphql")
78+ .fill(upstream.endpoint);
79+ await page.getByPlaceholder("e.g. Shopify API").fill("Linear GraphQL");
80+ await page.locator("input").nth(2).fill(String(slug));
81+ await page.getByRole("button", { name: "Add method" }).click();
82+ await page.getByText("Method 1").waitFor();
83+ await page.getByLabel("Header name").fill("Authorization");
84+ await page.getByLabel("Prefix").fill("");
85+ },
86+ );
87+
88+ await step("Add the GraphQL integration without add-time introspection", async () => {
89+ await page.getByRole("button", { name: "Add integration" }).click();
90+ await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
91+ await page.getByText("Connections").first().waitFor();
92+ await page.getByText("No connections yet").waitFor();
93+ });
94+
95+ await step("Connect an API key", async () => {
96+ await page.getByRole("button", { name: "Add connection" }).first().click();
97+ const dialog = page.getByRole("dialog");
98+ await dialog.getByRole("tab", { name: "API key (Authorization)" }).waitFor();
99+ await dialog.getByRole("textbox", { name: "Authorization" }).fill("lin_test_key");
100+ await dialog.getByRole("button", { name: "Continue" }).click();
101+ await dialog.getByLabel("Display name").fill("main");
102+ await dialog.getByRole("button", { name: "Add connection" }).click();
103+ await page.getByText("Connection added").waitFor({ timeout: 30_000 });
104+ await page.getByText("main").waitFor();
105+ });
106+
107+ await step("Open Tools and observe the empty catalog with no error", async () => {
108+ await page.getByRole("tab", { name: "Tools" }).click();
109+ await page.getByPlaceholder("Filter 0 tools…").waitFor({ timeout: 30_000 });
110+ await page.getByText("No tools available").first().waitFor();
111+
112+ const visibleErrorCopy = await page
113+ .getByText(
114+ /Failed to add connection|Failed to load tools|Introspection failed|invalid shape/i,
115+ )
116+ .count();
117+ expect(
118+ visibleErrorCopy,
119+ "no visible error explains the invalid introspection body",
120+ ).toBe(0);
121+ });
122+ });
123+
124+ const connections = yield* apiClient.connections.list({
125+ query: { integration: slug },
126+ });
127+ expect(connections.length, "the connection was created").toBe(1);
128+
129+ const tools = yield* apiClient.tools.list({ query: { integration: slug } });
130+ expect(tools, "the connected GraphQL integration produced no tools").toEqual([]);
131+ }),
132+ Effect.gen(function* () {
133+ const connections = yield* apiClient.connections
134+ .list({ query: { integration: slug } })
135+ .pipe(Effect.catch(() => Effect.succeed([])));
136+ for (const connection of connections) {
137+ yield* apiClient.connections
138+ .remove({
139+ params: {
140+ owner: connection.owner,
141+ integration: slug,
142+ name: ConnectionName.make(String(connection.name)),
143+ },
144+ })
145+ .pipe(Effect.ignore);
146+ }
147+ yield* apiClient.integrations.remove({ params: { slug } }).pipe(Effect.ignore);
148+ }),
149+ );
150+ }),
151+ ),
152+);