430 lines · 15.1 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// OpenAPI plugin — spec-in-blob storage coverage.//// The resolved spec text must live in the plugin blob store (content-addressed// `spec/<sha256>`), NOT inline in `integration.config`: the catalog row rides// along on every integrations list, so a multi-MB inline spec turns a// metadata read into a bulk transfer. These tests pin://   - addSpec stores a pointer config (`specHash`, no inline `spec`) and the//     blob round-trips through the store,//   - the e2e path (addSpec → connection → invoke) works off the blob,//   - legacy rows that still inline `spec` resolve tools unchanged,//   - remove + re-add of the same spec is idempotent over the shared blob.// --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest";import { Effect, Schema } from "effect";import { FetchHttpClient, HttpServerRequest } from "effect/unstable/http";import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import {  createExecutor,  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  ToolAddress,  sha256Hex,  type IntegrationConfig,} from "@executor-js/sdk";import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";import { variable } from "@executor-js/sdk/http-auth"; import { openApiPlugin } from "./plugin";import type { OpenapiStore } from "./store";import type { AuthenticationInput } from "./types";import { defsBlobKey, specBlobKey } from "./store";import {  makeOpenApiHttpApiTestIntegrationConfig,  serveOpenApiHttpApiTestServer,  unwrapInvocation,} from "../testing"; const testPlugins = (httpClientLayer = FetchHttpClient.layer) =>  [openApiPlugin({ httpClientLayer }), memoryCredentialsPlugin()] as const; const EchoHeaders = Schema.Struct({  "x-api-key": Schema.optional(Schema.String),}); const EchoGroup = HttpApiGroup.make("items").add(  HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }),);const TestApi = HttpApi.make("testApi").add(EchoGroup); const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>  handlers.handle("echoHeaders", () =>    Effect.gen(function* () {      const req = yield* HttpServerRequest.HttpServerRequest;      return EchoHeaders.make({ "x-api-key": req.headers["x-api-key"] });    }),  ),); const specText = () => {  const spec = makeOpenApiHttpApiTestIntegrationConfig(TestApi, {}).spec;  if (spec.kind === "blob") return spec.value;  return spec.url;}; const specTextWithDefinition = () =>  JSON.stringify({    openapi: "3.1.0",    info: { title: "Catalog", version: "1.0.0" },    paths: {      "/items": {        get: {          operationId: "listItems",          responses: {            "200": {              description: "ok",              content: {                "application/json": {                  schema: {                    type: "array",                    items: { $ref: "#/components/schemas/Item" },                  },                },              },            },          },        },      },    },    components: {      schemas: {        Item: {          type: "object",          properties: { id: { type: "string" } },          required: ["id"],        },      },    },  }); const apiKeyTemplate: AuthenticationInput = {  slug: AuthTemplateSlug.make("apiKey"),  type: "apiKey",  headers: { "x-api-key": [variable("token")] },}; const openApiBlobNamespace = "o:test-tenant/openapi"; describe("OpenAPI plugin — spec blob storage", () => {  it.effect("addSpec stores a content pointer, not the inline spec text", () =>    Effect.scoped(      Effect.gen(function* () {        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));        const text = specText();         yield* executor.openapi.addSpec({          spec: { kind: "blob", value: text },          slug: "blob_api",        });         const config = yield* executor.openapi.getConfig("blob_api");        expect(Object.keys(config ?? {})).not.toContain("spec");        expect(config?.specHash).toBe(yield* sha256Hex(text));      }),    ),  );   it.effect("invokes a tool end-to-end off the blob-backed spec", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOpenApiHttpApiTestServer({          api: TestApi,          handlersLayer: EchoGroupLive,        });        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));         yield* executor.openapi.addSpec({          spec: { kind: "blob", value: server.specJson },          slug: "blob_invoke",          baseUrl: server.baseUrl,          authenticationTemplate: [apiKeyTemplate],        });        yield* executor.connections.create({          owner: "org",          name: ConnectionName.make("main"),          integration: IntegrationSlug.make("blob_invoke"),          template: AuthTemplateSlug.make("apiKey"),          value: "secret-key-123",        });         const result = unwrapInvocation(          yield* executor.execute(            ToolAddress.make("tools.blob_invoke.org.main.items.echoHeaders"),            {},          ),        ).data as { "x-api-key"?: string };         expect(result["x-api-key"]).toBe("secret-key-123");      }),    ),  );   it.effect(    "stale sync preserves tools and definitions when the spec blob parses to a non-object",    () =>      Effect.scoped(        Effect.gen(function* () {          const config = makeTestConfig({ plugins: testPlugins() });          const executor = yield* createExecutor(config);          const text = specTextWithDefinition();          const hash = yield* sha256Hex(text);           yield* executor.openapi.addSpec({            spec: { kind: "blob", value: text },            slug: "corrupt_blob",          });          yield* executor.connections.create({            owner: "org",            name: ConnectionName.make("main"),            integration: IntegrationSlug.make("corrupt_blob"),            template: AuthTemplateSlug.make("none"),            values: {},          });           const beforeTools = yield* executor.tools.list({            integration: IntegrationSlug.make("corrupt_blob"),          });          const beforeDefinitions = yield* Effect.promise(() =>            config.db.findMany("definition", {              where: (b) => b("integration", "=", "corrupt_blob"),            }),          );          expect(beforeTools.length).toBeGreaterThan(0);          expect(beforeDefinitions.length).toBeGreaterThan(0);           yield* Effect.promise(() =>            config.db.updateMany("blob", {              where: (b) =>                b.and(b("namespace", "=", openApiBlobNamespace), b("key", "=", specBlobKey(hash))),              set: { value: JSON.stringify("not an object") },            }),          );          yield* Effect.promise(() =>            config.db.deleteMany("blob", {              where: (b) =>                b.and(b("namespace", "=", openApiBlobNamespace), b("key", "=", defsBlobKey(hash))),            }),          );          yield* Effect.promise(() =>            config.db.updateMany("connection", {              where: (b) => b.and(b("integration", "=", "corrupt_blob"), b("name", "=", "main")),              set: { tools_synced_at: null },            }),          );           const afterTools = yield* executor.tools.list({            integration: IntegrationSlug.make("corrupt_blob"),          });          const afterDefinitions = yield* Effect.promise(() =>            config.db.findMany("definition", {              where: (b) => b("integration", "=", "corrupt_blob"),            }),          );          const connection = yield* executor.connections.get({            owner: "org",            integration: IntegrationSlug.make("corrupt_blob"),            name: ConnectionName.make("main"),          });           expect(afterTools.map((tool) => String(tool.name)).sort()).toEqual(            beforeTools.map((tool) => String(tool.name)).sort(),          );          expect(afterDefinitions).toHaveLength(beforeDefinitions.length);          expect(connection?.lastHealth).toMatchObject({            status: "degraded",            detail: expect.stringContaining("OpenAPI spec could not be parsed"),          });        }),      ),  );   it.effect("stale sync preserves tools and definitions when the spec blob is missing", () =>    Effect.scoped(      Effect.gen(function* () {        const config = makeTestConfig({ plugins: testPlugins() });        const executor = yield* createExecutor(config);        const text = specTextWithDefinition();        const hash = yield* sha256Hex(text);         yield* executor.openapi.addSpec({          spec: { kind: "blob", value: text },          slug: "missing_blob",        });        yield* executor.connections.create({          owner: "org",          name: ConnectionName.make("main"),          integration: IntegrationSlug.make("missing_blob"),          template: AuthTemplateSlug.make("none"),          values: {},        });         const beforeTools = yield* executor.tools.list({          integration: IntegrationSlug.make("missing_blob"),        });        const beforeDefinitions = yield* Effect.promise(() =>          config.db.findMany("definition", {            where: (b) => b("integration", "=", "missing_blob"),          }),        );        expect(beforeTools.length).toBeGreaterThan(0);        expect(beforeDefinitions.length).toBeGreaterThan(0);         yield* Effect.promise(() =>          config.db.deleteMany("blob", {            where: (b) =>              b.and(                b("namespace", "=", openApiBlobNamespace),                b("key", "in", [specBlobKey(hash), defsBlobKey(hash)]),              ),          }),        );        yield* Effect.promise(() =>          config.db.updateMany("connection", {            where: (b) => b.and(b("integration", "=", "missing_blob"), b("name", "=", "main")),            set: { tools_synced_at: null },          }),        );         const afterTools = yield* executor.tools.list({          integration: IntegrationSlug.make("missing_blob"),        });        const afterDefinitions = yield* Effect.promise(() =>          config.db.findMany("definition", {            where: (b) => b("integration", "=", "missing_blob"),          }),        );        const connection = yield* executor.connections.get({          owner: "org",          integration: IntegrationSlug.make("missing_blob"),          name: ConnectionName.make("main"),        });         expect(afterTools.map((tool) => String(tool.name)).sort()).toEqual(          beforeTools.map((tool) => String(tool.name)).sort(),        );        expect(afterDefinitions).toHaveLength(beforeDefinitions.length);        expect(connection?.lastHealth).toMatchObject({          status: "degraded",          detail: expect.stringContaining("OpenAPI spec blob could not be loaded"),        });      }),    ),  );   it.effect("explicit spec refresh accepts a valid OpenAPI document with zero operations", () =>    Effect.scoped(      Effect.gen(function* () {        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));        const emptySpec = JSON.stringify({          openapi: "3.1.0",          info: { title: "Empty", version: "1.0.0" },          paths: {},        });         yield* executor.openapi.addSpec({          spec: { kind: "blob", value: specText() },          slug: "healthy_zero",        });        yield* executor.connections.create({          owner: "org",          name: ConnectionName.make("main"),          integration: IntegrationSlug.make("healthy_zero"),          template: AuthTemplateSlug.make("none"),          values: {},        });        expect(          (yield* executor.tools.list({ integration: IntegrationSlug.make("healthy_zero") }))            .length,        ).toBeGreaterThan(0);         const update = yield* executor.openapi.updateSpec("healthy_zero", {          spec: { kind: "blob", value: emptySpec },        });        const tools = yield* executor.tools.list({          integration: IntegrationSlug.make("healthy_zero"),        });         expect(update.toolCount).toBe(0);        expect(tools).toEqual([]);      }),    ),  );   it.effect("resolveTools reads the spec from the store, never an inline field", () =>    Effect.gen(function* () {      const plugin = openApiPlugin({ httpClientLayer: FetchHttpClient.layer });      const text = specText();      const hash = yield* sha256Hex(text);      const storage: OpenapiStore = {        putOperations: () => Effect.void,        appendOperations: () => Effect.void,        getOperation: () => Effect.succeed(null),        listOperations: () => Effect.succeed([]),        removeOperations: () => Effect.void,        putSpec: () => Effect.void,        getSpec: (specHash) => Effect.succeed(specHash === hash ? text : null),        putDefs: () => Effect.void,        getDefs: () => Effect.succeed(null),      };       const resolve = (config: IntegrationConfig) =>        plugin.resolveTools!({          integration: {            slug: IntegrationSlug.make("pointer_api"),            name: "pointer",            description: "pointer",            kind: "openapi",            canRemove: true,            canRefresh: false,            authMethods: [],          },          config,          connection: {            owner: "org",            integration: IntegrationSlug.make("pointer_api"),            name: ConnectionName.make("main"),          },          template: null,          storage,          httpClientLayer: FetchHttpClient.layer,          getValue: () => Effect.succeed(null),          getValues: () => Effect.succeed({}),        });       const fromPointer = yield* resolve({ specHash: hash } as IntegrationConfig);      expect(fromPointer.tools.map((tool) => String(tool.name))).toContain("items.echoHeaders");       // A pre-migration row that still inlines `spec` yields no tools: the      // spec-to-blob migrations rewrite those rows before this code runs, so      // the runtime carries no inline-read path.      const fromInline = yield* resolve({ spec: text } as IntegrationConfig);      expect(fromInline.tools).toHaveLength(0);    }),  );   it.effect("remove + re-add of the same spec is idempotent over the shared blob", () =>    Effect.scoped(      Effect.gen(function* () {        const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));        const text = specText();         yield* executor.openapi.addSpec({ spec: { kind: "blob", value: text }, slug: "re_add" });        yield* executor.openapi.removeSpec("re_add");        // The blob deliberately survives removal (another integration may share        // the hash); re-adding must re-point at it without conflict.        const second = yield* executor.openapi.addSpec({          spec: { kind: "blob", value: text },          slug: "re_add",        });         expect(second.toolCount).toBeGreaterThan(0);        const config = yield* executor.openapi.getConfig("re_add");        expect(config?.specHash).toBe(yield* sha256Hex(text));      }),    ),  );});