@jskit-ai/connectors-catalog 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/docs/ai-upstream-notices.md +51 -0
- package/docs/ai.md +191 -0
- package/docs/airtable.md +130 -0
- package/docs/algolia.md +190 -0
- package/docs/amazon-redshift.md +229 -0
- package/docs/amplitude.md +210 -0
- package/docs/apify.md +162 -0
- package/docs/apollo-io.md +200 -0
- package/docs/asana.md +126 -0
- package/docs/ashby.md +188 -0
- package/docs/atlassian.md +224 -0
- package/docs/attention.md +162 -0
- package/docs/aws-athena.md +150 -0
- package/docs/aws-credentials.md +75 -0
- package/docs/aws-s3.md +193 -0
- package/docs/bigquery.md +146 -0
- package/docs/brevo.md +122 -0
- package/docs/calendly.md +158 -0
- package/docs/canva.md +191 -0
- package/docs/chargebee.md +192 -0
- package/docs/clay.md +194 -0
- package/docs/clickhouse.md +242 -0
- package/docs/confidence-exp.md +74 -0
- package/docs/confidence-flags.md +75 -0
- package/docs/confidence-oauth.md +137 -0
- package/docs/contentful.md +123 -0
- package/docs/databricks.md +180 -0
- package/docs/dbt-semantic-layer.md +175 -0
- package/docs/elevenlabs.md +102 -0
- package/docs/figma.md +132 -0
- package/docs/firebase-cloud-messaging.md +262 -0
- package/docs/firecrawl.md +137 -0
- package/docs/fireflies.md +95 -0
- package/docs/fireworks-ai.md +53 -0
- package/docs/gatewayapi.md +113 -0
- package/docs/gemini-enterprise.md +224 -0
- package/docs/github-api.md +133 -0
- package/docs/gitlab-api.md +138 -0
- package/docs/gmail.md +133 -0
- package/docs/gong.md +205 -0
- package/docs/google-ads.md +156 -0
- package/docs/google-analytics.md +202 -0
- package/docs/google-docs.md +105 -0
- package/docs/google-drive.md +108 -0
- package/docs/google-maps-platform.md +208 -0
- package/docs/google-oauth.md +123 -0
- package/docs/google-search-console.md +124 -0
- package/docs/google-sheets.md +94 -0
- package/docs/google-slides.md +95 -0
- package/docs/granola.md +178 -0
- package/docs/hex.md +161 -0
- package/docs/heygen.md +124 -0
- package/docs/hubspot.md +142 -0
- package/docs/incident-io.md +118 -0
- package/docs/inngest.md +178 -0
- package/docs/klipy.md +149 -0
- package/docs/lexware.md +144 -0
- package/docs/lightspeed.md +193 -0
- package/docs/linear.md +137 -0
- package/docs/linkedin.md +196 -0
- package/docs/logo-dev.md +152 -0
- package/docs/mailgun.md +153 -0
- package/docs/mapbox.md +174 -0
- package/docs/microsoft-excel.md +97 -0
- package/docs/microsoft-fabric.md +249 -0
- package/docs/microsoft-oauth.md +143 -0
- package/docs/microsoft-onedrive.md +76 -0
- package/docs/microsoft-onenote.md +75 -0
- package/docs/microsoft-outlook.md +90 -0
- package/docs/microsoft-powerpoint.md +119 -0
- package/docs/microsoft-sharepoint.md +75 -0
- package/docs/microsoft-teams.md +72 -0
- package/docs/microsoft-word.md +119 -0
- package/docs/miro.md +117 -0
- package/docs/n8n.md +169 -0
- package/docs/notion.md +137 -0
- package/docs/oura.md +161 -0
- package/docs/paddle.md +168 -0
- package/docs/perplexity.md +85 -0
- package/docs/pipedrive.md +120 -0
- package/docs/polar.md +63 -0
- package/docs/posthog.md +227 -0
- package/docs/prestashop.md +151 -0
- package/docs/replicate.md +60 -0
- package/docs/resend.md +153 -0
- package/docs/salesforce.md +234 -0
- package/docs/sanity.md +122 -0
- package/docs/semrush.md +276 -0
- package/docs/sentry.md +112 -0
- package/docs/sevdesk.md +107 -0
- package/docs/shopify.md +206 -0
- package/docs/slack.md +257 -0
- package/docs/snowflake.md +271 -0
- package/docs/storyblok.md +130 -0
- package/docs/stripe.md +77 -0
- package/docs/supabase.md +51 -0
- package/docs/tally.md +105 -0
- package/docs/telegram.md +165 -0
- package/docs/tiktok.md +201 -0
- package/docs/twilio.md +284 -0
- package/docs/twitch.md +428 -0
- package/docs/wave.md +440 -0
- package/docs/wix.md +1249 -0
- package/docs/wiz.md +177 -0
- package/docs/woocommerce.md +400 -0
- package/docs/wordpress-com.md +358 -0
- package/docs/wordpress-self-hosted.md +252 -0
- package/docs/workday.md +292 -0
- package/docs/x-twitter.md +188 -0
- package/docs/xero.md +429 -0
- package/docs/zoho-books.md +214 -0
- package/docs/zoho-crm.md +192 -0
- package/package.json +153 -0
- package/patterns/ai-connections/PATTERN.md +84 -0
- package/patterns/ai-connections/example/ai-model.js +11 -0
- package/patterns/ai-connections/example/perplexity-answer.js +29 -0
- package/patterns/api-key-connection/PATTERN.md +368 -0
- package/patterns/api-key-connection/example/connections.js +18 -0
- package/patterns/api-key-connection/example/integrations.json +19 -0
- package/patterns/assistant-mcp/PATTERN.md +110 -0
- package/patterns/assistant-mcp/example/integrations.json +13 -0
- package/patterns/assistant-mcp-oauth/PATTERN.md +235 -0
- package/patterns/assistant-mcp-oauth/example/integrations.json +102 -0
- package/patterns/aws-storage-queries/PATTERN.md +169 -0
- package/patterns/aws-storage-queries/example/formats/data-formats.js +35 -0
- package/patterns/aws-storage-queries/example/formats/package-lock.json +49 -0
- package/patterns/aws-storage-queries/example/formats/package.json +12 -0
- package/patterns/aws-storage-queries/example/formats/verify-formats.mjs +37 -0
- package/patterns/aws-storage-queries/example/integrations.json +38 -0
- package/patterns/aws-storage-queries/example/s3-transfer.js +34 -0
- package/patterns/event-delivery/PATTERN.md +151 -0
- package/patterns/event-delivery/example/integrations.json +17 -0
- package/patterns/firebase-messaging/PATTERN.md +156 -0
- package/patterns/firebase-messaging/example/server/notifications.js +23 -0
- package/patterns/google-ads-search/PATTERN.md +92 -0
- package/patterns/google-ads-search/example/ads-setup.js +25 -0
- package/patterns/oauth-connection/PATTERN.md +724 -0
- package/patterns/oauth-connection/example/integrations.json +20 -0
- package/patterns/paddle-catalogue/PATTERN.md +78 -0
- package/patterns/paddle-catalogue/example/create-products.js +38 -0
- package/patterns/public-image/PATTERN.md +84 -0
- package/patterns/public-image/example/integrations.json +13 -0
- package/patterns/public-image/example/logo-url.js +16 -0
- package/patterns/redshift-queries/PATTERN.md +150 -0
- package/patterns/redshift-queries/example/integrations.json +33 -0
- package/patterns/source-scanning/PATTERN.md +83 -0
- package/patterns/source-scanning/example/source-scanner.js +13 -0
- package/src/client/firebase-cloud-messaging.js +10 -0
- package/src/client/google-analytics.js +72 -0
- package/src/client/google-maps-platform.js +39 -0
- package/src/client/logo-dev.js +36 -0
- package/src/server/ai.js +46 -0
- package/src/server/airtable.js +67 -0
- package/src/server/algolia.js +63 -0
- package/src/server/amazon-redshift.js +96 -0
- package/src/server/amplitude.js +31 -0
- package/src/server/apify.js +68 -0
- package/src/server/apollo-io.js +85 -0
- package/src/server/asana.js +64 -0
- package/src/server/ashby.js +61 -0
- package/src/server/atlassian.js +32 -0
- package/src/server/attention.js +93 -0
- package/src/server/aws-athena.js +82 -0
- package/src/server/aws-s3.js +49 -0
- package/src/server/awsTransport.js +75 -0
- package/src/server/bigquery.js +97 -0
- package/src/server/brevo.js +97 -0
- package/src/server/calendly.js +77 -0
- package/src/server/canva.js +13 -0
- package/src/server/chargebee.js +88 -0
- package/src/server/clay.js +78 -0
- package/src/server/clickhouse.js +66 -0
- package/src/server/confidence.js +29 -0
- package/src/server/contentful.js +40 -0
- package/src/server/databricks.js +55 -0
- package/src/server/dbt-semantic-layer.js +95 -0
- package/src/server/elevenlabs.js +81 -0
- package/src/server/figma.js +24 -0
- package/src/server/firebase-cloud-messaging.js +111 -0
- package/src/server/firecrawl.js +70 -0
- package/src/server/fireflies.js +31 -0
- package/src/server/fireworks-ai.js +16 -0
- package/src/server/gatewayapi.js +48 -0
- package/src/server/gemini-enterprise.js +47 -0
- package/src/server/github-api.js +90 -0
- package/src/server/gitlab-api.js +67 -0
- package/src/server/gmail.js +60 -0
- package/src/server/gong.js +52 -0
- package/src/server/google-ads-search.js +134 -0
- package/src/server/google-ads.js +80 -0
- package/src/server/google-docs.js +36 -0
- package/src/server/google-drive.js +71 -0
- package/src/server/google-maps-platform.js +90 -0
- package/src/server/google-search-console.js +31 -0
- package/src/server/google-sheets.js +60 -0
- package/src/server/google-slides.js +36 -0
- package/src/server/google.js +61 -0
- package/src/server/granola.js +115 -0
- package/src/server/graphqlOperation.js +15 -0
- package/src/server/hex.js +29 -0
- package/src/server/heygen.js +54 -0
- package/src/server/hubspot.js +85 -0
- package/src/server/incident-io.js +68 -0
- package/src/server/inngest.js +86 -0
- package/src/server/jsonOperation.js +34 -0
- package/src/server/klipy.js +34 -0
- package/src/server/lexware.js +59 -0
- package/src/server/lightspeed.js +82 -0
- package/src/server/linear.js +52 -0
- package/src/server/linkedin.js +74 -0
- package/src/server/mailgun.js +76 -0
- package/src/server/mapbox.js +46 -0
- package/src/server/mcpRegistration.js +41 -0
- package/src/server/mcpTransport.js +60 -0
- package/src/server/microsoft-excel.js +67 -0
- package/src/server/microsoft-fabric.js +81 -0
- package/src/server/microsoft-onedrive.js +58 -0
- package/src/server/microsoft-onenote.js +52 -0
- package/src/server/microsoft-outlook.js +58 -0
- package/src/server/microsoft-powerpoint.js +5 -0
- package/src/server/microsoft-sharepoint.js +46 -0
- package/src/server/microsoft-teams.js +29 -0
- package/src/server/microsoft-word.js +5 -0
- package/src/server/microsoft.js +88 -0
- package/src/server/miro.js +29 -0
- package/src/server/n8n.js +78 -0
- package/src/server/notion.js +76 -0
- package/src/server/oura.js +48 -0
- package/src/server/paddle.js +83 -0
- package/src/server/perplexity.js +15 -0
- package/src/server/pipedrive.js +95 -0
- package/src/server/polar.js +20 -0
- package/src/server/posthog.js +44 -0
- package/src/server/prestashop.js +42 -0
- package/src/server/replicate.js +42 -0
- package/src/server/resend.js +93 -0
- package/src/server/salesforce.js +84 -0
- package/src/server/sanity.js +31 -0
- package/src/server/semrush.js +359 -0
- package/src/server/sentry.js +30 -0
- package/src/server/sevdesk.js +20 -0
- package/src/server/shopify.js +99 -0
- package/src/server/slack.js +143 -0
- package/src/server/snowflake.js +139 -0
- package/src/server/storyblok.js +49 -0
- package/src/server/stripe.js +13 -0
- package/src/server/supabase.js +13 -0
- package/src/server/tally.js +78 -0
- package/src/server/telegram.js +55 -0
- package/src/server/tiktok.js +86 -0
- package/src/server/twilio.js +90 -0
- package/src/server/twitch.js +409 -0
- package/src/server/wave.js +520 -0
- package/src/server/wix.js +1048 -0
- package/src/server/wiz.js +117 -0
- package/src/server/woocommerce.js +412 -0
- package/src/server/wordpress-com.js +236 -0
- package/src/server/wordpress-self-hosted.js +134 -0
- package/src/server/workday.js +136 -0
- package/src/server/x-twitter.js +110 -0
- package/src/server/xero.js +397 -0
- package/src/server/zoho-books.js +90 -0
- package/src/server/zoho-crm.js +89 -0
- package/src/server/zohoOAuth.js +21 -0
- package/src/shared/ai-models.json +8049 -0
- package/src/shared/ai.js +63 -0
- package/src/shared/algolia.js +29 -0
- package/src/shared/amazon-redshift.js +51 -0
- package/src/shared/amplitude.js +28 -0
- package/src/shared/atlassian.js +43 -0
- package/src/shared/aws.js +72 -0
- package/src/shared/canva.js +66 -0
- package/src/shared/chargebee.js +26 -0
- package/src/shared/clickhouse.js +38 -0
- package/src/shared/confidence.js +28 -0
- package/src/shared/databricks.js +33 -0
- package/src/shared/dbt-semantic-layer.js +32 -0
- package/src/shared/definitions.js +140 -0
- package/src/shared/figma.js +21 -0
- package/src/shared/firebase-cloud-messaging.js +45 -0
- package/src/shared/gemini-enterprise.js +33 -0
- package/src/shared/gong.js +33 -0
- package/src/shared/google-ads-search.js +31 -0
- package/src/shared/google-ads.js +28 -0
- package/src/shared/google-maps-platform.js +28 -0
- package/src/shared/google.js +133 -0
- package/src/shared/granola.js +25 -0
- package/src/shared/hex.js +28 -0
- package/src/shared/inngest.js +35 -0
- package/src/shared/lightspeed.js +75 -0
- package/src/shared/linkedin.js +27 -0
- package/src/shared/logo-dev.js +21 -0
- package/src/shared/mapbox.js +38 -0
- package/src/shared/mcp.js +92 -0
- package/src/shared/microsoft-fabric.js +47 -0
- package/src/shared/microsoft.js +231 -0
- package/src/shared/miro.js +27 -0
- package/src/shared/oura.js +23 -0
- package/src/shared/posthog.js +33 -0
- package/src/shared/prestashop.js +23 -0
- package/src/shared/salesforce.js +36 -0
- package/src/shared/semrush.js +24 -0
- package/src/shared/shopify.js +51 -0
- package/src/shared/siteUrl.js +12 -0
- package/src/shared/slack.js +88 -0
- package/src/shared/snowflake.js +55 -0
- package/src/shared/tiktok.js +22 -0
- package/src/shared/tokens.js +747 -0
- package/src/shared/twilio.js +41 -0
- package/src/shared/twitch.js +49 -0
- package/src/shared/wave.js +52 -0
- package/src/shared/wix.js +40 -0
- package/src/shared/wiz.js +41 -0
- package/src/shared/wordpress-com.js +30 -0
- package/src/shared/wordpress.js +69 -0
- package/src/shared/workday.js +50 -0
- package/src/shared/x-twitter.js +19 -0
- package/src/shared/xero.js +46 -0
- package/src/shared/zoho-books.js +39 -0
- package/src/shared/zoho-crm.js +43 -0
- package/src/shared/zoho-regions.js +12 -0
- package/test/ai.test.js +136 -0
- package/test/airtable.test.js +125 -0
- package/test/algolia.test.js +207 -0
- package/test/amazon-redshift.test.js +272 -0
- package/test/amplitude.test.js +239 -0
- package/test/apify.test.js +93 -0
- package/test/apollo-io.test.js +125 -0
- package/test/asana.test.js +99 -0
- package/test/ashby.test.js +97 -0
- package/test/atlassian.test.js +249 -0
- package/test/attention.test.js +79 -0
- package/test/aws.test.js +405 -0
- package/test/brevo.test.js +71 -0
- package/test/calendly.test.js +136 -0
- package/test/canva.test.js +258 -0
- package/test/chargebee.test.js +195 -0
- package/test/clickhouse.test.js +278 -0
- package/test/confidence.test.js +205 -0
- package/test/databricks.test.js +253 -0
- package/test/dbt-semantic-layer.test.js +221 -0
- package/test/designMcp.test.js +336 -0
- package/test/elevenlabs.test.js +52 -0
- package/test/firebase-cloud-messaging.test.js +242 -0
- package/test/firecrawl.test.js +66 -0
- package/test/gemini-enterprise.test.js +218 -0
- package/test/github-api.test.js +167 -0
- package/test/gitlab-api.test.js +160 -0
- package/test/gong.test.js +192 -0
- package/test/google-ads.test.js +350 -0
- package/test/google-maps-platform.test.js +191 -0
- package/test/google.test.js +710 -0
- package/test/granola.test.js +240 -0
- package/test/hex.test.js +253 -0
- package/test/heygen.test.js +121 -0
- package/test/hubspot.test.js +103 -0
- package/test/inngest.test.js +219 -0
- package/test/lightspeed.test.js +250 -0
- package/test/linear.test.js +146 -0
- package/test/linkedin.test.js +243 -0
- package/test/logo-dev.test.js +79 -0
- package/test/mailgun.test.js +107 -0
- package/test/mapbox.test.js +181 -0
- package/test/mcp.test.js +227 -0
- package/test/microsoft-fabric.test.js +258 -0
- package/test/microsoft.test.js +554 -0
- package/test/n8nOAuth.test.js +188 -0
- package/test/notionOAuth.test.js +183 -0
- package/test/oura.test.js +211 -0
- package/test/paddleCatalogue.test.js +51 -0
- package/test/paddlePermissions.test.js +101 -0
- package/test/perplexityComposition.test.js +39 -0
- package/test/pipedriveOAuth.test.js +140 -0
- package/test/posthog.test.js +200 -0
- package/test/prestashop.test.js +207 -0
- package/test/resend.test.js +163 -0
- package/test/salesforce.test.js +276 -0
- package/test/semrush.test.js +479 -0
- package/test/sentry.test.js +35 -0
- package/test/shopify.test.js +247 -0
- package/test/slack.test.js +320 -0
- package/test/snowflake.test.js +391 -0
- package/test/tally.test.js +75 -0
- package/test/telegram.test.js +58 -0
- package/test/tiktok.test.js +243 -0
- package/test/tokens.test.js +1079 -0
- package/test/twilio.test.js +221 -0
- package/test/twitch.test.js +573 -0
- package/test/wave.test.js +678 -0
- package/test/wix.test.js +1099 -0
- package/test/wiz.test.js +185 -0
- package/test/wordpress-com.test.js +469 -0
- package/test/wordpress.test.js +660 -0
- package/test/workday.test.js +314 -0
- package/test/x-twitter.test.js +256 -0
- package/test/xero.test.js +700 -0
- package/test/zoho-books.test.js +253 -0
- package/test/zoho-crm.test.js +223 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { granolaProvider, registerGranolaClient } from "../src/server/granola.js";
|
|
10
|
+
|
|
11
|
+
const noteId = "not_12345678901234";
|
|
12
|
+
const folderId = "fol_12345678901234";
|
|
13
|
+
const summary = { id: noteId, object: "note", title: null, owner: { name: null, email: "fixture@example.test" }, created_at: "2026-09-08T12:00:00Z", updated_at: "2026-09-08T13:00:00Z" };
|
|
14
|
+
const folder = { id: folderId, object: "folder", name: "Fixture folder", parent_folder_id: null };
|
|
15
|
+
const transcript = [{ speaker: { source: "microphone", diarization_label: "Speaker A" }, text: "Fixture text", start_time: "2026-09-08T12:00:00Z", end_time: "2026-09-08T12:00:01Z" }];
|
|
16
|
+
const note = { ...summary, summary_text: "Fixture summary", summary_markdown: null, private_notes_text: null, private_notes_markdown: null, transcript: null };
|
|
17
|
+
const context = { applicationId: "app-one", subjectId: "owner-one" };
|
|
18
|
+
const args = { context, integrationId: "meetings" };
|
|
19
|
+
|
|
20
|
+
async function fixture(t) {
|
|
21
|
+
const directory = await mkdtemp(path.join(tmpdir(), "granola-connector-"));
|
|
22
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
23
|
+
const state = { key: "grn_private-fixture-key", status: 200, value: undefined, hang: false };
|
|
24
|
+
const requests = [];
|
|
25
|
+
const options = {
|
|
26
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { meetings: {
|
|
27
|
+
provider: "granola", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:GRANOLA_KEY" },
|
|
28
|
+
settings: { apiUrl: "https://invalid.test" }
|
|
29
|
+
} } },
|
|
30
|
+
providers: [{ ...granolaProvider, requestTimeoutMs: 50 }], authorize: async (owner) => owner,
|
|
31
|
+
resolveReference: async () => state.key,
|
|
32
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(9) }, activeKeyId: "current" }) }),
|
|
33
|
+
fetchImpl: async (address, init) => {
|
|
34
|
+
const url = new URL(String(address)); requests.push({ url, init });
|
|
35
|
+
assert.equal(url.origin, "https://public-api.granola.ai"); assert.equal(init.method, "GET"); assert.equal(init.redirect, "error"); assert.equal(init.credentials, "omit");
|
|
36
|
+
assert.equal(new Headers(init.headers).get("authorization"), `Bearer ${state.key}`);
|
|
37
|
+
assert.equal(url.searchParams.has("api_key"), false);
|
|
38
|
+
if (state.hang) return new Promise((_, reject) => {
|
|
39
|
+
const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
|
|
40
|
+
const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
|
|
41
|
+
if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
if (state.status === 204) return new Response(null, { status: 204 });
|
|
44
|
+
if (typeof state.value === "string") return new Response(state.value, { status: state.status });
|
|
45
|
+
const value = url.pathname === "/v1/notes" ? { notes: [summary], hasMore: true, cursor: "next+=cursor" } :
|
|
46
|
+
url.pathname === "/v1/folders" ? { folders: [folder], hasMore: false, cursor: null } :
|
|
47
|
+
url.pathname.endsWith("/transcript") ? { transcript, hasMore: true, cursor: "transcript-next" } :
|
|
48
|
+
{ ...note, transcript: url.searchParams.has("include") ? transcript : null };
|
|
49
|
+
return Response.json(state.value === undefined ? value : state.value, { status: state.status });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const service = createConnectionService(options);
|
|
53
|
+
return { service, options, directory, state, requests, connect: () => service.connectApiKey(args) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("Granola verifies a key, encrypts connection files and reads current bindings after restart", async (t) => {
|
|
57
|
+
const f = await fixture(t); assert.equal((await f.connect()).status, "connected");
|
|
58
|
+
assert.equal(f.requests[0].url.pathname, "/v1/notes"); assert.equal(f.requests[0].url.searchParams.get("page_size"), "10");
|
|
59
|
+
for (const name of await readdir(f.directory)) assert.equal((await readFile(path.join(f.directory, name), "utf8")).includes(f.state.key), false);
|
|
60
|
+
const restarted = createConnectionService(f.options); assert.equal((await restarted.status(args)).status, "connected");
|
|
61
|
+
f.state.key = "grn_rotated-key";
|
|
62
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "notes.get", input: { note_id: noteId } }), note);
|
|
63
|
+
assert.equal(f.requests.at(-1).url.search, "");
|
|
64
|
+
await restarted.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected");
|
|
65
|
+
assert.equal(f.requests.length, 2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("Granola reads explicit filter and cursor pages and preserves null fields and anonymous speakers", async (t) => {
|
|
69
|
+
const f = await fixture(t); await f.connect();
|
|
70
|
+
const list = await f.service.invoke({ ...args, operation: "notes.list", input: {
|
|
71
|
+
created_after: "2026-09-01", created_before: "2026-09-09T12:00:00.001Z", updated_after: "2026-09-08T00:00:00Z", folder_id: folderId, cursor: "next+=cursor", page_size: 2
|
|
72
|
+
} });
|
|
73
|
+
assert.equal(list.notes[0].title, null); assert.equal(list.cursor, "next+=cursor");
|
|
74
|
+
assert.equal(f.requests.at(-1).url.searchParams.get("cursor"), "next+=cursor"); assert.equal(f.requests.at(-1).url.searchParams.get("folder_id"), folderId);
|
|
75
|
+
assert.deepEqual((await f.service.invoke({ ...args, operation: "folders.list", input: { page_size: 1 } })).folders, [folder]);
|
|
76
|
+
assert.deepEqual((await f.service.invoke({ ...args, operation: "notes.get", input: { note_id: noteId, include: "transcript" } })).transcript, transcript);
|
|
77
|
+
const page = await f.service.invoke({ ...args, operation: "transcripts.list", input: { note_id: noteId, page_size: 100, cursor: "transcript-next" } });
|
|
78
|
+
assert.equal(page.transcript[0].speaker.attribution, undefined); assert.equal(page.transcript[0].speaker.diarization_label, "Speaker A");
|
|
79
|
+
assert.equal(f.requests.at(-1).url.pathname, `/v1/notes/${noteId}/transcript`); assert.equal(f.requests.length, 5);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("Granola rejects invalid IDs, dates, page bounds, arbitrary destinations and writes before transport", async (t) => {
|
|
83
|
+
const f = await fixture(t); await f.connect(); const before = f.requests.length;
|
|
84
|
+
for (const input of [{ page_size: 31 }, { page_size: 0 }, { page_size: 1.5 }, { created_after: "2026-02-30" }, { created_after: "2026-09-01T25:00:00Z" },
|
|
85
|
+
{ created_after: "2026-09-01T12:00:00+08:00" }, { updated_after: "tomorrow" }, { folder_id: "../secret" }, { cursor: " " }, { cursor: "x".repeat(8193) }, { url: "https://invalid.test" }]) {
|
|
86
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.list", input }), { code: "connector_input_invalid" });
|
|
87
|
+
}
|
|
88
|
+
for (const input of [{ note_id: "../secret" }, { note_id: "550e8400-e29b-41d4-a716-446655440000" }, { note_id: noteId, include: "all" }]) {
|
|
89
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.get", input }), { code: "connector_input_invalid" });
|
|
90
|
+
}
|
|
91
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "transcripts.list", input: { note_id: noteId, page_size: 101 } }), { code: "connector_input_invalid" });
|
|
92
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.create" }), { code: "connector_operation_unknown" });
|
|
93
|
+
assert.equal(f.requests.length, before);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("Granola accepts empty pages but rejects missing cursors, malformed summaries and mismatched note identities", async (t) => {
|
|
97
|
+
const f = await fixture(t); f.state.value = { notes: [], hasMore: false, cursor: null }; await f.connect();
|
|
98
|
+
for (const value of [null, "invalid JSON", {}, { notes: [], hasMore: true, cursor: null }, { notes: [null], hasMore: false, cursor: null },
|
|
99
|
+
{ notes: [summary, summary], hasMore: true, cursor: "next" }, { notes: [], hasMore: "false", cursor: null }, { notes: [], hasMore: false }]) {
|
|
100
|
+
f.state.value = value;
|
|
101
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.list", input: { page_size: 1 } }), { code: "connector_response_invalid" });
|
|
102
|
+
}
|
|
103
|
+
f.state.value = { ...note, id: "not_abcdefghijklmn" };
|
|
104
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.get", input: { note_id: noteId } }), { code: "connector_response_invalid" });
|
|
105
|
+
f.state.value = { transcript: [{ text: "Missing speaker" }], hasMore: false, cursor: null };
|
|
106
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "transcripts.list", input: { note_id: noteId } }), { code: "connector_response_invalid" });
|
|
107
|
+
f.state.value = { folders: [{ ...folder, parent_folder_id: "bad" }], hasMore: false, cursor: null };
|
|
108
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "folders.list" }), { code: "connector_response_invalid" });
|
|
109
|
+
f.state.status = 204; await assert.rejects(f.service.invoke({ ...args, operation: "notes.list" }), { code: "connector_response_invalid" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("Granola distinguishes unavailable notes and oversized inline transcripts without exposing provider text or retrying", async (t) => {
|
|
113
|
+
const f = await fixture(t); await f.connect();
|
|
114
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [404, "connector_resource_not_found"], [413, "connector_response_too_large"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
|
|
115
|
+
f.state.status = status; f.state.value = { error: { code: "TRANSCRIPT_TOO_LARGE", message: "sensitive provider text" } };
|
|
116
|
+
const before = f.requests.length;
|
|
117
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.get", input: { note_id: noteId, include: "transcript" } }), (error) => error.code === code && !error.message.includes("sensitive"));
|
|
118
|
+
assert.equal(f.requests.length, before + 1);
|
|
119
|
+
}
|
|
120
|
+
f.state.status = 200; f.state.value = undefined;
|
|
121
|
+
assert.deepEqual((await f.service.invoke({ ...args, operation: "transcripts.list", input: { note_id: noteId } })).transcript, transcript);
|
|
122
|
+
f.state.status = 401; await assert.rejects(f.service.invoke({ ...args, operation: "notes.list" }), { code: "connector_reconnect_required" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("Granola isolates owners, honors host policy and preserves a saved connection after failed replacement verification", async (t) => {
|
|
126
|
+
const f = await fixture(t); await f.connect();
|
|
127
|
+
for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
|
|
128
|
+
await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "notes.list" }), { code: "connector_reconnect_required" });
|
|
129
|
+
}
|
|
130
|
+
const denied = createConnectionService({ ...f.options, authorize: async () => null });
|
|
131
|
+
await assert.rejects(denied.invoke({ ...args, operation: "notes.list" }), { code: "connector_access_denied" });
|
|
132
|
+
for (const key of ["plain-key", "grn_", "grn_secret\nvalue"]) {
|
|
133
|
+
f.state.key = key; await assert.rejects(f.connect(), { code: "connector_binding_missing" });
|
|
134
|
+
}
|
|
135
|
+
assert.equal(f.requests.length, 1); assert.equal((await f.service.status(args)).status, "reconnect-required");
|
|
136
|
+
f.state.key = "grn_replacement"; f.state.status = 401;
|
|
137
|
+
await assert.rejects(f.connect(), { code: "connector_reconnect_required" });
|
|
138
|
+
f.state.key = "grn_private-fixture-key"; f.state.status = 200;
|
|
139
|
+
assert.equal((await f.service.status(args)).status, "connected");
|
|
140
|
+
await f.service.invoke({ ...args, operation: "notes.list" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("Granola cancellation and timeout reach the provider without replay", async (t) => {
|
|
144
|
+
const f = await fixture(t); await f.connect(); f.state.hang = true;
|
|
145
|
+
const abort = new AbortController(); const pending = f.service.invoke({ ...args, operation: "notes.list", signal: abort.signal });
|
|
146
|
+
setTimeout(() => abort.abort(), 10);
|
|
147
|
+
await assert.rejects(pending, { code: "connector_cancelled" });
|
|
148
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "notes.list" }), { code: "connector_provider_timeout" });
|
|
149
|
+
assert.equal(f.requests.length, 3);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("Granola guide validates the shared reference-only configuration and does not offer API-key mode as per-user OAuth", async () => {
|
|
153
|
+
const guide = await readFile(new URL("../docs/granola.md", import.meta.url), "utf8"); const json = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
|
|
154
|
+
assert.equal(parseIntegrationConfiguration(json, { providers: [granolaProvider] }).integrations.meetings.provider, "granola");
|
|
155
|
+
for (const change of [{ accountMode: "per-user" }, { authentication: { method: "api-key", secretRef: "grn_raw-secret" } },
|
|
156
|
+
{ scopes: ["all-workspace-notes"] }]) {
|
|
157
|
+
const invalid = JSON.parse(json); Object.assign(invalid.integrations.meetings, change);
|
|
158
|
+
assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [granolaProvider] }));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
test("Granola MCP OAuth verifies tools, refreshes after restart and cannot call API-key operations", async (t) => {
|
|
164
|
+
const f = await fixture(t);
|
|
165
|
+
const callback = "http://127.0.0.1:4930/granola/callback";
|
|
166
|
+
const calls = []; let time = Date.now();
|
|
167
|
+
const scopes = ["openid", "profile", "email", "offline_access"];
|
|
168
|
+
const options = { ...f.options, now: () => time,
|
|
169
|
+
configuration: { schemaVersion: 1, registrations: { granola: { source: "own", clientId: "fixture-client",
|
|
170
|
+
clientSecretRef: "env:GRANOLA_SECRET", callbackUrlRef: "env:GRANOLA_CALLBACK" } }, integrations: {
|
|
171
|
+
meetings: { provider: "granola", accountMode: "assistant", scopes, authentication: { method: "oauth2", registrationRef: "granola" } }
|
|
172
|
+
} }, resolveReference: async (ref) => ref === "env:GRANOLA_SECRET" ? "fixture-secret" : callback,
|
|
173
|
+
fetchImpl: async (address, init) => {
|
|
174
|
+
const url = String(address); calls.push({ url, init });
|
|
175
|
+
if (url === "https://mcp-auth.granola.ai/oauth2/token") {
|
|
176
|
+
const body = new URLSearchParams(init.body);
|
|
177
|
+
assert.equal(body.get("client_secret"), "fixture-secret");
|
|
178
|
+
assert.equal(body.get("resource"), "https://mcp.granola.ai/mcp");
|
|
179
|
+
const refresh = body.get("grant_type") === "refresh_token";
|
|
180
|
+
if (refresh) assert.equal(body.get("refresh_token"), "fixture-refresh");
|
|
181
|
+
else { assert(body.get("code_verifier")); assert.equal(body.get("redirect_uri"), callback); }
|
|
182
|
+
return Response.json({ access_token: refresh ? "refreshed-access" : "fixture-access", refresh_token: "fixture-refresh",
|
|
183
|
+
token_type: "Bearer", expires_in: 60, scope: scopes.join(" ") });
|
|
184
|
+
}
|
|
185
|
+
assert.equal(url, "https://mcp.granola.ai/mcp");
|
|
186
|
+
assert.match(new Headers(init.headers).get("authorization"), /^Bearer (fixture-access|refreshed-access)$/u);
|
|
187
|
+
if (init.method === "DELETE") return new Response(null, { status: 204 });
|
|
188
|
+
if (init.method === "GET") return new Response(null, { status: 405 });
|
|
189
|
+
const message = JSON.parse(init.body); calls.at(-1).message = message;
|
|
190
|
+
if (message.id === undefined) return new Response(null, { status: 202 });
|
|
191
|
+
const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion,
|
|
192
|
+
capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } :
|
|
193
|
+
message.method === "tools/list" ? { tools: [] } : { content: [{ type: "text", text: "Fixture note" }] };
|
|
194
|
+
return Response.json({ jsonrpc: "2.0", id: message.id, result });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const service = createConnectionService(options);
|
|
198
|
+
const start = await service.beginAuthorization(args); const url = new URL(start.authorizationUrl);
|
|
199
|
+
assert.equal(url.origin + url.pathname, "https://mcp-auth.granola.ai/oauth2/authorize");
|
|
200
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
201
|
+
const completion = { ...args, callbackUrl: `${callback}?state=${url.searchParams.get("state")}&code=fixture` };
|
|
202
|
+
assert.equal((await service.completeAuthorization(completion)).status, "connected");
|
|
203
|
+
assert.equal(calls.some((call) => call.message?.method === "tools/call"), false);
|
|
204
|
+
const before = calls.length;
|
|
205
|
+
await assert.rejects(service.invoke({ ...args, operation: "notes.list" }), { code: "connector_mode_unavailable" });
|
|
206
|
+
assert.equal(calls.length, before);
|
|
207
|
+
await assert.rejects(service.completeAuthorization(completion));
|
|
208
|
+
time += 61000;
|
|
209
|
+
const restarted = createConnectionService(options);
|
|
210
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "tools.list" }), { tools: [] });
|
|
211
|
+
assert.equal(calls.filter((call) => call.url.endsWith("/token")).length, 2);
|
|
212
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "tools.call", input: { name: "get_account_info", arguments: {} } }),
|
|
213
|
+
{ content: [{ type: "text", text: "Fixture note" }] });
|
|
214
|
+
await restarted.disconnect(args); assert.equal((await restarted.status(args)).status, "disconnected");
|
|
215
|
+
const key = await fixture(t); await key.connect(); const keyRequests = key.requests.length;
|
|
216
|
+
await assert.rejects(key.service.invoke({ ...args, operation: "tools.list" }), { code: "connector_mode_unavailable" });
|
|
217
|
+
assert.equal(key.requests.length, keyRequests);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("Granola registers the host callback through the existing MCP registration contract", async () => {
|
|
221
|
+
const requests = [];
|
|
222
|
+
const result = await registerGranolaClient({ clientName: "Fixture host", callbackUrl: "https://app.example/integrations/granola/callback" }, {
|
|
223
|
+
fetchImpl: async (url, init) => {
|
|
224
|
+
requests.push({ url: String(url), body: JSON.parse(init.body) });
|
|
225
|
+
return Response.json({ ...JSON.parse(init.body), client_id: "client", client_secret: "secret" });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
assert.deepEqual(result, { clientId: "client", clientSecret: "secret" });
|
|
229
|
+
assert.equal(requests.length, 1);
|
|
230
|
+
assert.equal(requests[0].url, "https://mcp-auth.granola.ai/oauth2/register");
|
|
231
|
+
assert.deepEqual(requests[0].body.redirect_uris, ["https://app.example/integrations/granola/callback"]);
|
|
232
|
+
assert.equal(requests[0].body.token_endpoint_auth_method, "client_secret_post");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("Granola API keys cannot store OAuth identity scopes", () => {
|
|
236
|
+
const configuration = { schemaVersion: 1, registrations: {}, integrations: { meetings: {
|
|
237
|
+
provider: "granola", accountMode: "shared", scopes: ["openid"], authentication: { method: "api-key", secretRef: "env:GRANOLA_KEY" }
|
|
238
|
+
} } };
|
|
239
|
+
assert.throws(() => parseIntegrationConfiguration(JSON.stringify(configuration), { providers: [granolaProvider] }), error => Boolean(error.fieldErrors?.["integrations.meetings.scopes"]));
|
|
240
|
+
});
|
package/test/hex.test.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { hexProvider, registerHexClient } from "../src/server/hex.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "assistant-host", subjectId: "account-owner" };
|
|
12
|
+
const input = { context, integrationId: "analysis" };
|
|
13
|
+
const callback = "http://127.0.0.1:4919/hex/callback";
|
|
14
|
+
const scopes = ["openid", "profile", "email", "offline_access"];
|
|
15
|
+
const discovery = { tools: [{ name: "search_projects", inputSchema: { type: "object", properties: { query: { type: "string" } } } }] };
|
|
16
|
+
|
|
17
|
+
async function fixture(t, endpoint = "standard") {
|
|
18
|
+
const directory = await mkdtemp(path.join(tmpdir(), "hex-connector-"));
|
|
19
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
20
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(3) }, activeKeyId: "current" });
|
|
21
|
+
const origin = { standard: "https://app.hex.tech", eu: "https://eu.hex.tech", hipaa: "https://hc.hex.tech" }[endpoint];
|
|
22
|
+
const issuer = origin.replace("https://", "https://auth.");
|
|
23
|
+
const state = { time: Date.now(), scopes, status: 200, list: discovery, call: { content: [{ type: "text", text: "Fixture project results" }] } };
|
|
24
|
+
const requests = [];
|
|
25
|
+
const options = {
|
|
26
|
+
configuration: { schemaVersion: 1, registrations: { hex: { source: "own", clientId: "fixture-client",
|
|
27
|
+
clientSecretRef: "env:HEX_CLIENT_SECRET", callbackUrlRef: "env:HEX_CALLBACK" } }, integrations: {
|
|
28
|
+
analysis: { provider: "hex", accountMode: "assistant", scopes, settings: { endpoint },
|
|
29
|
+
authentication: { method: "oauth2", registrationRef: "hex" } }
|
|
30
|
+
} },
|
|
31
|
+
providers: [hexProvider], store: createFileConnectionStore({ directory, protection }), now: () => state.time,
|
|
32
|
+
authorize: async (owner, request) => request.operation === "tools.call" &&
|
|
33
|
+
(request.input.name !== "search_projects" || request.input.arguments.query !== "sales question") ? null : owner,
|
|
34
|
+
resolveReference: async (reference) => reference === "env:HEX_CLIENT_SECRET" ? "fixture-client-secret" : callback,
|
|
35
|
+
fetchImpl: async (address, init) => {
|
|
36
|
+
init.signal.throwIfAborted();
|
|
37
|
+
const url = String(address);
|
|
38
|
+
requests.push({ url, init, headers: new Headers(init.headers) });
|
|
39
|
+
if (url === `${issuer}/oauth2/token`) {
|
|
40
|
+
if (state.tokenError) return Response.json({ error: state.tokenError, error_description: "fixture-client-secret" }, { status: 400 });
|
|
41
|
+
const refresh = new URLSearchParams(init.body).get("grant_type") === "refresh_token";
|
|
42
|
+
return Response.json({ token_type: "Bearer", access_token: refresh ? "refreshed-access" : "initial-access",
|
|
43
|
+
refresh_token: refresh ? "rotated-refresh" : "initial-refresh", expires_in: 60, scope: state.scopes.join(" ") });
|
|
44
|
+
}
|
|
45
|
+
assert.equal(url, `${origin}/mcp`);
|
|
46
|
+
if (init.method === "DELETE") return new Response(null, { status: 204 });
|
|
47
|
+
if (init.method === "GET") return new Response(null, { status: 405 });
|
|
48
|
+
if (state.status !== 200) return new Response("initial-access fixture-client-secret", { status: state.status,
|
|
49
|
+
headers: { "WWW-Authenticate": 'Bearer resource_metadata="https://unexpected.invalid/auth"' } });
|
|
50
|
+
const message = JSON.parse(init.body);
|
|
51
|
+
requests.at(-1).message = message;
|
|
52
|
+
if (message.id === undefined) return new Response(null, { status: 202 });
|
|
53
|
+
const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion,
|
|
54
|
+
capabilities: { tools: {} }, serverInfo: { name: "hex-fixture", version: "1" } }
|
|
55
|
+
: message.method === "tools/list" ? state.list : state.call;
|
|
56
|
+
if (state.pause && message.method === "tools/call") {
|
|
57
|
+
state.started();
|
|
58
|
+
return new Promise((resolve, reject) => init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }));
|
|
59
|
+
}
|
|
60
|
+
return Response.json({ jsonrpc: "2.0", id: message.id, result }, { headers: { "Mcp-Session-Id": "hex-session" } });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const service = createConnectionService(options);
|
|
64
|
+
const complete = async (start, extra = "code=fixture-code") => service.completeAuthorization({ ...input,
|
|
65
|
+
callbackUrl: `${callback}?state=${new URL(start.authorizationUrl).searchParams.get("state")}&${extra}` });
|
|
66
|
+
return { service, options, state, requests, origin, issuer, directory, protection, complete };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const endpoint of ["standard", "eu", "hipaa"]) {
|
|
70
|
+
test(`Hex ${endpoint} uses endpoint-specific PKCE and resource-bound grants, verifies discovery and refreshes after file restart`, async (t) => {
|
|
71
|
+
const { service, options, requests, state, origin, issuer, complete, directory, protection } = await fixture(t, endpoint);
|
|
72
|
+
const start = await service.beginAuthorization(input);
|
|
73
|
+
const url = new URL(start.authorizationUrl);
|
|
74
|
+
assert.equal(url.origin + url.pathname, `${issuer}/oauth2/authorize`);
|
|
75
|
+
assert.equal(url.searchParams.get("resource"), `${origin}/mcp`);
|
|
76
|
+
assert.equal(url.searchParams.get("scope"), scopes.join(" "));
|
|
77
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
78
|
+
assert.equal(requests.length, 0);
|
|
79
|
+
assert.equal((await complete(start)).status, "connected");
|
|
80
|
+
const grant = requests.find((request) => request.url === `${issuer}/oauth2/token`);
|
|
81
|
+
const body = new URLSearchParams(grant.init.body);
|
|
82
|
+
assert.equal(body.get("resource"), `${origin}/mcp`);
|
|
83
|
+
assert.equal(body.get("client_secret"), "fixture-client-secret");
|
|
84
|
+
assert.equal(body.get("redirect_uri"), callback);
|
|
85
|
+
assert(body.get("code_verifier"));
|
|
86
|
+
assert.equal(requests.filter(({ message }) => message?.method === "tools/call").length, 0);
|
|
87
|
+
for (const request of requests.filter(({ url }) => url.endsWith("/mcp"))) {
|
|
88
|
+
assert.equal(request.headers.get("authorization"), "Bearer initial-access");
|
|
89
|
+
assert.equal(request.init.redirect, "error");
|
|
90
|
+
assert.equal(request.init.credentials, "omit");
|
|
91
|
+
}
|
|
92
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
93
|
+
state.time += 61_000;
|
|
94
|
+
assert.deepEqual(await restarted.invoke({ ...input, operation: "tools.list" }), discovery);
|
|
95
|
+
const refresh = new URLSearchParams(requests.findLast(({ url }) => url.endsWith("/token")).init.body);
|
|
96
|
+
assert.equal(refresh.get("resource"), `${origin}/mcp`);
|
|
97
|
+
assert.equal(refresh.get("refresh_token"), "initial-refresh");
|
|
98
|
+
assert.equal(requests.at(-1).headers.get("authorization"), "Bearer refreshed-access");
|
|
99
|
+
assert.equal(requests.at(-1).init.method, "DELETE");
|
|
100
|
+
for (const name of await readdir(directory)) {
|
|
101
|
+
const stored = await readFile(path.join(directory, name), "utf8");
|
|
102
|
+
for (const secret of ["initial-access", "refreshed-access", "initial-refresh", "rotated-refresh", "fixture-client-secret", body.get("code_verifier")]) assert(!stored.includes(secret));
|
|
103
|
+
}
|
|
104
|
+
const count = requests.length;
|
|
105
|
+
await assert.rejects(complete(start), { code: "connector_attempt_invalid" });
|
|
106
|
+
for (const owner of [{ ...context, applicationId: "another-host" }, { ...context, subjectId: "another-owner" }]) {
|
|
107
|
+
await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "tools.list" }), { code: "connector_reconnect_required" });
|
|
108
|
+
}
|
|
109
|
+
assert.equal(requests.length, count);
|
|
110
|
+
await restarted.disconnect(input);
|
|
111
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
112
|
+
assert.equal(requests.length, count);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("Hex configuration and changed endpoints cannot bypass account ownership or reuse a pending grant", async (t) => {
|
|
117
|
+
const { options, service, complete, requests } = await fixture(t);
|
|
118
|
+
const parse = (config) => parseIntegrationConfiguration(JSON.stringify(config), { providers: [hexProvider] });
|
|
119
|
+
for (const [field, value] of [["accountMode", "shared"], ["accountMode", "per-user"], ["scopes", ["admin"]], ["settings", { endpoint: "https://unexpected.invalid" }]]) {
|
|
120
|
+
const config = structuredClone(options.configuration);
|
|
121
|
+
config.integrations.analysis[field] = value;
|
|
122
|
+
assert.throws(() => parse(config));
|
|
123
|
+
}
|
|
124
|
+
const start = await service.beginAuthorization(input);
|
|
125
|
+
const config = structuredClone(options.configuration);
|
|
126
|
+
config.integrations.analysis.settings.endpoint = "eu";
|
|
127
|
+
const changed = createConnectionService({ ...options, configuration: config });
|
|
128
|
+
await assert.rejects(changed.completeAuthorization({ ...input, callbackUrl: `${callback}?code=x&state=${new URL(start.authorizationUrl).searchParams.get("state")}` }), { code: "connector_attempt_invalid" });
|
|
129
|
+
assert.equal(requests.length, 0);
|
|
130
|
+
await complete(await service.beginAuthorization(input));
|
|
131
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
132
|
+
const count = requests.length;
|
|
133
|
+
await assert.rejects(changed.invoke({ ...input, operation: "tools.list" }), { code: "connector_reconnect_required" });
|
|
134
|
+
assert.equal(requests.length, count);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("Hex authorizes exact tools and arguments, preserves tool errors and cancels without replay", async (t) => {
|
|
138
|
+
const { service, complete, requests, state } = await fixture(t);
|
|
139
|
+
await complete(await service.beginAuthorization(input));
|
|
140
|
+
const count = requests.length;
|
|
141
|
+
for (const payload of [{ name: "delete_chart", arguments: {} }, { name: "search_projects", arguments: { query: "private question" } }]) {
|
|
142
|
+
await assert.rejects(service.invoke({ ...input, operation: "tools.call", input: payload }), { code: "connector_access_denied" });
|
|
143
|
+
}
|
|
144
|
+
await assert.rejects(service.invoke({ ...input, operation: "tools.list", input: { url: "https://unexpected.invalid" } }), { code: "connector_input_invalid" });
|
|
145
|
+
assert.equal(requests.length, count);
|
|
146
|
+
const payload = { name: "search_projects", arguments: { query: "sales question" } };
|
|
147
|
+
state.call = { content: [{ type: "text", text: "No matching project" }], isError: true };
|
|
148
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "tools.call", input: payload }), state.call);
|
|
149
|
+
assert.deepEqual(requests.findLast(({ message }) => message?.method === "tools/call").message.params, payload);
|
|
150
|
+
state.pause = true;
|
|
151
|
+
const started = new Promise((resolve) => { state.started = resolve; });
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const pending = assert.rejects(service.invoke({ ...input, operation: "tools.call", input: payload, signal: controller.signal }), { code: "connector_cancelled" });
|
|
154
|
+
await started;
|
|
155
|
+
controller.abort();
|
|
156
|
+
await pending;
|
|
157
|
+
assert.equal(requests.filter(({ message }) => message?.method === "tools/call").length, 2);
|
|
158
|
+
assert.equal(requests.at(-1).init.method, "DELETE");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("Hex denied consent, missing requested identity permissions and malformed discovery never create a grant", async (t) => {
|
|
162
|
+
const { service, complete, requests, state } = await fixture(t);
|
|
163
|
+
await assert.rejects(complete(await service.beginAuthorization(input), "error=access_denied"), { code: "connector_consent_denied" });
|
|
164
|
+
assert.equal(requests.length, 0);
|
|
165
|
+
state.scopes = ["profile"];
|
|
166
|
+
await assert.rejects(complete(await service.beginAuthorization(input)), { code: "connector_scope_missing" });
|
|
167
|
+
assert.equal(requests.length, 1);
|
|
168
|
+
state.scopes = scopes;
|
|
169
|
+
state.list = { wrong: [] };
|
|
170
|
+
await assert.rejects(complete(await service.beginAuthorization(input)), { code: "connector_provider_failed" });
|
|
171
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("Hex HTTP and refresh failures are sanitized and do not follow authentication metadata", async (t) => {
|
|
175
|
+
const { service, complete, requests, state, origin, issuer } = await fixture(t);
|
|
176
|
+
for (const [status, code] of [[401, "connector_reconnect_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
|
|
177
|
+
state.status = status;
|
|
178
|
+
await assert.rejects(complete(await service.beginAuthorization(input)), (error) => {
|
|
179
|
+
assert.equal(error.code, code);
|
|
180
|
+
assert(!error.message.includes("fixture-client-secret"));
|
|
181
|
+
assert.equal(error.cause, undefined);
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
assert(requests.every(({ url }) => url === `${origin}/mcp` || url === `${issuer}/oauth2/token`));
|
|
186
|
+
state.status = 200;
|
|
187
|
+
await complete(await service.beginAuthorization(input));
|
|
188
|
+
state.time += 61_000;
|
|
189
|
+
state.tokenError = "invalid_grant";
|
|
190
|
+
await assert.rejects(service.invoke({ ...input, operation: "tools.list" }), { code: "connector_reconnect_required" });
|
|
191
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("Hex registration uses the SDK and exact selected endpoint with explicit confidential client metadata", async () => {
|
|
195
|
+
for (const endpoint of ["standard", "eu", "hipaa"]) {
|
|
196
|
+
const requests = [];
|
|
197
|
+
const result = await registerHexClient({ endpoint, clientName: "My assistant", callbackUrl: callback, scopes }, {
|
|
198
|
+
fetchImpl: async (url, init) => {
|
|
199
|
+
requests.push({ url: String(url), init });
|
|
200
|
+
return Response.json({ ...JSON.parse(init.body), client_id: "created-client", client_secret: "created-secret", client_secret_expires_at: 0 });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
assert.deepEqual(result, { clientId: "created-client", clientSecret: "created-secret", clientSecretExpiresAt: 0 });
|
|
204
|
+
assert.equal(requests.length, 1);
|
|
205
|
+
assert.equal(requests[0].url, `https://auth.${{ standard: "app.hex.tech", eu: "eu.hex.tech", hipaa: "hc.hex.tech" }[endpoint]}/oauth2/register`);
|
|
206
|
+
assert.equal(requests[0].init.redirect, "error");
|
|
207
|
+
assert.equal(requests[0].init.credentials, "omit");
|
|
208
|
+
assert.deepEqual(JSON.parse(requests[0].init.body), { client_name: "My assistant", redirect_uris: [callback], token_endpoint_auth_method: "client_secret_post",
|
|
209
|
+
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: scopes.join(" ") });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Hex registration rejects invalid input before HTTP and sanitizes ambiguous provider results without retry", async () => {
|
|
214
|
+
const valid = { clientName: "CLI client", callbackUrl: callback, scopes };
|
|
215
|
+
let requests = 0;
|
|
216
|
+
const options = { fetchImpl: async () => { requests++; throw new Error("secret-provider-response"); } };
|
|
217
|
+
for (const fields of [{ endpoint: "other" }, { callbackUrl: "http://remote.example/callback" }, { callbackUrl: "https://user:secret@example.test/callback" },
|
|
218
|
+
{ callbackUrl: "https://example.test/callback?other=1" }, { scopes: [] }, { scopes: ["profile"] }, { scopes: ["openid", "openid"] }, { clientName: "" }]) {
|
|
219
|
+
await assert.rejects(registerHexClient({ ...valid, ...fields }, options));
|
|
220
|
+
}
|
|
221
|
+
assert.equal(requests, 0);
|
|
222
|
+
await assert.rejects(registerHexClient(valid, options), (error) => {
|
|
223
|
+
assert.equal(error.code, "connector_registration_failed");
|
|
224
|
+
assert.equal(error.cause, undefined);
|
|
225
|
+
assert(!error.message.includes("secret-provider-response"));
|
|
226
|
+
return true;
|
|
227
|
+
});
|
|
228
|
+
assert.equal(requests, 1);
|
|
229
|
+
for (const changes of [{ client_secret: undefined }, { token_endpoint_auth_method: "none" }, { redirect_uris: ["https://unexpected.invalid"] }]) {
|
|
230
|
+
await assert.rejects(registerHexClient(valid, { fetchImpl: async (url, init) => Response.json({ ...JSON.parse(init.body),
|
|
231
|
+
client_id: "client", client_secret: "secret", ...changes }) }), { code: "connector_registration_failed" });
|
|
232
|
+
}
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
controller.abort();
|
|
235
|
+
await assert.rejects(registerHexClient(valid, { ...options, signal: controller.signal }), { code: "connector_registration_interrupted" });
|
|
236
|
+
assert.equal(requests, 1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("Hex discovery preserves cursors and empty pages without calling a tool, and the guide imports through the shared parser", async (t) => {
|
|
240
|
+
const { service, complete, requests, state } = await fixture(t);
|
|
241
|
+
state.list = { tools: [], nextCursor: "opaque-next" };
|
|
242
|
+
await complete(await service.beginAuthorization(input));
|
|
243
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "tools.list", input: { cursor: "opaque-next" } }), state.list);
|
|
244
|
+
assert.deepEqual(requests.findLast(({ message }) => message?.method === "tools/list").message.params, { cursor: "opaque-next" });
|
|
245
|
+
assert.equal(requests.some(({ message }) => message?.method === "tools/call"), false);
|
|
246
|
+
const guide = await readFile(new URL("../docs/hex.md", import.meta.url), "utf8");
|
|
247
|
+
const json = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
|
|
248
|
+
const config = parseIntegrationConfiguration(json, { providers: [hexProvider] });
|
|
249
|
+
assert.equal(config.integrations.analysis.settings.endpoint, "standard");
|
|
250
|
+
assert.equal(config.integrations.analysis.accountMode, "assistant");
|
|
251
|
+
delete config.integrations.analysis.settings;
|
|
252
|
+
assert.equal(parseIntegrationConfiguration(JSON.stringify(config), { providers: [hexProvider] }).integrations.analysis.settings.endpoint, "standard");
|
|
253
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { heygenProvider, registerHeyGenClient } from "../src/server/heygen.js";
|
|
9
|
+
|
|
10
|
+
const context = { applicationId: "app-one", subjectId: "owner-one" };
|
|
11
|
+
const args = { context, integrationId: "meetings" };
|
|
12
|
+
async function fixture(t) {
|
|
13
|
+
const directory = await mkdtemp(path.join(tmpdir(), "heygen-oauth-"));
|
|
14
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
15
|
+
const requests = [];
|
|
16
|
+
const options = {
|
|
17
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { meetings: {
|
|
18
|
+
provider: "heygen", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:HEYGEN_KEY" }
|
|
19
|
+
} } },
|
|
20
|
+
providers: [heygenProvider], authorize: async (owner) => owner,
|
|
21
|
+
resolveReference: async () => "fixture-api-key",
|
|
22
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(7) }, activeKeyId: "current" }) }),
|
|
23
|
+
fetchImpl: async (url, init) => {
|
|
24
|
+
requests.push(String(url));
|
|
25
|
+
assert.equal(String(url), "https://api.heygen.com/v3/users/me");
|
|
26
|
+
assert.equal(new Headers(init.headers).get("x-api-key"), "fixture-api-key");
|
|
27
|
+
return Response.json({ data: { username: "fixture" } });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const service = createConnectionService(options);
|
|
31
|
+
return { service, options, requests, connect: () => service.connectApiKey(args) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("HeyGen MCP OAuth verifies tools, refreshes after restart and cannot call API-key operations", async (t) => {
|
|
35
|
+
const f = await fixture(t);
|
|
36
|
+
const callback = "http://127.0.0.1:4930/heygen/callback";
|
|
37
|
+
const calls = []; let time = Date.now();
|
|
38
|
+
const scopes = ["openid", "profile", "email"];
|
|
39
|
+
const tools = [
|
|
40
|
+
{ name: "create_video_agent", description: "Controlled creation fixture", inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] } },
|
|
41
|
+
{ name: "get_video_agent_session", description: "Controlled status fixture", inputSchema: { type: "object", properties: { session_id: { type: "string" } }, required: ["session_id"] } }
|
|
42
|
+
];
|
|
43
|
+
const options = { ...f.options, now: () => time,
|
|
44
|
+
authorize: async (owner, request) => request.operation === "tools.call" && ![
|
|
45
|
+
"create_video_agent", "get_video_agent_session"
|
|
46
|
+
].includes(request.input.name) ? null : owner,
|
|
47
|
+
configuration: { schemaVersion: 1, registrations: { heygen: { source: "own", clientId: "fixture-client",
|
|
48
|
+
clientSecretRef: "env:HEYGEN_SECRET", callbackUrlRef: "env:HEYGEN_CALLBACK" } }, integrations: {
|
|
49
|
+
meetings: { provider: "heygen", accountMode: "assistant", scopes, authentication: { method: "oauth2", registrationRef: "heygen" } }
|
|
50
|
+
} }, resolveReference: async (ref) => ref === "env:HEYGEN_SECRET" ? "fixture-secret" : callback,
|
|
51
|
+
fetchImpl: async (address, init) => {
|
|
52
|
+
const url = String(address); calls.push({ url, init });
|
|
53
|
+
if (url === "https://api2.heygen.com/v1/oauth/token") {
|
|
54
|
+
const body = new URLSearchParams(init.body);
|
|
55
|
+
assert.equal(body.get("client_secret"), "fixture-secret");
|
|
56
|
+
assert.equal(body.get("resource"), "https://mcp.heygen.com");
|
|
57
|
+
const refresh = body.get("grant_type") === "refresh_token";
|
|
58
|
+
if (refresh) assert.equal(body.get("refresh_token"), "fixture-refresh");
|
|
59
|
+
else { assert(body.get("code_verifier")); assert.equal(body.get("redirect_uri"), callback); }
|
|
60
|
+
return Response.json({ access_token: refresh ? "refreshed-access" : "fixture-access", refresh_token: "fixture-refresh",
|
|
61
|
+
token_type: "Bearer", expires_in: 60, scope: scopes.join(" ") });
|
|
62
|
+
}
|
|
63
|
+
assert.equal(url, "https://mcp.heygen.com/mcp/v1/");
|
|
64
|
+
assert.match(new Headers(init.headers).get("authorization"), /^Bearer (fixture-access|refreshed-access)$/u);
|
|
65
|
+
if (init.method === "DELETE") return new Response(null, { status: 204 });
|
|
66
|
+
if (init.method === "GET") return new Response(null, { status: 405 });
|
|
67
|
+
const message = JSON.parse(init.body); calls.at(-1).message = message;
|
|
68
|
+
if (message.id === undefined) return new Response(null, { status: 202 });
|
|
69
|
+
const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion,
|
|
70
|
+
capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } :
|
|
71
|
+
message.method === "tools/list" ? { tools } : { content: [{ type: "text", text: JSON.stringify(
|
|
72
|
+
message.params.name === "create_video_agent" ? { session_id: "fixture-session", status: "pending" } :
|
|
73
|
+
{ session_id: "fixture-session", status: "completed", video_url: "https://media.example/fixture.mp4" }
|
|
74
|
+
) }] };
|
|
75
|
+
return Response.json({ jsonrpc: "2.0", id: message.id, result });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const service = createConnectionService(options);
|
|
79
|
+
const start = await service.beginAuthorization(args); const url = new URL(start.authorizationUrl);
|
|
80
|
+
assert.equal(url.origin + url.pathname, "https://api2.heygen.com/v1/oauth/authorize");
|
|
81
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
82
|
+
const completion = { ...args, callbackUrl: `${callback}?state=${url.searchParams.get("state")}&code=fixture` };
|
|
83
|
+
assert.equal((await service.completeAuthorization(completion)).status, "connected");
|
|
84
|
+
assert.equal(calls.some((call) => call.message?.method === "tools/call"), false);
|
|
85
|
+
const before = calls.length;
|
|
86
|
+
await assert.rejects(service.invoke({ ...args, operation: "profile.read" }), { code: "connector_mode_unavailable" });
|
|
87
|
+
assert.equal(calls.length, before);
|
|
88
|
+
await assert.rejects(service.completeAuthorization(completion));
|
|
89
|
+
time += 61000;
|
|
90
|
+
const restarted = createConnectionService(options);
|
|
91
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "tools.list" }), { tools });
|
|
92
|
+
assert.equal(calls.filter((call) => call.url.endsWith("/token")).length, 2);
|
|
93
|
+
const creation = await restarted.invoke({ ...args, operation: "tools.call", input: { name: "create_video_agent", arguments: { prompt: "A booking reminder" } } });
|
|
94
|
+
assert.equal(JSON.parse(creation.content[0].text).session_id, "fixture-session");
|
|
95
|
+
assert.deepEqual(calls.filter(call => call.message?.method === "tools/call").at(-1).message.params,
|
|
96
|
+
{ name: "create_video_agent", arguments: { prompt: "A booking reminder" } });
|
|
97
|
+
const status = await restarted.invoke({ ...args, operation: "tools.call", input: { name: "get_video_agent_session", arguments: { session_id: "fixture-session" } } });
|
|
98
|
+
assert.equal(JSON.parse(status.content[0].text).status, "completed");
|
|
99
|
+
const beforeDenied = calls.length;
|
|
100
|
+
await assert.rejects(restarted.invoke({ ...args, operation: "tools.call", input: { name: "delete_video", arguments: { video_id: "other-user" } } }));
|
|
101
|
+
assert.equal(calls.length, beforeDenied);
|
|
102
|
+
await restarted.disconnect(args); assert.equal((await restarted.status(args)).status, "disconnected");
|
|
103
|
+
const key = await fixture(t); await key.connect(); const keyRequests = key.requests.length;
|
|
104
|
+
await assert.rejects(key.service.invoke({ ...args, operation: "tools.list" }), { code: "connector_mode_unavailable" });
|
|
105
|
+
assert.equal(key.requests.length, keyRequests);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("HeyGen registers the host callback through the existing MCP registration contract", async () => {
|
|
109
|
+
const requests = [];
|
|
110
|
+
const result = await registerHeyGenClient({ clientName: "Fixture host", callbackUrl: "https://app.example/integrations/heygen/callback" }, {
|
|
111
|
+
fetchImpl: async (url, init) => {
|
|
112
|
+
requests.push({ url: String(url), body: JSON.parse(init.body) });
|
|
113
|
+
return Response.json({ ...JSON.parse(init.body), client_id: "client", client_secret: "secret" });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
assert.deepEqual(result, { clientId: "client", clientSecret: "secret" });
|
|
117
|
+
assert.equal(requests.length, 1);
|
|
118
|
+
assert.equal(requests[0].url, "https://api2.heygen.com/v1/oauth/register");
|
|
119
|
+
assert.deepEqual(requests[0].body.redirect_uris, ["https://app.example/integrations/heygen/callback"]);
|
|
120
|
+
assert.equal(requests[0].body.token_endpoint_auth_method, "client_secret_post");
|
|
121
|
+
});
|