720 lines · 24.7 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate, Result } from "effect"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  ProviderItemId,  ProviderKey,  ToolAddress,  ToolName,} from "./ids";import { createExecutor } from "./executor";import { definePlugin } from "./plugin";import type { CredentialProvider } from "./provider";import { makeTestConfig, makeTestExecutor } from "./testing"; // removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh,// SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2// model folds secret/connection into one provider-resolved Connection, and OAuth// refresh is core's responsibility (stubbed for milestone 1). The cases below// cover the v2 connection surface: create (inline + external), list, get,// remove, refresh, and per-connection tool production. const memoryProvider = (): CredentialProvider => {  const store = new Map<string, string>();  return {    key: ProviderKey.make("memory"),    writable: true,    get: (id) => Effect.sync(() => store.get(String(id)) ?? null),    set: (id, value) => Effect.sync(() => void store.set(String(id), value)),    has: (id) => Effect.sync(() => store.has(String(id))),    list: () =>      Effect.sync(() =>        Array.from(store.keys()).map((key) => ({          id: ProviderItemId.make(key),          name: key,        })),      ),  };}; const INTEG = IntegrationSlug.make("vercel");const TEMPLATE = AuthTemplateSlug.make("apiKey"); const demoPlugin = definePlugin(() => ({  id: "demo" as const,  credentialProviders: [memoryProvider()],  storage: () => ({}),  resolveTools: () =>    Effect.succeed({      tools: [        { name: ToolName.make("deploy"), description: "deploy" },        { name: ToolName.make("list"), description: "list" },      ],    }),  invokeTool: ({ toolRow, credential }) =>    Effect.succeed({ ran: toolRow.name, value: credential.value }),  extension: (ctx) => ({    seed: () =>      ctx.core.integrations.register({        slug: INTEG,        description: "Vercel",        config: {},      }),    resolveValue: (owner: "org" | "user", name: string) =>      ctx.connections.resolveValue({        owner,        integration: INTEG,        name: ConnectionName.make(name),      }),  }),}))(); const setup = () =>  makeTestExecutor({ plugins: [demoPlugin] as const }).pipe(    Effect.tap((executor) => executor.demo.seed()),  ); describe("connections.create", () => {  it.effect("inline value writes to the default writable provider and produces tools", () =>    Effect.gen(function* () {      const executor = yield* setup();      const connection = yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("main"),        integration: INTEG,        template: TEMPLATE,        value: "secret-token",      });      expect(connection.provider).toBe(ProviderKey.make("memory"));      expect(String(connection.address)).toBe("tools.vercel.org.main");       const tools = yield* executor.tools.list();      expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);       // The inline value is resolvable via the connection's provider.      const value = yield* executor.demo.resolveValue("org", "main");      expect(value).toBe("secret-token");    }),  );   it.effect("normalizes free-form names into JS-callable connection identifiers", () =>    Effect.gen(function* () {      const executor = yield* setup();      const connection = yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("my-api-key"),        integration: INTEG,        template: TEMPLATE,        value: "secret-token",      });       expect(String(connection.name)).toBe("myApiKey");      expect(String(connection.address)).toBe("tools.vercel.org.myApiKey");       const tools = yield* executor.tools.list();      expect(tools.map((t) => String(t.address)).sort()).toEqual([        "tools.vercel.org.myApiKey.deploy",        "tools.vercel.org.myApiKey.list",      ]);       const value = yield* executor.demo.resolveValue("org", "myApiKey");      expect(value).toBe("secret-token");    }),  );   it.effect("external `from` references a provider item without writing it", () =>    Effect.gen(function* () {      const executor = yield* setup();      const connection = yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("byo"),        integration: INTEG,        template: TEMPLATE,        from: {          provider: ProviderKey.make("memory"),          id: ProviderItemId.make("ext-item"),        },      });      expect(connection.provider).toBe(ProviderKey.make("memory"));      // No value was stored (external reference) — resolveValue returns null.      const value = yield* executor.demo.resolveValue("org", "byo");      expect(value).toBeNull();    }),  );   it.effect("create on an unknown integration fails with IntegrationNotFoundError", () =>    Effect.gen(function* () {      const executor = yield* setup();      const result = yield* Effect.result(        executor.connections.create({          owner: "org",          name: ConnectionName.make("x"),          integration: IntegrationSlug.make("unknown"),          template: TEMPLATE,          value: "v",        }),      );      expect(Result.isFailure(result)).toBe(true);      if (!Result.isFailure(result)) return;      expect(Predicate.isTagged("IntegrationNotFoundError")(result.failure)).toBe(true);    }),  );   // A credentialed connection is "born wired": it must reference at least one  // credential input. An empty binding (an empty `values`/`inputs` map) produces  // a credential with no credential — it persists, produces a full tool catalog,  // and then fails every invocation with `connection_value_missing`. These cases  // must be rejected at create with a typed `InvalidConnectionInputError` (the  // HTTP edge answers 400 with the reason, not an opaque 500). The exception is  // the no-auth template ("none"), where zero inputs and an empty `item_ids`  // map are the canonical shape — covered below. (An empty-STRING value is also  // allowed, and an external `from` that resolves to null is a supported case —  // both covered by their own tests.)  it.effect("rejects an empty `values` map on a credentialed template and persists nothing", () =>    Effect.gen(function* () {      const executor = yield* setup();      const result = yield* Effect.result(        executor.connections.create({          owner: "org",          name: ConnectionName.make("empty"),          integration: INTEG,          template: TEMPLATE,          values: {},        }),      );      expect(Result.isFailure(result)).toBe(true);      if (!Result.isFailure(result)) return;      expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true);      // No connection row and — critically — no tools were produced.      expect(yield* executor.connections.list()).toEqual([]);      expect(yield* executor.tools.list()).toEqual([]);    }),  );   it.effect("rejects an empty `inputs` map on a credentialed template", () =>    Effect.gen(function* () {      const executor = yield* setup();      const result = yield* Effect.result(        executor.connections.create({          owner: "org",          name: ConnectionName.make("empty2"),          integration: INTEG,          template: TEMPLATE,          inputs: {},        }),      );      expect(Result.isFailure(result)).toBe(true);      if (!Result.isFailure(result)) return;      expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true);      expect(yield* executor.connections.list()).toEqual([]);    }),  );   // The no-auth template: public servers need no credential. The UI submits  // `values: {}` for them and the persisted row carries an empty `item_ids`  // map — that is the canonical shape (every migrated no-auth connection in  // prod has it), so it must create cleanly and keep its tools on refresh.  it.effect('creates a no-auth (`template: "none"`) connection from an empty `values` map', () =>    Effect.gen(function* () {      const executor = yield* setup();      const connection = yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("public"),        integration: INTEG,        template: AuthTemplateSlug.make("none"),        values: {},      });      expect(String(connection.address)).toBe("tools.vercel.org.public");       const tools = yield* executor.tools.list();      expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);       // Refresh must NOT treat the empty binding as invalid and wipe the tools.      const refreshed = yield* executor.connections.refresh({        owner: "org",        integration: INTEG,        name: ConnectionName.make("public"),      });      expect(refreshed.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);      expect((yield* executor.tools.list()).length).toBe(2);    }),  );   it.effect("allows an empty-string value (no-auth integrations bind one)", () =>    Effect.gen(function* () {      const executor = yield* setup();      const connection = yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("noauth"),        integration: INTEG,        template: TEMPLATE,        value: "",      });      // The binding exists (non-empty item_ids), so tools are produced; the      // empty value itself is the integration's concern, surfaced at invoke.      expect(String(connection.address)).toBe("tools.vercel.org.noauth");      const tools = yield* executor.tools.list();      expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);    }),  );}); describe("connections.list / get", () => {  it.effect("lists created connections and filters by integration", () =>    Effect.gen(function* () {      const executor = yield* setup();      yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("a"),        integration: INTEG,        template: TEMPLATE,        value: "v",      });      const all = yield* executor.connections.list();      expect(all.map((c) => String(c.name))).toEqual(["a"]);      const filtered = yield* executor.connections.list({ integration: INTEG });      expect(filtered.length).toBe(1);      const get = yield* executor.connections.get({        owner: "org",        integration: INTEG,        name: ConnectionName.make("a"),      });      expect(get?.name).toBe(ConnectionName.make("a"));    }),  );   it.effect("get returns null for an unknown connection", () =>    Effect.gen(function* () {      const executor = yield* setup();      const get = yield* executor.connections.get({        owner: "org",        integration: INTEG,        name: ConnectionName.make("missing"),      });      expect(get).toBeNull();    }),  );}); describe("connections.remove", () => {  it.effect("removes the connection and its tools", () =>    Effect.gen(function* () {      const executor = yield* setup();      yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("main"),        integration: INTEG,        template: TEMPLATE,        value: "v",      });      yield* executor.connections.remove({        owner: "org",        integration: INTEG,        name: ConnectionName.make("main"),      });      const connections = yield* executor.connections.list();      expect(connections).toEqual([]);      const tools = yield* executor.tools.list();      expect(tools).toEqual([]);    }),  );   it.effect("remove on an unknown connection fails with ConnectionNotFoundError", () =>    Effect.gen(function* () {      const executor = yield* setup();      const result = yield* Effect.result(        executor.connections.remove({          owner: "org",          integration: INTEG,          name: ConnectionName.make("missing"),        }),      );      expect(Result.isFailure(result)).toBe(true);      if (!Result.isFailure(result)) return;      expect(Predicate.isTagged("ConnectionNotFoundError")(result.failure)).toBe(true);    }),  );}); describe("connections.refresh", () => {  it.effect("re-produces the connection's tools", () =>    Effect.gen(function* () {      const executor = yield* setup();      yield* executor.connections.create({        owner: "org",        name: ConnectionName.make("main"),        integration: INTEG,        template: TEMPLATE,        value: "v",      });      const tools = yield* executor.connections.refresh({        owner: "org",        integration: INTEG,        name: ConnectionName.make("main"),      });      expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);    }),  );}); describe("tool catalog sync safety", () => {  it.effect(    "background sync preserves a nonzero remote catalog when a plugin returns authoritative empty",    () =>      Effect.scoped(        Effect.gen(function* () {          let empty = false;          const guardedPlugin = definePlugin(() => ({            id: "guarded" as const,            remoteToolCatalog: true,            credentialProviders: [memoryProvider()],            storage: () => ({}),            resolveTools: () =>              Effect.sync(() => ({                tools: empty                  ? []                  : [                      { name: ToolName.make("deploy"), description: "deploy" },                      { name: ToolName.make("list"), description: "list" },                    ],              })),            invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),            extension: (ctx) => ({              seed: () =>                ctx.core.integrations.register({                  slug: INTEG,                  description: "Vercel",                  config: {},                }),            }),          }))();          const config = makeTestConfig({ plugins: [guardedPlugin] as const });          const executor = yield* createExecutor(config);          yield* executor.guarded.seed();          yield* executor.connections.create({            owner: "org",            name: ConnectionName.make("main"),            integration: INTEG,            template: TEMPLATE,            value: "secret-token",          });           empty = true;          yield* Effect.promise(() =>            config.db.updateMany("connection", {              where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),              set: { tools_synced_at: null },            }),          );          const tools = yield* executor.tools.list({ integration: INTEG });          const connection = yield* executor.connections.get({            owner: "org",            integration: INTEG,            name: ConnectionName.make("main"),          });           expect(tools.map((tool) => String(tool.name)).sort()).toEqual(["deploy", "list"]);          expect(connection?.lastHealth).toMatchObject({            status: "degraded",            detail: expect.stringContaining("authoritative empty catalog"),          });        }),      ),  );   it.effect(    "background sync clears a non-remote catalog when a plugin returns authoritative empty",    () =>      Effect.scoped(        Effect.gen(function* () {          let empty = false;          const storedStatePlugin = definePlugin(() => ({            id: "stored-state" as const,            credentialProviders: [memoryProvider()],            storage: () => ({}),            resolveTools: () =>              Effect.sync(() => ({                tools: empty                  ? []                  : [                      { name: ToolName.make("deploy"), description: "deploy" },                      { name: ToolName.make("list"), description: "list" },                    ],              })),            invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }),            extension: (ctx) => ({              seed: () =>                ctx.core.integrations.register({                  slug: INTEG,                  description: "Vercel",                  config: {},                }),            }),          }))();          const config = makeTestConfig({ plugins: [storedStatePlugin] as const });          const executor = yield* createExecutor(config);          yield* executor["stored-state"].seed();          yield* executor.connections.create({            owner: "org",            name: ConnectionName.make("main"),            integration: INTEG,            template: TEMPLATE,            value: "secret-token",          });           empty = true;          yield* Effect.promise(() =>            config.db.updateMany("connection", {              where: (b) => b.and(b("integration", "=", String(INTEG)), b("name", "=", "main")),              set: { tools_synced_at: null },            }),          );          const tools = yield* executor.tools.list({ integration: INTEG });          const connection = yield* executor.connections.get({            owner: "org",            integration: INTEG,            name: ConnectionName.make("main"),          });           expect(tools).toEqual([]);          expect(connection?.lastHealth).toBeNull();        }),      ),  );   it.effect("explicit refresh accepts an authoritative empty catalog", () =>    Effect.scoped(      Effect.gen(function* () {        let empty = false;        const guardedPlugin = definePlugin(() => ({          id: "guarded" as const,          credentialProviders: [memoryProvider()],          storage: () => ({}),          resolveTools: () =>            Effect.sync(() => ({              tools: empty                ? []                : [

Lines 1–500 of 720.