import { Client } from "@notionhq/client"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { connections, createWorkflowConnections, normalizeConnectionRequirements, type WorkflowConnections, } from "./connections.js"; afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); }); describe("connection requirements", () => { it("rejects Calendar declarations that omit explicit access", () => { expect(() => normalizeConnectionRequirements({ work: { type: "calendar" } })).toThrow( "named targets", ); }); it.each([ { scope: "read", readTeammatesCalendars: false }, { permissions: "read" }, { targets: { meetings: { description: "Read meetings", permissions: "read", multiple: true }, }, permissions: "read", }, { readTeammatesCalendars: false }, { permissions: "write", readTeammatesCalendars: false }, { permissions: "read", readTeammatesCalendars: "false" }, ])("rejects invalid Calendar options: %j", (calendar) => { expect(() => Reflect.apply(normalizeConnectionRequirements, undefined, [ { work: { type: "calendar", calendar } }, ]), ).toThrow("named targets"); }); it("rejects Calendar options on other providers", () => { expect(() => normalizeConnectionRequirements({ work: { type: "mail", calendar: { targets: { calendar: { description: "Calendar", permissions: "read", multiple: true, }, }, }, }, }), ).toThrow("only valid on Calendar"); }); it.each([undefined, "many", 1])( "rejects missing or invalid target cardinality: %j", (multiple) => { const connection = connections.calendar({ targets: { destinations: { description: "Destinations", permissions: "read_write", multiple: true, }, }, }); Object.assign(connection.calendar.targets.destinations, { multiple }); expect(() => normalizeConnectionRequirements({ zebra: connection })).toThrow( "multiple boolean", ); }, ); it.each([true, false])( "allows either cardinality with read or write permissions: %s", (multiple) => { for (const permissions of ["read", "read_write"] as const) { const zebra = connections.calendar({ targets: { calendars: { description: "Calendars", permissions, multiple } }, }); expect(normalizeConnectionRequirements({ zebra })[0]?.calendar).toEqual( zebra.calendar, ); } }, ); it("copies target descriptions into the manifest", () => { const connection = connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read_write", multiple: false }, }, }); const normalized = normalizeConnectionRequirements({ work: connection }); if (!connection.calendar) throw new Error("Missing Calendar options"); Object.assign(connection.calendar.targets.calendar, { description: "Changed" }); expect(normalized).toEqual([ { type: "calendar", key: "work", calendar: { targets: { calendar: { description: "Calendar", permissions: "read_write", multiple: false, }, }, }, }, ]); }); it.each(["", " "])("rejects a blank Calendar target description: %j", (description) => { expect(() => normalizeConnectionRequirements({ calendar: connections.calendar({ targets: { meetings: { description, permissions: "read", multiple: true } }, }), }), ).toThrow("descriptions"); }); it("rejects a legacy label alongside a target description", () => { const connection = connections.calendar({ targets: { meetings: { description: "Read meetings", permissions: "read", multiple: true }, }, }); Object.assign(connection.calendar.targets.meetings, { label: "Read meetings" }); expect(() => normalizeConnectionRequirements({ calendar: connection })).toThrow( "descriptions", ); }); it("normalizes object keys into manifest requirements", () => { expect( normalizeConnectionRequirements({ work: connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }), support: connections.slack(), team: connections.slack(), }), ).toEqual([ { type: "calendar", key: "work", calendar: { targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }, }, { type: "slack", key: "support" }, { type: "slack", key: "team" }, ]); }); it.each(["bad/key", "__proto__", "constructor", "prototype"])( "rejects invalid or prototype-sensitive keys: %s", (key) => { expect(() => normalizeConnectionRequirements({ [key]: connections.slack() })).toThrow( "Invalid", ); }, ); it("rejects excessive requirements", () => { const requirements: Record = {}; for (let i = 0; i < 101; i++) requirements[`team${i}`] = connections.slack(); expect(() => normalizeConnectionRequirements(requirements)).toThrow("100"); }); it("rejects old arrays and declarations with hidden or inherited keys", () => { expect(() => Reflect.apply(normalizeConnectionRequirements, undefined, [[connections.slack()]]), ).toThrow("must be an object"); const inherited = { support: connections.slack() }; Object.setPrototypeOf(inherited, { hidden: connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }), }); expect(() => normalizeConnectionRequirements(inherited)).toThrow("only own"); const hidden = { support: connections.slack() }; Object.defineProperty(hidden, "hidden", { value: connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }), }); expect(() => normalizeConnectionRequirements(hidden)).toThrow("enumerable strings"); const symbolic = { [Symbol("support")]: connections.slack() }; expect(() => normalizeConnectionRequirements(symbolic)).toThrow("enumerable strings"); const legacyDeclaration = { support: { ...connections.slack(), key: "other" } }; expect(() => normalizeConnectionRequirements(legacyDeclaration)).toThrow("object property"); }); }); describe("workflow connection runtime", () => { it.each(["gmail", "googleCalendar"])( "rejects a removed %s provider even with an existing binding", (type) => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "legacy", type, id: "legacy-id" }]), ); const runtime = createWorkflowConnections(new Client(), { legacy: { type }, }); expect(() => runtime.legacy).toThrow(`Unsupported connection provider: ${type}`); }, ); it("uses the configured Mail binding and preserves pagination and tool results", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "inbox", type: "mail", id: "mail-id" }]), ); const notion = new Client({ auth: "test" }); const result = { name: "search_emails", content: [{ type: "text", text: "Mailbox search results" }], structuredContent: { threads: [], nextPageToken: "next" }, }; const request = vi.spyOn(notion, "request").mockResolvedValue({ result }); const runtime = createWorkflowConnections(notion, { inbox: connections.mail() }); expect( await runtime.inbox.searchEmails({ userEmailAddress: "mail@example.com", query: "is:unread", pageToken: "previous", }), ).toEqual(result); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "mail-id", action: "searchEmails", args: { userEmailAddress: "mail@example.com", query: "is:unread", pageToken: "previous", }, }, }); }); it("invokes separate bindings for multiple keys of the same provider", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([ { key: "support", type: "slack", id: "support-id" }, { key: "team", type: "slack", id: "team-id" }, { key: "undeclared", type: "slack", id: "hidden-id" }, ]), ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: undefined }); const declarations = { support: connections.slack(), team: connections.slack() }; const runtime = createWorkflowConnections(notion, declarations); Object.assign(declarations, { support: connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }), injected: connections.slack(), }); await runtime.support.findUserByEmail({ email: "support@example.com" }); await runtime.team.findUserByEmail({ email: "team@example.com" }); expect(request.mock.calls.map(([input]) => input.body)).toEqual([ { connection: "support-id", action: "findUserByEmail", args: { email: "support@example.com" }, }, { connection: "team-id", action: "findUserByEmail", args: { email: "team@example.com" }, }, ]); expect(Object.keys(runtime)).toEqual(["support", "team"]); expect(Reflect.get(runtime, "undeclared")).toBeUndefined(); expect(Reflect.get(runtime, "injected")).toBeUndefined(); expect(Reflect.get(runtime, "toString")).toBeUndefined(); expect(runtime.support).toBe(runtime.support); }); it("sends newly exposed Slack actions through the declared binding", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"slack","id":"support-id"}]', ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: {} }); const slack = createWorkflowConnections(notion, { support: connections.slack(), }).support; await slack.getUser({ userId: "U123" }); await slack.getUserMessages({ email: "person@example.com" }); await slack.parseSlackUriOrUrl({ uriOrUrl: "https://example.slack.com/archives/C123/p123", }); await slack.addReactionToMessage({ prefixedMessageUri: "slackMessage://C123/123", reaction: "eyes", }); await slack.removeReactionFromMessage({ prefixedMessageUri: "slackMessage://C123/123", reaction: "eyes", }); expect(request.mock.calls.map(([input]) => input.body)).toEqual([ { connection: "support-id", action: "getUser", args: { userId: "U123" } }, { connection: "support-id", action: "getUserMessages", args: { email: "person@example.com" }, }, { connection: "support-id", action: "parseSlackUriOrUrl", args: { uriOrUrl: "https://example.slack.com/archives/C123/p123" }, }, { connection: "support-id", action: "addReactionToMessage", args: { prefixedMessageUri: "slackMessage://C123/123", reaction: "eyes" }, }, { connection: "support-id", action: "removeReactionFromMessage", args: { prefixedMessageUri: "slackMessage://C123/123", reaction: "eyes" }, }, ]); }); it("routes Slack channel writes through their configured connection", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"slack","id":"support-id"}]', ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: {} }); const slack = createWorkflowConnections(notion, { support: connections.slack(), }).support; await slack.createThreadInChannel({ channelId: "C123", message: "New update" }); await slack.replyInThread({ prefixedThreadUri: "slackMessage://example.slack.com/C123/123/123", message: "Reply", }); await slack.updateMessage({ prefixedMessageUri: "slackMessage://example.slack.com/C123/123/123", message: "Edited update", }); expect(request.mock.calls.map(([input]) => input.body)).toEqual([ { connection: "support-id", action: "createThreadInChannel", args: { channelId: "C123", message: "New update" }, }, { connection: "support-id", action: "replyInThread", args: { prefixedThreadUri: "slackMessage://example.slack.com/C123/123/123", message: "Reply", }, }, { connection: "support-id", action: "updateMessage", args: { prefixedMessageUri: "slackMessage://example.slack.com/C123/123/123", message: "Edited update", }, }, ]); }); it("does not parse missing or malformed bindings until a declared client is used", () => { vi.stubEnv("NOTION_CONNECTION_BINDINGS", "not json"); const runtime = createWorkflowConnections(new Client(), { support: connections.slack() }); expect(Object.keys(runtime)).toEqual(["support"]); expect(() => runtime.support).toThrow(); expect(Object.keys(createWorkflowConnections(new Client(), {}))).toEqual([]); }); it("preserves pagination through a generated OAuth provider method", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"documents","type":"googleDriveOauth","id":"drive-id"}]', ); const notion = new Client({ auth: "test" }); const request = vi .spyOn(notion, "request") .mockResolvedValue({ result: { files: [], nextPageToken: "next" } }); const result = await createWorkflowConnections(notion, { documents: connections.googleDriveOauth(), }).documents.listFiles({ folderId: "folder", pageToken: "previous" }); expect(result.nextPageToken).toBe("next"); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "drive-id", action: "listFiles", args: { folderId: "folder", pageToken: "previous" }, }, }); }); it("uses the configured connector binding without choosing a user in the SDK", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"documents","type":"box","id":"box-id"}]', ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] }); await createWorkflowConnections(notion, { documents: connections.box() }).documents.search({ question: "Quarterly report", }); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "box-id", action: "search", args: { question: "Quarterly report" }, }, }); }); it("rejects a mismatched provider before sending requests", () => { vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"calendar","id":"a"}]'); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request"); expect( () => createWorkflowConnections(notion, { support: connections.slack() }).support, ).toThrow("not slack"); expect(request).not.toHaveBeenCalled(); }); it("invokes a typed Calendar method with the default binding", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"calendar","type":"calendar","id":"calendar-id"}]', ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] }); const result = await createWorkflowConnections(notion, { calendar: connections.calendar({ targets: { calendar: { description: "Calendar", permissions: "read", multiple: true }, }, }), }).calendar.listCalendars({ calendars: { target: "calendar" } }); expect(result).toEqual([]); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "calendar-id", action: "listCalendars", args: { calendars: { target: "calendar" } }, }, }); }); it("lists a target and sends a whole returned calendar through the same connection", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", '[{"key":"zebra","type":"calendar","id":"calendar-id"}]', ); const notion = new Client({ auth: "test" }); const selectedCalendar = { target: "destinations", calendarKey: "opaque-reference", name: "Chosen calendar", isHidden: false, isPrimary: true, isReadOnly: false, isSelected: true, }; const request = vi .spyOn(notion, "request") .mockResolvedValueOnce({ result: [selectedCalendar] }) .mockResolvedValue({ result: {} }); const { zebra } = createWorkflowConnections(notion, { zebra: connections.calendar({ targets: { destinations: { description: "Destinations", permissions: "read_write", multiple: true, }, }, }), }); const selected = await zebra.listCalendars({ calendars: zebra.targets.destinations }); for (const item of selected) { await zebra.createEvents({ timeZone: "UTC", events: [ { calendar: item, summary: "Follow-up", period: { type: "DATE", start: { date: "2026-10-02" }, end: { date: "2026-10-03" }, }, }, ], }); } expect(request).toHaveBeenCalledTimes(2); expect(request.mock.calls[1]?.[0].body).toEqual({ connection: "calendar-id", action: "createEvents", args: { timeZone: "UTC", events: [ { calendar: selectedCalendar, summary: "Follow-up", period: { type: "DATE", start: { date: "2026-10-02" }, end: { date: "2026-10-03" }, }, }, ], }, }); }); it("invokes the exact named binding and unwraps its result", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "support", type: "slack", id: "configured-id" }]), ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: { ok: true } }); const runtime = createWorkflowConnections(notion, { support: connections.slack(), team: connections.slack(), }); await expect( runtime.support.findUserByEmail({ email: "person@example.com" }), ).resolves.toEqual({ ok: true, }); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "configured-id", action: "findUserByEmail", args: { email: "person@example.com" }, }, }); expect(() => runtime.team).toThrow("not bound"); expect(request).toHaveBeenCalledTimes(1); }); it.each([ "{}", '[{"key":"support","type":"slack","id":1}]', '[{"key":"support","type":"slack","id":"a"},{"key":"support","type":"slack","id":"b"}]', ])("rejects malformed or ambiguous bindings: %s", (metadata) => { vi.stubEnv("NOTION_CONNECTION_BINDINGS", metadata); expect( () => createWorkflowConnections(new Client(), { support: connections.slack() }).support, ).toThrow(); }); it("propagates permission failures without retrying an alternate connection", async () => { vi.stubEnv("NOTION_CONNECTION_BINDINGS", '[{"key":"support","type":"slack","id":"a"}]'); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockRejectedValue(new Error("forbidden")); await expect( createWorkflowConnections(notion, { support: connections.slack(), }).support.findUserByEmail({ email: "person@example.com" }), ).rejects.toThrow("forbidden"); expect(request).toHaveBeenCalledTimes(1); }); }); describe("generic OAuth connections", () => { const config = { authorizationEndpoint: "https://github.com/login/oauth/authorize", tokenEndpoint: "https://github.com/login/oauth/access_token", clientId: "client", clientSecretEnv: "GITHUB_CLIENT_SECRET", scope: "repo", }; it("declares configuration with a secret reference", () => { expect(connections.oauth(config)).toEqual({ type: "oauth", oauth: config }); const declaration = connections.oauth(config); expectTypeOf(declaration.type).toEqualTypeOf<"oauth">(); expectTypeOf< keyof WorkflowConnections<{ github: typeof declaration }> >().toEqualTypeOf<"github">(); expectTypeOf>>().toEqualTypeOf(); expect(normalizeConnectionRequirements({ github: declaration })).toEqual([ { type: "oauth", key: "github", oauth: config }, ]); }); it("returns only the token bound to this workflow execution", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "github", type: "oauth", id: "ab-cd" }]), ); vi.stubEnv("NOTION_OAUTH_CONNECTION_ABCD", "scoped-token"); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request"); expect( await createWorkflowConnections(notion, { github: connections.oauth(config), }).github.accessToken(), ).toBe("scoped-token"); expect(request).not.toHaveBeenCalled(); expect( () => createWorkflowConnections(notion, { other: connections.oauth(config) }).other, ).toThrow("not bound"); }); it("keeps separately keyed OAuth tokens isolated and omits undeclared bindings", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([ { key: "github", type: "oauth", id: "first-id" }, { key: "other", type: "oauth", id: "second-id" }, { key: "undeclared", type: "oauth", id: "hidden-id" }, ]), ); vi.stubEnv("NOTION_OAUTH_CONNECTION_FIRSTID", "first-token"); vi.stubEnv("NOTION_OAUTH_CONNECTION_SECONDID", "second-token"); const runtime = createWorkflowConnections(new Client({ auth: "test" }), { github: connections.oauth(config), other: connections.oauth(config), }); expect(await runtime.github.accessToken()).toBe("first-token"); expect(await runtime.other.accessToken()).toBe("second-token"); expect(Object.keys(runtime)).toEqual(["github", "other"]); expect(Reflect.get(runtime, "undeclared")).toBeUndefined(); }); it("requires explicit authorization and does not fall back to worker OAuth", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "github", type: "oauth", id: "unconfigured" }]), ); vi.stubEnv("OAUTH_676974687562_ACCESS_TOKEN", "global-token"); await expect( createWorkflowConnections(new Client({ auth: "test" }), { github: connections.oauth(config), }).github.accessToken(), ).rejects.toThrow("requires authorization"); }); it("rejects a provider connection used as generic OAuth", () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "github", type: "slack", id: "connection" }]), ); expect( () => createWorkflowConnections(new Client({ auth: "test" }), { github: connections.oauth(config), }).github, ).toThrow("not oauth"); }); it.each(["http://localhost/token", "https://user:password@example.com/token"])( "rejects unsafe token endpoint %s", (tokenEndpoint) => { expect(() => connections.oauth({ ...config, tokenEndpoint })).toThrow("HTTPS URLs"); }, ); it("protects the callback state and PKCE parameters", () => { expect(() => connections.oauth({ ...config, authorizationParams: { state: "forged" } }), ).toThrow("protocol fields"); }); }); describe("Calendar targets", () => { it("sends the explicit event read target for server-side grant resolution", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "calendar", type: "calendar", id: "calendar-id" }]), ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] }); const { calendar } = createWorkflowConnections(notion, { calendar: connections.calendar({ targets: { meetings: { description: "Read meetings", permissions: "read", multiple: true }, }, }), }); await calendar.listEvents({ calendars: calendar.targets.meetings, timeMin: "2026-10-01T00:00:00Z", timeMax: "2026-10-02T00:00:00Z", timeZone: "UTC", }); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "calendar-id", action: "listEvents", args: { calendars: { target: "meetings" }, timeMin: "2026-10-01T00:00:00Z", timeMax: "2026-10-02T00:00:00Z", timeZone: "UTC", }, }, }); }); it("rejects multiple Calendar connection declarations", () => { const options = { targets: { meetings: { description: "Meetings", permissions: "read" as const, multiple: true }, }, }; expect(() => normalizeConnectionRequirements({ work: connections.calendar(options), personal: connections.calendar(options), }), ).toThrow("one Calendar connection"); }); it("sends a reusable target key without a provider calendar ID", async () => { vi.stubEnv( "NOTION_CONNECTION_BINDINGS", JSON.stringify([{ key: "calendar", type: "calendar", id: "calendar-id" }]), ); const notion = new Client({ auth: "test" }); const request = vi.spyOn(notion, "request").mockResolvedValue({ result: [] }); const { calendar } = createWorkflowConnections(notion, { calendar: connections.calendar({ targets: { meetings: { description: "Read meetings", permissions: "read", multiple: true }, }, }), }); await calendar.listCalendars({ calendars: calendar.targets.meetings }); expect(request).toHaveBeenCalledWith({ path: "tools/run/internal", method: "post", body: { connection: "calendar-id", action: "listCalendars", args: { calendars: { target: "meetings" } }, }, }); }); });