@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,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 { getProviderScopes, parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { databricksProvider } from "../src/server/databricks.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "app-one", subjectId: "owner-one" };
|
|
12
|
+
const args = { context, integrationId: "warehouse" };
|
|
13
|
+
const callback = "https://app.example.test/oauth/databricks/callback";
|
|
14
|
+
const job = { job_id: 123, settings: { name: "Weekly inventory", tasks: [] } };
|
|
15
|
+
|
|
16
|
+
async function fixture(t, grantType = "authorization_code", workspaceUrl = "https://dbc-abc123.cloud.databricks.com") {
|
|
17
|
+
const directory = await mkdtemp(path.join(tmpdir(), "databricks-connector-"));
|
|
18
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
19
|
+
const machine = grantType === "client_credentials";
|
|
20
|
+
const scopes = machine ? ["jobs"] : ["all-apis", "offline_access"];
|
|
21
|
+
const configuration = { schemaVersion: 1, registrations: { databricks: { source: "own", grantType, clientId: "fixture-client",
|
|
22
|
+
clientSecretRef: "env:DATABRICKS_SECRET", tokenEndpointAuthMethod: machine ? "client_secret_basic" : "client_secret_post",
|
|
23
|
+
...(machine ? {} : { callbackUrlRef: "env:DATABRICKS_CALLBACK" }) } }, integrations: {
|
|
24
|
+
warehouse: { provider: "databricks", accountMode: machine ? "shared" : "per-user", settings: { workspaceUrl }, scopes,
|
|
25
|
+
authentication: { method: "oauth2", registrationRef: "databricks" } }
|
|
26
|
+
} };
|
|
27
|
+
const state = { time: Date.now(), count: 0, value: undefined, status: 200, tokenPatch: {}, tokenStatus: 200, hang: false, secret: "private-client-secret" };
|
|
28
|
+
const requests = [];
|
|
29
|
+
const options = { configuration, providers: [{ ...databricksProvider, requestTimeoutMs: 50 }], authorize: async (owner) => owner,
|
|
30
|
+
now: () => state.time, resolveReference: async (reference) => {
|
|
31
|
+
if (reference === "env:DATABRICKS_SECRET") return state.secret;
|
|
32
|
+
assert.equal(machine, false, "A service account must never request a callback binding.");
|
|
33
|
+
assert.equal(reference, "env:DATABRICKS_CALLBACK");
|
|
34
|
+
return callback;
|
|
35
|
+
},
|
|
36
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(6) }, activeKeyId: "current" }) }),
|
|
37
|
+
fetchImpl: async (address, init) => {
|
|
38
|
+
const url = new URL(String(address)); requests.push({ url, init });
|
|
39
|
+
assert.equal(url.origin, new URL(workspaceUrl).origin);
|
|
40
|
+
const headers = new Headers(init.headers);
|
|
41
|
+
if (url.pathname === "/oidc/v1/token") {
|
|
42
|
+
assert.equal(init.method, "POST");
|
|
43
|
+
const body = new URLSearchParams(init.body);
|
|
44
|
+
if (machine) {
|
|
45
|
+
assert.equal(body.get("grant_type"), "client_credentials");
|
|
46
|
+
assert.equal(body.get("scope"), scopes.join(" "));
|
|
47
|
+
const auth = headers.get("authorization"); assert.equal(auth.split(" ")[0], "Basic");
|
|
48
|
+
assert.deepEqual(Buffer.from(auth.split(" ")[1], "base64").toString().split(":").map(decodeURIComponent), ["fixture-client", state.secret]);
|
|
49
|
+
for (const key of ["redirect_uri", "code", "code_verifier", "client_secret", "refresh_token"]) assert.equal(body.has(key), false);
|
|
50
|
+
} else {
|
|
51
|
+
assert.equal(body.get("client_id"), "fixture-client"); assert.equal(body.get("client_secret"), state.secret);
|
|
52
|
+
if (body.get("grant_type") === "authorization_code") {
|
|
53
|
+
assert.equal(body.get("redirect_uri"), callback); assert.equal(body.get("code"), "private-code"); assert.ok(body.get("code_verifier"));
|
|
54
|
+
} else {
|
|
55
|
+
assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), `private-refresh-${state.count}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
state.count++;
|
|
59
|
+
return Response.json(state.tokenStatus === 200 ? { access_token: `private-access-${state.count}`, token_type: "Bearer", expires_in: 120,
|
|
60
|
+
...(machine ? {} : { refresh_token: `private-refresh-${state.count}`, scope: scopes.join(" ") }), ...state.tokenPatch }
|
|
61
|
+
: { error: machine ? "invalid_client" : "invalid_grant", error_description: "private-provider-error" }, { status: state.tokenStatus });
|
|
62
|
+
}
|
|
63
|
+
assert.equal(init.method, "GET"); assert.equal(init.redirect, "error");
|
|
64
|
+
assert.equal(headers.get("authorization"), `Bearer private-access-${state.count}`);
|
|
65
|
+
assert.match(url.pathname, /^\/api\/2\.2\/jobs\/(list|get)$/u);
|
|
66
|
+
if (state.hang) return new Promise((_, reject) => {
|
|
67
|
+
const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
|
|
68
|
+
const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
|
|
69
|
+
if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
|
|
70
|
+
});
|
|
71
|
+
if (state.status === 204) return new Response(null, { status: 204 });
|
|
72
|
+
if (typeof state.value === "string") return new Response(state.value, { status: state.status });
|
|
73
|
+
return Response.json(state.value === undefined ? url.pathname.endsWith("/get") ? job : { jobs: [job], next_page_token: "page+two=" } : state.value, { status: state.status });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const service = createConnectionService(options);
|
|
77
|
+
const start = async () => {
|
|
78
|
+
const { authorizationUrl } = await service.beginAuthorization({ ...args, verificationInput: { limit: 1 } });
|
|
79
|
+
const url = new URL(authorizationUrl); const returned = new URL(callback);
|
|
80
|
+
returned.searchParams.set("state", url.searchParams.get("state")); returned.searchParams.set("code", "private-code");
|
|
81
|
+
return { url, callbackUrl: returned.href };
|
|
82
|
+
};
|
|
83
|
+
const connect = async () => machine ? service.connectClientCredentials({ ...args, verificationInput: { limit: 1 } })
|
|
84
|
+
: service.completeAuthorization({ ...args, callbackUrl: (await start()).callbackUrl });
|
|
85
|
+
return { directory, service, state, options, requests, start, connect };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("Databricks user consent pins the workspace, sends PKCE and persists encrypted restartable grants", async (t) => {
|
|
89
|
+
const f = await fixture(t); const start = await f.start();
|
|
90
|
+
assert.equal(start.url.href.split("?")[0], "https://dbc-abc123.cloud.databricks.com/oidc/v1/authorize");
|
|
91
|
+
assert.equal(start.url.searchParams.get("scope"), "all-apis offline_access");
|
|
92
|
+
assert.equal(start.url.searchParams.get("code_challenge_method"), "S256");
|
|
93
|
+
assert.equal(start.url.searchParams.has("client_secret"), false);
|
|
94
|
+
await f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl });
|
|
95
|
+
for (const file of await readdir(f.directory)) {
|
|
96
|
+
const text = await readFile(path.join(f.directory, file), "utf8");
|
|
97
|
+
for (const secret of ["private-code", "private-client-secret", "private-access-1", "private-refresh-1"]) assert.equal(text.includes(secret), false);
|
|
98
|
+
}
|
|
99
|
+
const restarted = createConnectionService(f.options);
|
|
100
|
+
assert.equal((await restarted.status(args)).status, "connected");
|
|
101
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "jobs.get", input: { job_id: 123 } }), job);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Databricks service principals use confidential grants on AWS, Azure and GCP without callbacks", async (t) => {
|
|
105
|
+
for (const origin of ["https://dbc-abc123.cloud.databricks.com", "https://adb-123456789.7.azuredatabricks.net", "https://123456789.7.gcp.databricks.com/"]) {
|
|
106
|
+
const f = await fixture(t, "client_credentials", origin); await f.connect();
|
|
107
|
+
const restarted = createConnectionService(f.options);
|
|
108
|
+
assert.equal((await restarted.status(args)).status, "connected");
|
|
109
|
+
assert.deepEqual((await restarted.status(args)).grantedScopes, ["jobs"]);
|
|
110
|
+
f.state.time += 85_000; // 35 seconds remain: refresh before Azure's 30-second refusal boundary.
|
|
111
|
+
f.state.secret = "rotated-client-secret";
|
|
112
|
+
await Promise.all([restarted.invoke({ ...args, operation: "jobs.list" }), restarted.invoke({ ...args, operation: "jobs.list" })]);
|
|
113
|
+
assert.equal(f.state.count, 2);
|
|
114
|
+
for (const file of await readdir(f.directory)) assert.equal((await readFile(path.join(f.directory, file), "utf8")).includes("private-access"), false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("Databricks returns bounded job pages, empty lists and nested detail continuation without running jobs", async (t) => {
|
|
119
|
+
const f = await fixture(t); await f.connect();
|
|
120
|
+
const before = f.requests.length;
|
|
121
|
+
await f.service.invoke({ ...args, operation: "jobs.list", input: { limit: 2, name: "Inventory & stock", expand_tasks: true, page_token: "page+two=" } });
|
|
122
|
+
assert.equal(f.requests.length, before + 1);
|
|
123
|
+
assert.equal(f.requests.at(-1).url.search, "?limit=2&expand_tasks=true&name=Inventory+%26+stock&page_token=page%2Btwo%3D");
|
|
124
|
+
for (const empty of [{}, { jobs: [] }]) {
|
|
125
|
+
f.state.value = empty; assert.deepEqual(await f.service.invoke({ ...args, operation: "jobs.list" }), empty);
|
|
126
|
+
}
|
|
127
|
+
f.state.value = { ...job, settings: { tasks: [] }, next_page_token: "details+next=" };
|
|
128
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation: "jobs.get", input: { job_id: 123, include_trigger_state: true, page_token: "details+next=" } }), f.state.value);
|
|
129
|
+
assert.equal(f.requests.at(-1).url.search, "?job_id=123&include_trigger_state=true&page_token=details%2Bnext%3D");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("Databricks rejects unsupported input, unsafe numeric identifiers, SQL and writes before transport", async (t) => {
|
|
133
|
+
const f = await fixture(t); await f.connect(); const before = f.requests.length;
|
|
134
|
+
for (const input of [{ limit: 0 }, { limit: 101 }, { limit: 1.5 }, { expand_tasks: "wrong" }, { page_token: "" },
|
|
135
|
+
{ page_token: "x".repeat(8193) }, { name: "" }, { workspaceUrl: "https://other.test" }, { url: "https://other.test" }]) {
|
|
136
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list", input }), { code: "connector_input_invalid" });
|
|
137
|
+
}
|
|
138
|
+
for (const input of [{}, { job_id: 0 }, { job_id: -1 }, { job_id: 1.2 }, { job_id: Number.MAX_SAFE_INTEGER + 1 }]) {
|
|
139
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.get", input }), { code: "connector_input_invalid" });
|
|
140
|
+
}
|
|
141
|
+
for (const operation of ["jobs.run", "jobs.create", "sql.execute"]) await assert.rejects(f.service.invoke({ ...args, operation }), { code: "connector_operation_unknown" });
|
|
142
|
+
assert.equal(f.requests.length, before);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("Databricks verifies granted scopes and application ownership before reading a job", async (t) => {
|
|
146
|
+
const f = await fixture(t); await f.connect();
|
|
147
|
+
for (const owner of [{ ...context, subjectId: "another" }, { ...context, applicationId: "another" }]) {
|
|
148
|
+
await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "jobs.list" }), { code: "connector_reconnect_required" });
|
|
149
|
+
}
|
|
150
|
+
const denied = createConnectionService({ ...f.options, authorize: async () => null });
|
|
151
|
+
await assert.rejects(denied.invoke({ ...args, operation: "jobs.list" }), { code: "connector_access_denied" });
|
|
152
|
+
assert.equal(f.requests.length, 2);
|
|
153
|
+
const insufficient = await fixture(t); insufficient.state.tokenPatch.scope = "offline_access";
|
|
154
|
+
await assert.rejects(insufficient.connect(), { code: "connector_scope_missing" });
|
|
155
|
+
assert.equal(insufficient.requests.length, 1);
|
|
156
|
+
assert.equal((await insufficient.service.status(args)).status, "disconnected");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("Databricks persists renewal before failed reads and marks revoked grants for reconnection in both flows", async (t) => {
|
|
160
|
+
for (const type of ["authorization_code", "client_credentials"]) {
|
|
161
|
+
const f = await fixture(t, type); await f.connect(); f.state.time += 85_000; f.state.status = 429;
|
|
162
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list" }), { code: "connector_rate_limited" });
|
|
163
|
+
f.state.status = 200;
|
|
164
|
+
const restarted = createConnectionService(f.options); await restarted.invoke({ ...args, operation: "jobs.list" });
|
|
165
|
+
assert.equal(f.state.count, 2);
|
|
166
|
+
f.state.time += 85_000; f.state.tokenStatus = 400;
|
|
167
|
+
await assert.rejects(restarted.invoke({ ...args, operation: "jobs.list" }), (error) => error.code === "connector_reconnect_required" && !error.message.includes("private"));
|
|
168
|
+
assert.equal((await restarted.status(args)).status, "reconnect-required");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("Databricks rejects malformed token lifetimes, scopes and refresh values before verification", async (t) => {
|
|
173
|
+
for (const tokenPatch of [{ expires_in: undefined }, { expires_in: 40 }, { expires_in: 1.5 }, { scope: "" },
|
|
174
|
+
{ scope: "jobs\nall-apis" }, { refresh_token: "" }]) {
|
|
175
|
+
const f = await fixture(t); f.state.tokenPatch = tokenPatch;
|
|
176
|
+
await assert.rejects(f.connect(), { code: "connector_response_invalid" }); assert.equal(f.requests.length, 1);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("Databricks pending consent cannot change workspace and cancelled, denied or replayed consent preserves saved access", async (t) => {
|
|
181
|
+
const f = await fixture(t); await f.connect(); const start = await f.start();
|
|
182
|
+
const changed = structuredClone(f.options.configuration); changed.integrations.warehouse.settings.workspaceUrl = "https://dbc-other.cloud.databricks.com";
|
|
183
|
+
const other = createConnectionService({ ...f.options, configuration: changed });
|
|
184
|
+
await assert.rejects(other.completeAuthorization({ ...args, callbackUrl: start.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
185
|
+
const denied = await f.start(); const returned = new URL(denied.callbackUrl); returned.searchParams.delete("code"); returned.searchParams.set("error", "access_denied");
|
|
186
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: returned.href }), { code: "connector_consent_denied" });
|
|
187
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: denied.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
188
|
+
const cancelled = await f.start(); await f.service.cancelAuthorization({ ...args, state: cancelled.url.searchParams.get("state") });
|
|
189
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
190
|
+
assert.equal((await f.service.status(args)).status, "connected"); assert.equal(f.requests.length, 2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("Databricks grants cannot move to a different workspace, client or service scope without reconnection", async (t) => {
|
|
194
|
+
const f = await fixture(t, "client_credentials"); await f.connect();
|
|
195
|
+
for (const mutate of [(value) => { value.integrations.warehouse.settings.workspaceUrl = "https://dbc-other.cloud.databricks.com"; },
|
|
196
|
+
(value) => { value.registrations.databricks.clientId = "other-client"; }, (value) => { value.integrations.warehouse.scopes = ["all-apis"]; }]) {
|
|
197
|
+
const configuration = structuredClone(f.options.configuration); mutate(configuration);
|
|
198
|
+
const changed = createConnectionService({ ...f.options, configuration });
|
|
199
|
+
assert.equal((await changed.status(args)).status, "reconnect-required");
|
|
200
|
+
await assert.rejects(changed.invoke({ ...args, operation: "jobs.list" }), { code: "connector_reconnect_required" });
|
|
201
|
+
}
|
|
202
|
+
assert.equal(f.requests.length, 2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("Databricks rejects malformed job successes, mismatched IDs and overfull pages and redacts provider errors", async (t) => {
|
|
206
|
+
const f = await fixture(t); await f.connect();
|
|
207
|
+
for (const value of [null, [], "not-json", { jobs: null }, { jobs: [{ job_id: 1.5 }] }, { jobs: [{ job_id: Number.MAX_SAFE_INTEGER + 1 }] },
|
|
208
|
+
{ jobs: [{ ...job, settings: [] }] }, { jobs: [job, job] }, { error_code: "PRIVATE_ERROR", message: "private-provider-error" },
|
|
209
|
+
{ jobs: [], next_page_token: 123 }]) {
|
|
210
|
+
f.state.value = value;
|
|
211
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list", input: { limit: 1 } }), (error) =>
|
|
212
|
+
["connector_response_invalid", "connector_provider_failed"].includes(error.code) && !error.message.includes("private"), JSON.stringify(value));
|
|
213
|
+
}
|
|
214
|
+
f.state.value = { ...job, job_id: 456 };
|
|
215
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.get", input: { job_id: 123 } }), { code: "connector_response_invalid" });
|
|
216
|
+
f.state.value = undefined; f.state.status = 204;
|
|
217
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list" }), { code: "connector_response_invalid" });
|
|
218
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [500, "connector_provider_failed"], [429, "connector_rate_limited"], [401, "connector_reconnect_required"]]) {
|
|
219
|
+
f.state.status = status; f.state.value = { message: "private-provider-error" };
|
|
220
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list" }), (error) => error.code === code && !error.message.includes("private"));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("Databricks bounds cancellation and timeout and disconnects locally", async (t) => {
|
|
225
|
+
const f = await fixture(t, "client_credentials"); await f.connect(); f.state.hang = true;
|
|
226
|
+
const controller = new AbortController(); const pending = f.service.invoke({ ...args, operation: "jobs.list", signal: controller.signal });
|
|
227
|
+
setTimeout(() => controller.abort(), 10); await assert.rejects(pending, { code: "connector_cancelled" });
|
|
228
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "jobs.list" }), { code: "connector_provider_timeout" });
|
|
229
|
+
await f.service.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected"); assert.equal(f.requests.length, 4);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("Databricks guide, CLI parser and shared fields agree on grant-specific inputs and workspace origins", async () => {
|
|
233
|
+
const guide = await readFile(new URL("../docs/databricks.md", import.meta.url), "utf8");
|
|
234
|
+
const blocks = [...guide.matchAll(/```json\n([\s\S]*?)\n```/gu)].map((match) => JSON.parse(match[1]));
|
|
235
|
+
const parse = (value) => parseIntegrationConfiguration(JSON.stringify(value), { providers: [databricksProvider] });
|
|
236
|
+
assert.deepEqual(parse(blocks[0]), blocks[0]);
|
|
237
|
+
const user = structuredClone(blocks[0]); user.registrations["databricks-service"] = blocks[1];
|
|
238
|
+
user.integrations.warehouse.accountMode = "per-user"; user.integrations.warehouse.scopes = ["all-apis", "offline_access"];
|
|
239
|
+
assert.deepEqual(parse(user), user);
|
|
240
|
+
assert.deepEqual(getProviderScopes(databricksProvider).map((scope) => scope.value), ["all-apis", "offline_access"]);
|
|
241
|
+
assert.deepEqual(getProviderScopes(databricksProvider, {}, "client_credentials").map((scope) => scope.value), ["all-apis", "jobs"]);
|
|
242
|
+
for (const workspaceUrl of ["", "http://dbc-abc123.cloud.databricks.com", "https://accounts.cloud.databricks.com", "https://accounts.azuredatabricks.net",
|
|
243
|
+
"https://dbc-abc123.cloud.databricks.com.attacker.test", "https://dbc-abc123.cloud.databricks.com/path", "https://dbc-abc123.cloud.databricks.com/?o=1",
|
|
244
|
+
"https://dbc-abc123.cloud.databricks.com/#page", "https://user@dbc-abc123.cloud.databricks.com", "https://dbc-abc123.cloud.databricks.com:8443",
|
|
245
|
+
"https://dbc-abc123.cloud.databricks.com/../", "https://127.0.0.1", "https://us-east4.gcp.databricks.com"]) {
|
|
246
|
+
const invalid = structuredClone(blocks[0]); invalid.integrations.warehouse.settings.workspaceUrl = workspaceUrl;
|
|
247
|
+
assert.throws(() => parse(invalid), (error) => Boolean(error.fieldErrors["integrations.warehouse.settings.workspaceUrl"]));
|
|
248
|
+
}
|
|
249
|
+
for (const [key, value] of [["callbackUrlRef", callback], ["clientSecretRef", "raw-secret"], ["tokenEndpointAuthMethod", "client_secret_basic"]]) {
|
|
250
|
+
const invalid = structuredClone(user); invalid.registrations["databricks-service"][key] = value;
|
|
251
|
+
assert.throws(() => parse(invalid), (error) => Boolean(error.fieldErrors[`registrations.databricks-service.${key}`]));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
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 { dbtSemanticLayerProvider } from "../src/server/dbt-semantic-layer.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "metrics-app", subjectId: "analytics-team" };
|
|
12
|
+
const input = { context, integrationId: "metrics" };
|
|
13
|
+
const environmentId = "9007199254740993";
|
|
14
|
+
const metric = { name: "order_total", description: null, type: "SIMPLE" };
|
|
15
|
+
const page = (items, values = {}) => ({ items, pageNum: 1, pageSize: 20, totalItems: items.length, totalPages: items.length ? 1 : 0, ...values });
|
|
16
|
+
async function fixture(t, host = "semantic-layer.cloud.getdbt.com") {
|
|
17
|
+
const directory = await mkdtemp(path.join(tmpdir(), "dbt-connector-"));
|
|
18
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
19
|
+
const requests = [];
|
|
20
|
+
const state = { key: "fixture-dbt-token", status: 200, response: undefined, hang: false, resolutions: 0 };
|
|
21
|
+
const options = {
|
|
22
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { metrics: {
|
|
23
|
+
provider: "dbt-semantic-layer", displayName: "Warehouse metadata", accountMode: "shared", scopes: [],
|
|
24
|
+
settings: { host, environmentId }, authentication: { method: "api-key", secretRef: "env:DBT_SERVICE_TOKEN" }, extensions: { fromCli: true }
|
|
25
|
+
} }, extensions: { retained: true } },
|
|
26
|
+
providers: [{ ...dbtSemanticLayerProvider, requestTimeoutMs: 50 }],
|
|
27
|
+
authorize: async (owner) => owner,
|
|
28
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(9) }, activeKeyId: "current" }) }),
|
|
29
|
+
resolveReference: async (reference) => { state.resolutions++; assert.equal(reference, "env:DBT_SERVICE_TOKEN"); return state.key; },
|
|
30
|
+
fetchImpl: async (address, init) => {
|
|
31
|
+
init.signal.throwIfAborted();
|
|
32
|
+
const body = JSON.parse(init.body);
|
|
33
|
+
requests.push({ url: String(address), init, body, headers: new Headers(init.headers) });
|
|
34
|
+
if (state.hang) {
|
|
35
|
+
state.started?.();
|
|
36
|
+
return new Promise((_, reject) => {
|
|
37
|
+
if (init.signal.aborted) reject(init.signal.reason);
|
|
38
|
+
else init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
let data = { environmentInfo: { dialect: "SNOWFLAKE" } };
|
|
42
|
+
for (const [name, items] of [["metricsPaginated", [metric]], ["dimensionsPaginated", [{ name: "metric_time", description: "Metric date", type: "TIME" }]], ["savedQueriesPaginated", [{ name: "orders", description: null }]]]) {
|
|
43
|
+
if (body.query.includes(name)) data = { [name]: page(items, { pageNum: body.variables.pageNum, pageSize: body.variables.pageSize }) };
|
|
44
|
+
}
|
|
45
|
+
return Response.json(state.response === undefined ? { data } : state.response, { status: state.status });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return { service: createConnectionService(options), options, state, requests, directory };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("dbt verifies only environment metadata and preserves file-backed ownership, references and exact large IDs", async (t) => {
|
|
52
|
+
const f = await fixture(t);
|
|
53
|
+
const connected = await f.service.connectApiKey(input);
|
|
54
|
+
assert.equal(connected.status, "connected"); assert.equal(f.requests.length, 1);
|
|
55
|
+
const request = f.requests[0];
|
|
56
|
+
assert.equal(request.url, "https://semantic-layer.cloud.getdbt.com/api/graphql");
|
|
57
|
+
assert.equal(request.init.method, "POST"); assert.equal(request.init.redirect, "error"); assert.equal(request.init.credentials, "omit");
|
|
58
|
+
assert.equal(request.headers.get("authorization"), "Bearer fixture-dbt-token");
|
|
59
|
+
assert.deepEqual(request.body.variables, { environmentId });
|
|
60
|
+
assert.match(request.body.query, /\$environmentId: BigInt!/u); assert(!request.body.query.includes("mutation"));
|
|
61
|
+
for (const name of await readdir(f.directory)) assert(!(await readFile(path.join(f.directory, name), "utf8")).includes(f.state.key));
|
|
62
|
+
const restarted = createConnectionService(f.options);
|
|
63
|
+
assert.deepEqual(await restarted.status(input), connected);
|
|
64
|
+
for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
|
|
65
|
+
await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "metrics.list" }), { code: "connector_reconnect_required" });
|
|
66
|
+
}
|
|
67
|
+
assert.equal(f.requests.length, 1);
|
|
68
|
+
f.state.key = "rotated-dbt-token";
|
|
69
|
+
await restarted.invoke({ ...input, operation: "metrics.list" });
|
|
70
|
+
assert.equal(f.requests.at(-1).headers.get("authorization"), "Bearer rotated-dbt-token");
|
|
71
|
+
await restarted.disconnect(input); assert.equal((await f.service.status(input)).status, "disconnected");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("dbt uses each declared regional, single-tenant and multi-cell host without following caller-supplied URLs", async (t) => {
|
|
75
|
+
for (const host of ["semantic-layer.emea.dbt.com", "semantic-layer.au.dbt.com", "semantic-layer.account123.getdbt.com", "account123.semantic-layer.us1.dbt.com"]) {
|
|
76
|
+
const f = await fixture(t, host); await f.service.connectApiKey(input);
|
|
77
|
+
assert.equal(f.requests[0].url, `https://${host}/api/graphql`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("dbt metadata pages are explicit, accept empty pages and preserve future metric types and nullable descriptions", async (t) => {
|
|
82
|
+
const f = await fixture(t); await f.service.connectApiKey(input);
|
|
83
|
+
for (const [operation, key, extra] of [["metrics.list", "metricsPaginated", {}], ["dimensions.list", "dimensionsPaginated", { metrics: [{ name: "order_total" }] }], ["savedQueries.list", "savedQueriesPaginated", {}]]) {
|
|
84
|
+
const values = { pageNum: 2, pageSize: 3, search: "order\" \\ query", ...extra };
|
|
85
|
+
const items = key === "savedQueriesPaginated" ? [{ name: "orders", description: "" }] : [{ ...metric, type: "FUTURE_TYPE" }];
|
|
86
|
+
f.state.response = { data: { [key]: page(items, { pageNum: 2, pageSize: 3, totalItems: 4, totalPages: 2 }) } };
|
|
87
|
+
const before = f.requests.length;
|
|
88
|
+
assert.deepEqual(await f.service.invoke({ ...input, operation, input: values }), f.state.response);
|
|
89
|
+
assert.deepEqual(f.requests.at(-1).body.variables, { ...values, environmentId });
|
|
90
|
+
assert.equal(f.requests.at(-1).body.query.includes(values.search), false); assert.equal(f.requests.length, before + 1);
|
|
91
|
+
f.state.response = { data: { [key]: page([]) } };
|
|
92
|
+
assert.deepEqual(await f.service.invoke({ ...input, operation, input: extra }), f.state.response);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("dbt rejects malformed IDs, hosts, credential literals and unimplemented auth modes through the CLI parser", async (t) => {
|
|
97
|
+
const f = await fixture(t);
|
|
98
|
+
const parse = (configuration) => parseIntegrationConfiguration(JSON.stringify(configuration), { providers: [dbtSemanticLayerProvider] });
|
|
99
|
+
const parsed = parse(f.options.configuration);
|
|
100
|
+
assert.equal(parsed.integrations.metrics.settings.environmentId, environmentId);
|
|
101
|
+
assert.deepEqual(parsed.extensions, { retained: true }); assert.deepEqual(parsed.integrations.metrics.extensions, { fromCli: true });
|
|
102
|
+
const bad = (change) => {
|
|
103
|
+
const config = structuredClone(f.options.configuration); Object.assign(config.integrations.metrics, change);
|
|
104
|
+
assert.throws(() => parse(config), (error) => Boolean(error.fieldErrors) && !error.fieldErrors.configuration);
|
|
105
|
+
};
|
|
106
|
+
for (const host of ["", "https://semantic-layer.cloud.getdbt.com", "semantic-layer.cloud.getdbt.com:443", "semantic-layer.cloud.getdbt.com/", "semantic-layer.cloud.getdbt.com?x=1", "semantic-layer.cloud.getdbt.com.evil.test", "127.0.0.1", "metadata.internal", "SEMANTIC-LAYER.cloud.getdbt.com", "semantic-layer.-bad.dbt.com"]) bad({ settings: { host, environmentId } });
|
|
107
|
+
for (const id of ["", 123, "01", "0", "-1", "1.5", "1e15", " 123", "1".repeat(39)]) bad({ settings: { host: f.options.configuration.integrations.metrics.settings.host, environmentId: id } });
|
|
108
|
+
for (const secretRef of ["dbtc_raw-token", "https://secret.test/key", ""]) bad({ authentication: { method: "api-key", secretRef } });
|
|
109
|
+
bad({ accountMode: "per-user" }); bad({ scopes: ["metadata"] }); bad({ authentication: { method: "oauth2", registrationRef: "dbt" } });
|
|
110
|
+
const assistant = structuredClone(f.options.configuration); assistant.integrations.metrics.accountMode = "assistant"; assert.equal(parse(assistant).integrations.metrics.accountMode, "assistant");
|
|
111
|
+
assert.equal(f.requests.length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("dbt validates pagination and nested metric inputs before transport and rejects arbitrary queries", async (t) => {
|
|
115
|
+
const f = await fixture(t); await f.service.connectApiKey(input); const before = f.requests.length;
|
|
116
|
+
for (const values of [{ pageNum: 0 }, { pageNum: 1.5 }, { pageNum: 2_147_483_648 }, { pageSize: 0 }, { pageSize: 101 }, { pageSize: 1.5 }, { search: "x".repeat(513) }, { environmentId: "other" }, { host: "other.test" }, { query: "mutation { createQuery }" }]) {
|
|
117
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list", input: values }), { code: "connector_input_invalid" });
|
|
118
|
+
}
|
|
119
|
+
for (const metrics of [undefined, [], ["order_total"], [{}], [{ name: "" }], [{ name: "x".repeat(513) }], [{ name: "orders", sql: "other" }], Array.from({ length: 101 }, () => ({ name: "orders" }))]) {
|
|
120
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "dimensions.list", input: { metrics } }), { code: "connector_input_invalid" });
|
|
121
|
+
}
|
|
122
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "query.create" }), { code: "connector_operation_unknown" });
|
|
123
|
+
assert.equal(f.requests.length, before);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("dbt rejects malformed metadata and inconsistent pages instead of accepting a false connection", async (t) => {
|
|
127
|
+
const f = await fixture(t);
|
|
128
|
+
for (const value of [null, {}, { data: null }, { data: { environmentInfo: null } }, { data: { environmentInfo: { dialect: 4 } } }, { data: { environmentInfo: { dialect: "" } } }, { data: { environmentInfo: { dialect: "DIALECT" } }, errors: {} }]) {
|
|
129
|
+
f.state.response = value;
|
|
130
|
+
await assert.rejects(f.service.connectApiKey(input), { code: "connector_response_invalid" });
|
|
131
|
+
assert.notEqual((await f.service.status(input)).status, "connected");
|
|
132
|
+
}
|
|
133
|
+
f.state.response = undefined; await f.service.connectApiKey(input);
|
|
134
|
+
for (const result of [null, page([{}]), page([{ name: "metric", description: null }]), page([metric], { pageNum: 2 }), page([metric], { pageSize: 2 }), page([metric], { totalItems: 0 }), page([metric], { totalPages: -1 }), page([metric], { totalItems: 1.5 }), page([null])]) {
|
|
135
|
+
f.state.response = { data: { metricsPaginated: result } };
|
|
136
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list" }), { code: "connector_response_invalid" });
|
|
137
|
+
}
|
|
138
|
+
f.state.response = { data: { metricsPaginated: page([metric, metric], { pageSize: 1 }) } };
|
|
139
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list", input: { pageSize: 1 } }), { code: "connector_response_invalid" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("dbt provider errors including partial GraphQL data are redacted without guessing authentication from message text", async (t) => {
|
|
143
|
+
const f = await fixture(t); await f.service.connectApiKey(input);
|
|
144
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"], [401, "connector_reconnect_required"]]) {
|
|
145
|
+
f.state.status = status; f.state.response = { error: f.state.key };
|
|
146
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list" }), (error) => error.code === code && !JSON.stringify(error).includes(f.state.key) && error.cause === undefined);
|
|
147
|
+
}
|
|
148
|
+
assert.equal((await f.service.status(input)).status, "reconnect-required");
|
|
149
|
+
f.state.status = 200; f.state.response = undefined; await f.service.connectApiKey(input);
|
|
150
|
+
f.state.response = { data: { metricsPaginated: page([metric]) }, errors: [{ message: f.state.key }] };
|
|
151
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list" }), (error) => error.code === "connector_provider_failed" && !error.message.includes(f.state.key));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("dbt changing environment, host or token reference requires verification for the new configuration", async (t) => {
|
|
155
|
+
const f = await fixture(t); await f.service.connectApiKey(input); const before = f.requests.length;
|
|
156
|
+
for (const change of [{ settings: { host: "semantic-layer.au.dbt.com", environmentId } }, { settings: { host: f.options.configuration.integrations.metrics.settings.host, environmentId: "1" } }, { authentication: { method: "api-key", secretRef: "env:OTHER_TOKEN" } }]) {
|
|
157
|
+
const configuration = structuredClone(f.options.configuration); Object.assign(configuration.integrations.metrics, change);
|
|
158
|
+
const changed = createConnectionService({ ...f.options, configuration });
|
|
159
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
160
|
+
await assert.rejects(changed.invoke({ ...input, operation: "metrics.list" }), { code: "connector_reconnect_required" });
|
|
161
|
+
}
|
|
162
|
+
assert.equal(f.requests.length, before);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("dbt abort and timeout interrupt requests without replay or automatic page traversal", async (t) => {
|
|
166
|
+
const f = await fixture(t); await f.service.connectApiKey(input); const before = f.requests.length;
|
|
167
|
+
const controller = new AbortController(); controller.abort();
|
|
168
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list", signal: controller.signal }), { code: "connector_cancelled" });
|
|
169
|
+
assert.equal(f.requests.length, before);
|
|
170
|
+
f.state.hang = true; const started = new Promise((resolve) => { f.state.started = resolve; }); const running = new AbortController();
|
|
171
|
+
const pending = assert.rejects(f.service.invoke({ ...input, operation: "metrics.list", signal: running.signal }), { code: "connector_cancelled" });
|
|
172
|
+
await started; running.abort(); await pending;
|
|
173
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "metrics.list" }), { code: "connector_provider_timeout" });
|
|
174
|
+
assert.equal(f.requests.length, before + 2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("dbt guide JSON is the actual CLI format and rejects missing or unsafe secret bindings before requests", async (t) => {
|
|
178
|
+
const guide = await readFile(new URL("../docs/dbt-semantic-layer.md", import.meta.url), "utf8");
|
|
179
|
+
const text = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
|
|
180
|
+
const configuration = parseIntegrationConfiguration(text, { providers: [dbtSemanticLayerProvider] });
|
|
181
|
+
assert.equal(configuration.integrations.metrics.settings.environmentId, "70506183142324");
|
|
182
|
+
assert.equal(configuration.integrations.metrics.authentication.secretRef, "env:DBT_SERVICE_TOKEN");
|
|
183
|
+
const f = await fixture(t);
|
|
184
|
+
for (const key of [undefined, "", "key with spaces", "key\nheader"]) {
|
|
185
|
+
f.state.key = key; await assert.rejects(f.service.connectApiKey(input), { code: "connector_binding_missing" });
|
|
186
|
+
}
|
|
187
|
+
assert.equal(f.requests.length, 0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
test("dbt runs bounded metric queries with dimensions and time filters, then decodes explicit result pages without resubmitting", async (t) => {
|
|
192
|
+
const f = await fixture(t); await f.service.connectApiKey(input);
|
|
193
|
+
const values = { metrics: [{ name: "order_total", alias: "revenue" }], groupBy: [{ name: "metric_time", grain: "MONTH" }], where: [{ sql: "{{ Dimension('metric_time') }} >= '2026-01-01'" }], limit: 100 };
|
|
194
|
+
f.state.response = { data: { createQuery: { queryId: "query-123" } } };
|
|
195
|
+
assert.deepEqual(await f.service.invoke({ ...input, operation: "queries.create", input: values }), f.state.response);
|
|
196
|
+
assert.deepEqual(f.requests.at(-1).body.variables, { ...values, environmentId });
|
|
197
|
+
assert(!f.requests.at(-1).body.query.includes(values.where[0].sql));
|
|
198
|
+
const before = f.requests.length;
|
|
199
|
+
for (const bad of [{ ...values, metrics: [] }, { ...values, limit: 10001 }, { ...values, groupBy: [{ name: "metric_time", grain: "INVALID" }] }, { ...values, environmentId: "another" }]) {
|
|
200
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "queries.create", input: bad }), { code: "connector_input_invalid" });
|
|
201
|
+
}
|
|
202
|
+
assert.equal(f.requests.length, before);
|
|
203
|
+
f.state.response = { data: { query: { status: "RUNNING", error: null, totalPages: null, jsonResult: null } } };
|
|
204
|
+
assert.equal((await f.service.invoke({ ...input, operation: "queries.get", input: { queryId: "query-123" } })).data.query.status, "RUNNING");
|
|
205
|
+
const table = { schema: { fields: [{ name: "revenue", type: "number" }] }, data: [{ revenue: 123.5 }] };
|
|
206
|
+
f.state.response = { data: { query: { status: "SUCCESSFUL", error: null, totalPages: 2, jsonResult: JSON.stringify(table) } } };
|
|
207
|
+
const result = await f.service.invoke({ ...input, operation: "queries.get", input: { queryId: "query-123", pageNum: 2 } });
|
|
208
|
+
assert.deepEqual(result.data.query.jsonResult, table);
|
|
209
|
+
assert.deepEqual(f.requests.at(-1).body.variables, { queryId: "query-123", pageNum: 2, environmentId });
|
|
210
|
+
assert.match(f.requests.at(-1).body.query, /encoded: false/u);
|
|
211
|
+
for (const jsonResult of ["{", "null", "[]", JSON.stringify({ schema: { fields: [] }, data: Array(1025).fill({}) }), "x".repeat(5 * 1024 * 1024 + 1), null]) {
|
|
212
|
+
f.state.response = { data: { query: { status: "SUCCESSFUL", error: null, totalPages: 1, jsonResult } } };
|
|
213
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "queries.get", input: { queryId: "query-123" } }), { code: "connector_response_invalid" });
|
|
214
|
+
}
|
|
215
|
+
f.state.response = { data: { query: { status: "FAILED", error: f.state.key, totalPages: null, jsonResult: null } } };
|
|
216
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "queries.get", input: { queryId: "query-123" } }), error => error.code === "connector_provider_failed" && !error.message.includes(f.state.key));
|
|
217
|
+
f.state.status = 500;
|
|
218
|
+
const failedBefore = f.requests.length;
|
|
219
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "queries.create", input: values }), { code: "connector_provider_failed" });
|
|
220
|
+
assert.equal(f.requests.length, failedBefore + 1);
|
|
221
|
+
});
|