@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,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, 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 { hubspotProvider } from "../src/server/hubspot.js";
|
|
9
|
+
|
|
10
|
+
for (const perUser of [false, true]) test(`HubSpot OAuth ${perUser ? "per-user" : "shared rotating"} grants preserve protocol and ownership`, async (t) => {
|
|
11
|
+
const directory = await mkdtemp(path.join(tmpdir(), "hubspot-oauth-test-"));
|
|
12
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
13
|
+
const callback = "https://example.test/integrations/hubspot/callback";
|
|
14
|
+
const context = { applicationId: "app", subjectId: "owner" };
|
|
15
|
+
const input = { context, integrationId: "hubspot" };
|
|
16
|
+
let time = Date.now(), exchanges = 0;
|
|
17
|
+
let tokenScope = "oauth crm.objects.contacts.read", apiStatus = 200;
|
|
18
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(4) }, activeKeyId: "current" });
|
|
19
|
+
const options = {
|
|
20
|
+
configuration: { schemaVersion: 1, registrations: { hubspot: { source: "own", clientId: "client", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK", tokenEndpointAuthMethod: "client_secret_post" } }, integrations: {
|
|
21
|
+
hubspot: { provider: "hubspot", accountMode: perUser ? "per-user" : "shared", scopes: ["oauth", "crm.objects.contacts.read"], authentication: { method: "oauth2", registrationRef: "hubspot" } },
|
|
22
|
+
personal: { provider: "hubspot", accountMode: "shared", scopes: ["oauth", "crm.objects.contacts.read"], authentication: { method: "api-key", secretRef: "env:TOKEN" } }
|
|
23
|
+
} }, providers: [hubspotProvider], authorize: async owner => owner,
|
|
24
|
+
store: createFileConnectionStore({ directory, protection }), now: () => time,
|
|
25
|
+
resolveReference: async ref => ref === "env:CALLBACK" ? callback : ref === "env:TOKEN" ? "personal" : "secret",
|
|
26
|
+
fetchImpl: async (address, init) => {
|
|
27
|
+
const url = new URL(String(address)), headers = new Headers(init.headers);
|
|
28
|
+
if (url.pathname === "/oauth/2026-09/token") {
|
|
29
|
+
assert.equal(url.origin, "https://api.hubspot.com");
|
|
30
|
+
assert.equal(headers.get("accept"), "application/json");
|
|
31
|
+
const body = new URLSearchParams(init.body);
|
|
32
|
+
assert.equal(body.get("client_secret"), "secret");
|
|
33
|
+
if (body.get("grant_type") === "authorization_code") {
|
|
34
|
+
assert.equal(body.get("redirect_uri"), callback);
|
|
35
|
+
assert.equal(body.has("code_verifier"), false);
|
|
36
|
+
} else {
|
|
37
|
+
assert.equal(body.get("refresh_token"), `refresh-${exchanges}`);
|
|
38
|
+
assert.equal(body.has("redirect_uri"), false);
|
|
39
|
+
}
|
|
40
|
+
exchanges++;
|
|
41
|
+
return Response.json({ access_token: `access-${exchanges}`, refresh_token: `refresh-${exchanges}`, token_type: "Bearer", expires_in: 60, scopes: tokenScope.split(" ") });
|
|
42
|
+
}
|
|
43
|
+
assert.equal(url.origin, "https://api.hubapi.com");
|
|
44
|
+
assert.equal(url.pathname, "/crm/objects/2026-09/contacts");
|
|
45
|
+
assert.ok(headers.get("authorization") === `Bearer access-${exchanges}` || headers.get("authorization") === "Bearer personal");
|
|
46
|
+
if (apiStatus !== 200) return Response.json({ message: "private provider detail" }, { status: apiStatus });
|
|
47
|
+
return Response.json({ results: [{ id: "123" }] });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const service = createConnectionService(options);
|
|
51
|
+
for (const outcome of ["cancel", "denied"]) {
|
|
52
|
+
const attempt = new URL((await service.beginAuthorization(input)).authorizationUrl);
|
|
53
|
+
const denied = new URL(callback);
|
|
54
|
+
denied.searchParams.set("state", attempt.searchParams.get("state"));
|
|
55
|
+
if (outcome === "cancel") {
|
|
56
|
+
await service.cancelAuthorization({ ...input, state: attempt.searchParams.get("state") });
|
|
57
|
+
denied.searchParams.set("code", "unused");
|
|
58
|
+
} else denied.searchParams.set("error", "access_denied");
|
|
59
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: denied.href }), {
|
|
60
|
+
code: outcome === "cancel" ? "connector_attempt_invalid" : "connector_consent_denied"
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
assert.equal(exchanges, 0);
|
|
64
|
+
const { authorizationUrl } = await service.beginAuthorization(input);
|
|
65
|
+
const url = new URL(authorizationUrl);
|
|
66
|
+
assert.equal(url.origin + url.pathname, "https://app.hubspot.com/oauth/authorize");
|
|
67
|
+
assert.equal(url.searchParams.has("code_challenge_method"), false);
|
|
68
|
+
assert.equal(url.searchParams.get("scope"), "oauth crm.objects.contacts.read");
|
|
69
|
+
const returned = new URL(callback);
|
|
70
|
+
returned.searchParams.set("code", "code");
|
|
71
|
+
returned.searchParams.set("state", url.searchParams.get("state"));
|
|
72
|
+
assert.equal((await service.completeAuthorization({ ...input, callbackUrl: returned.href })).status, "connected");
|
|
73
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: returned.href }), { code: "connector_attempt_invalid" });
|
|
74
|
+
time += 40_000;
|
|
75
|
+
await Promise.all([service.invoke({ ...input, operation: "contacts.list" }), service.invoke({ ...input, operation: "contacts.list" })]);
|
|
76
|
+
assert.equal(exchanges, 2);
|
|
77
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
78
|
+
time += 40_000;
|
|
79
|
+
await restarted.invoke({ ...input, operation: "contacts.list" });
|
|
80
|
+
assert.equal(exchanges, 3);
|
|
81
|
+
assert.equal((await restarted.connectApiKey({ context, integrationId: "personal" })).status, "connected");
|
|
82
|
+
const otherApp = { context: { ...context, applicationId: "another-app" }, integrationId: "hubspot" };
|
|
83
|
+
assert.equal((await restarted.status(otherApp)).status, "disconnected");
|
|
84
|
+
if (perUser) {
|
|
85
|
+
const otherUser = { context: { ...context, subjectId: "another-user" }, integrationId: "hubspot" };
|
|
86
|
+
assert.equal((await restarted.status(otherUser)).status, "disconnected");
|
|
87
|
+
await assert.rejects(restarted.invoke({ ...otherUser, operation: "contacts.list" }));
|
|
88
|
+
assert.equal((await restarted.status(input)).status, "connected");
|
|
89
|
+
}
|
|
90
|
+
tokenScope = "oauth";
|
|
91
|
+
time += 40_000;
|
|
92
|
+
await assert.rejects(restarted.invoke({ ...input, operation: "contacts.list" }), { code: "connector_scope_missing" });
|
|
93
|
+
await restarted.disconnect(input);
|
|
94
|
+
assert.equal((await restarted.status(input)).status, "disconnected");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("HubSpot rejects malformed permission grants and preserves OAuth errors", async () => {
|
|
98
|
+
for (const scopes of [undefined, "oauth", [42], ["bad scope"]]) {
|
|
99
|
+
await assert.rejects(hubspotProvider.normalizeTokenResponse(Response.json({ access_token: "secret", scopes })), { code: "connector_response_invalid" });
|
|
100
|
+
}
|
|
101
|
+
const failure = Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
102
|
+
assert.equal(await hubspotProvider.normalizeTokenResponse(failure), failure);
|
|
103
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readFile, readdir, 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 { inngestProvider } from "../src/server/inngest.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "workflow-app", subjectId: "workspace" };
|
|
12
|
+
const input = { context, integrationId: "jobs" };
|
|
13
|
+
const event = { name: "app/report.requested", data: { reportId: "42", filters: ["active"] }, id: "report-requested-42", ts: 1788920000000 };
|
|
14
|
+
const appPage = { data: [{ id: "reports", name: "Reports", method: "SERVE", isArchived: false }], page: { cursor: null, hasMore: false, limit: 20 } };
|
|
15
|
+
|
|
16
|
+
async function fixture(t) {
|
|
17
|
+
const directory = await mkdtemp(path.join(tmpdir(), "inngest-connectors-"));
|
|
18
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
19
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(8) }, activeKeyId: "current" });
|
|
20
|
+
const requests = [];
|
|
21
|
+
const resolved = [];
|
|
22
|
+
const state = { signing: "signkey-prod-fixture-signing", eventKey: "event+key/with?special#chars=", httpStatus: 200,
|
|
23
|
+
page: appPage, receipt: { ids: ["01K1QQ3VQ8R3M8QX4D51J8G7XH"], status: 200 } };
|
|
24
|
+
const options = {
|
|
25
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { jobs: {
|
|
26
|
+
provider: "inngest", accountMode: "shared", scopes: [], authentication: { method: "api-key", secretRef: "env:INNGEST_SIGNING_KEY" },
|
|
27
|
+
settings: { eventKeyRef: "env:INNGEST_EVENT_KEY", branchEnvironment: "feature/my-branch" }
|
|
28
|
+
} }, extensions: { preserve: true } },
|
|
29
|
+
providers: [inngestProvider], store: createFileConnectionStore({ directory, protection }),
|
|
30
|
+
authorize: async (owner, request) => {
|
|
31
|
+
if (request.operation === "events.send" && request.input.name !== event.name) return null;
|
|
32
|
+
return owner;
|
|
33
|
+
},
|
|
34
|
+
resolveReference: async (reference) => {
|
|
35
|
+
resolved.push(reference);
|
|
36
|
+
if (reference === "env:INNGEST_SIGNING_KEY") return state.signing;
|
|
37
|
+
if (reference === "env:INNGEST_EVENT_KEY") return state.eventKey;
|
|
38
|
+
throw new Error("Unrecognized binding.");
|
|
39
|
+
},
|
|
40
|
+
fetchImpl: async (address, init) => {
|
|
41
|
+
init.signal.throwIfAborted();
|
|
42
|
+
const url = new URL(String(address));
|
|
43
|
+
requests.push({ url, init, headers: new Headers(init.headers), body: init.body && JSON.parse(init.body) });
|
|
44
|
+
if (state.pause) {
|
|
45
|
+
state.started?.();
|
|
46
|
+
return new Promise((resolve, reject) => init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }));
|
|
47
|
+
}
|
|
48
|
+
return Response.json(state.httpStatus === 200 ? (url.hostname === "inn.gs" ? state.receipt : state.page) : { error: state.eventKey, signing: state.signing },
|
|
49
|
+
{ status: state.httpStatus, headers: { Location: "https://unexpected.example", Link: '<https://unexpected.example/next>; rel="next"' } });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
return { service: createConnectionService(options), options, directory, protection, state, requests, resolved };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("Inngest verifies only signing-key metadata and keeps both credential values out of file state", async (t) => {
|
|
56
|
+
const { service, options, directory, protection, requests, resolved, state } = await fixture(t);
|
|
57
|
+
const connection = await service.connectApiKey(input);
|
|
58
|
+
assert.equal(connection.status, "connected");
|
|
59
|
+
assert.deepEqual(resolved, ["env:INNGEST_SIGNING_KEY"]);
|
|
60
|
+
assert.equal(requests.length, 1);
|
|
61
|
+
assert.equal(requests[0].url.href, "https://api.inngest.com/v2/apps?limit=20&archived=false");
|
|
62
|
+
assert.equal(requests[0].init.method, "GET");
|
|
63
|
+
assert.equal(requests[0].headers.get("authorization"), `Bearer ${state.signing}`);
|
|
64
|
+
assert.equal(requests[0].headers.get("x-inngest-env"), null, "Event branch selection does not silently retarget API reads.");
|
|
65
|
+
for (const name of await readdir(directory)) {
|
|
66
|
+
const text = await readFile(path.join(directory, name), "utf8");
|
|
67
|
+
assert.equal(text.includes(state.eventKey), false);
|
|
68
|
+
assert.equal(text.includes(state.signing), false);
|
|
69
|
+
}
|
|
70
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
71
|
+
assert.deepEqual(await restarted.status(input), connection);
|
|
72
|
+
for (const owner of [{ ...context, applicationId: "different-app" }, { ...context, subjectId: "different-owner" }]) {
|
|
73
|
+
await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "events.send", input: event }), { code: "connector_reconnect_required" });
|
|
74
|
+
}
|
|
75
|
+
assert.equal(requests.length, 1);
|
|
76
|
+
await restarted.disconnect(input);
|
|
77
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Inngest event delivery uses only the encoded Event Key and preserves payload and branch", async (t) => {
|
|
81
|
+
const { service, requests, state } = await fixture(t);
|
|
82
|
+
await service.connectApiKey(input);
|
|
83
|
+
const original = structuredClone(event);
|
|
84
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "events.send", input: event }), state.receipt);
|
|
85
|
+
const request = requests.at(-1);
|
|
86
|
+
assert.equal(request.url.href, `https://inn.gs/e/${encodeURIComponent(state.eventKey)}`);
|
|
87
|
+
assert.equal(request.url.origin, "https://inn.gs");
|
|
88
|
+
assert.equal(request.url.search, "");
|
|
89
|
+
assert.equal(request.url.hash, "");
|
|
90
|
+
assert.equal(request.headers.get("authorization"), null);
|
|
91
|
+
assert.equal(request.headers.get("x-inngest-env"), "feature/my-branch");
|
|
92
|
+
assert.equal(request.init.redirect, "error");
|
|
93
|
+
assert.deepEqual(request.body, event);
|
|
94
|
+
assert.equal(JSON.stringify(request.body).includes("env:"), false);
|
|
95
|
+
assert.deepEqual(event, original);
|
|
96
|
+
state.eventKey = "rotated-event-key";
|
|
97
|
+
await service.invoke({ ...input, operation: "events.send", input: event });
|
|
98
|
+
assert.equal(requests.at(-1).url.pathname, "/e/rotated-event-key");
|
|
99
|
+
state.signing = "signkey-prod-rotated";
|
|
100
|
+
await service.invoke({ ...input, operation: "apps.list" });
|
|
101
|
+
assert.equal(requests.at(-1).headers.get("authorization"), `Bearer ${state.signing}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Inngest exposes bounded metadata pages without following provider links", async (t) => {
|
|
105
|
+
const { service, requests, state } = await fixture(t);
|
|
106
|
+
await service.connectApiKey(input);
|
|
107
|
+
state.page = { data: [{ id: "send-report", triggers: [{ type: "EVENT", value: event.name }] }], page: { cursor: "next+/=cursor", hasMore: true, limit: 5 } };
|
|
108
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "functions.list", input: { appId: "reports/a?b", limit: 5, cursor: "opaque+/=" } }), state.page);
|
|
109
|
+
assert.equal(requests.at(-1).url.pathname, "/v2/apps/reports%2Fa%3Fb/functions");
|
|
110
|
+
assert.equal(requests.at(-1).url.searchParams.get("cursor"), "opaque+/=");
|
|
111
|
+
await service.invoke({ ...input, operation: "apps.list", input: { archived: true, limit: 100 } });
|
|
112
|
+
assert.equal(requests.at(-1).url.searchParams.get("archived"), "true");
|
|
113
|
+
assert.equal(requests.length, 3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Inngest rejects invalid configuration, event payloads and metadata inputs before HTTP", async (t) => {
|
|
117
|
+
const { options, service, requests } = await fixture(t);
|
|
118
|
+
const parse = (config) => parseIntegrationConfiguration(JSON.stringify(config), { providers: [inngestProvider] });
|
|
119
|
+
assert.deepEqual(parse(options.configuration), options.configuration);
|
|
120
|
+
for (const eventKeyRef of [undefined, "raw-key", "env: KEY"]) {
|
|
121
|
+
const config = structuredClone(options.configuration);
|
|
122
|
+
config.integrations.jobs.settings.eventKeyRef = eventKeyRef;
|
|
123
|
+
assert.throws(() => parse(config));
|
|
124
|
+
}
|
|
125
|
+
for (const branchEnvironment of ["branch\nAuthorization: other", "branch with spaces", "bránch"]) {
|
|
126
|
+
const config = structuredClone(options.configuration);
|
|
127
|
+
config.integrations.jobs.settings.branchEnvironment = branchEnvironment;
|
|
128
|
+
assert.throws(() => parse(config));
|
|
129
|
+
}
|
|
130
|
+
await service.connectApiKey(input);
|
|
131
|
+
const count = requests.length;
|
|
132
|
+
for (const invalid of [{ name: event.name }, { name: event.name, data: [] }, { ...event, ts: 1 }, { ...event, ts: 1788920000000.5 }, { ...event, url: "https://unexpected.example" }]) {
|
|
133
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: invalid }), { code: "connector_input_invalid" });
|
|
134
|
+
}
|
|
135
|
+
for (const value of [0, 101, 3.5]) await assert.rejects(service.invoke({ ...input, operation: "apps.list", input: { limit: value } }), { code: "connector_input_invalid" });
|
|
136
|
+
for (const appId of [".", "..", ""]) await assert.rejects(service.invoke({ ...input, operation: "functions.list", input: { appId } }), { code: "connector_input_invalid" });
|
|
137
|
+
assert.equal(requests.length, count);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("Inngest authorizes event name and data before delivery and checks the Event Key binding lazily", async (t) => {
|
|
141
|
+
const { service, requests, state, resolved } = await fixture(t);
|
|
142
|
+
await service.connectApiKey(input);
|
|
143
|
+
const count = requests.length;
|
|
144
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: { ...event, name: "unauthorized/event" } }), { code: "connector_access_denied" });
|
|
145
|
+
assert.equal(resolved.includes("env:INNGEST_EVENT_KEY"), false);
|
|
146
|
+
for (const key of [undefined, "", "\n", "key\rAuthorization: secret"]) {
|
|
147
|
+
state.eventKey = key;
|
|
148
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: event }), { code: "connector_binding_missing" });
|
|
149
|
+
}
|
|
150
|
+
assert.equal(requests.length, count);
|
|
151
|
+
assert.equal((await service.status(input)).status, "connected", "The existing metadata grant remains; Event Key validity was not asserted.");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("Inngest rejects ambiguous event receipts and provider failures without disclosing credentials or retrying", async (t) => {
|
|
155
|
+
const { service, state, requests } = await fixture(t);
|
|
156
|
+
await service.connectApiKey(input);
|
|
157
|
+
for (const [receipt, code] of [[{ status: 200, ids: [] }, "connector_response_invalid"], [{ status: 200, ids: ["id"], error: "failure" }, "connector_response_invalid"],
|
|
158
|
+
[{ status: 403, error: "denied" }, "connector_permission_denied"], [{ status: 429, error: "limited" }, "connector_rate_limited"]]) {
|
|
159
|
+
state.receipt = receipt;
|
|
160
|
+
const count = requests.length;
|
|
161
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: event }), { code });
|
|
162
|
+
assert.equal(requests.length, count + 1);
|
|
163
|
+
}
|
|
164
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
|
|
165
|
+
state.httpStatus = status;
|
|
166
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: event }), (error) => error.code === code && !error.message.includes(state.eventKey) && !error.message.includes(state.signing));
|
|
167
|
+
}
|
|
168
|
+
state.httpStatus = 401;
|
|
169
|
+
await assert.rejects(service.invoke({ ...input, operation: "events.send", input: event }), { code: "connector_reconnect_required" });
|
|
170
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("Inngest rejects malformed metadata and failed verification without creating a connection", async (t) => {
|
|
174
|
+
const { service, state } = await fixture(t);
|
|
175
|
+
for (const page of [{ ok: true }, { data: [{}], page: { hasMore: false, limit: 20 } }, { data: [], page: { hasMore: "false", limit: 20 } }]) {
|
|
176
|
+
state.page = page;
|
|
177
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_response_invalid" });
|
|
178
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
179
|
+
}
|
|
180
|
+
state.httpStatus = 401;
|
|
181
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_reconnect_required" });
|
|
182
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("Inngest reference and branch changes require reverification, including branch removal", async (t) => {
|
|
186
|
+
const { service, options, requests } = await fixture(t);
|
|
187
|
+
await service.connectApiKey(input);
|
|
188
|
+
for (const changes of [{ eventKeyRef: "env:OTHER_EVENT_KEY" }, { branchEnvironment: "other-branch" }, { branchEnvironment: undefined }]) {
|
|
189
|
+
const config = structuredClone(options.configuration);
|
|
190
|
+
Object.assign(config.integrations.jobs.settings, changes);
|
|
191
|
+
for (const [name, value] of Object.entries(changes)) if (value === undefined) delete config.integrations.jobs.settings[name];
|
|
192
|
+
const changed = createConnectionService({ ...options, configuration: config });
|
|
193
|
+
const count = requests.length;
|
|
194
|
+
assert.equal((await changed.status(input)).status, "reconnect-required");
|
|
195
|
+
await assert.rejects(changed.invoke({ ...input, operation: "events.send", input: event }), { code: "connector_reconnect_required" });
|
|
196
|
+
assert.equal(requests.length, count);
|
|
197
|
+
}
|
|
198
|
+
const config = structuredClone(options.configuration);
|
|
199
|
+
delete config.integrations.jobs.settings.branchEnvironment;
|
|
200
|
+
const changed = createConnectionService({ ...options, configuration: config });
|
|
201
|
+
await changed.connectApiKey(input);
|
|
202
|
+
await changed.invoke({ ...input, operation: "events.send", input: event });
|
|
203
|
+
assert.equal(requests.at(-1).headers.get("x-inngest-env"), null);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("Inngest cancellation reports interruption without replaying an event", async (t) => {
|
|
207
|
+
const { service, state, requests } = await fixture(t);
|
|
208
|
+
await service.connectApiKey(input);
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
state.pause = true;
|
|
211
|
+
const started = new Promise((resolve) => { state.started = resolve; });
|
|
212
|
+
const pending = service.invoke({ ...input, operation: "events.send", input: event, signal: controller.signal });
|
|
213
|
+
const rejected = assert.rejects(pending, { code: "connector_cancelled" });
|
|
214
|
+
await started;
|
|
215
|
+
controller.abort();
|
|
216
|
+
await rejected;
|
|
217
|
+
assert.equal(requests.length, 2);
|
|
218
|
+
assert.equal((await service.status(input)).status, "connected");
|
|
219
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
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 { lightspeedProvider } from "../src/server/lightspeed.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "application-one", subjectId: "user-one" };
|
|
12
|
+
const args = { context, integrationId: "retail" };
|
|
13
|
+
const scopes = ["products:read", "customers:read", "outlets:read"];
|
|
14
|
+
const callback = "https://app.example.test/oauth/lightspeed/callback";
|
|
15
|
+
const item = { id: "06bf537b-c783-11e6-f6b9-53a81bd6b215", version: 1690497245, name: null, first_name: null };
|
|
16
|
+
const page = { data: [item], version: { min: item.version, max: item.version } };
|
|
17
|
+
|
|
18
|
+
async function fixture(t, domainPrefix = "fixture-store", selectedScopes = scopes) {
|
|
19
|
+
const directory = await mkdtemp(path.join(tmpdir(), "lightspeed-connector-"));
|
|
20
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
21
|
+
const api = `https://${domainPrefix}.retail.lightspeed.app`;
|
|
22
|
+
const state = { time: Date.now(), count: 0, status: 200, value: undefined, tokenStatus: 200, tokenPatch: {}, hang: false };
|
|
23
|
+
const requests = [];
|
|
24
|
+
const configuration = { schemaVersion: 1, registrations: { lightspeed: { source: "own", clientId: "fixture-lightspeed-client", clientSecretRef: "env:LIGHTSPEED_SECRET", callbackUrlRef: "env:LIGHTSPEED_CALLBACK" } },
|
|
25
|
+
integrations: { retail: { provider: "lightspeed", accountMode: "per-user", settings: { domainPrefix }, scopes: selectedScopes, authentication: { method: "oauth2", registrationRef: "lightspeed" } } } };
|
|
26
|
+
const options = {
|
|
27
|
+
configuration, providers: [{ ...lightspeedProvider, requestTimeoutMs: 50 }], authorize: async (owner) => owner,
|
|
28
|
+
now: () => state.time, resolveReference: async (ref) => ({ "env:LIGHTSPEED_SECRET": "fixture-client-secret", "env:LIGHTSPEED_CALLBACK": callback })[ref],
|
|
29
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(5) }, activeKeyId: "current" }) }),
|
|
30
|
+
fetchImpl: async (address, init) => {
|
|
31
|
+
const url = new URL(String(address)); requests.push({ url, init });
|
|
32
|
+
assert.equal(url.origin, api);
|
|
33
|
+
if (url.pathname === "/api/1.0/token") {
|
|
34
|
+
assert.equal(init.method, "POST");
|
|
35
|
+
assert.match(new Headers(init.headers).get("content-type"), /application\/x-www-form-urlencoded/u);
|
|
36
|
+
const body = new URLSearchParams(init.body);
|
|
37
|
+
assert.equal(body.get("client_id"), "fixture-lightspeed-client"); assert.equal(body.get("client_secret"), "fixture-client-secret");
|
|
38
|
+
if (body.get("grant_type") === "authorization_code") { assert.equal(body.get("redirect_uri"), callback); assert.equal(body.get("code"), "fixture-code"); }
|
|
39
|
+
else { assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), `fixture-refresh-${state.count}`); }
|
|
40
|
+
state.count += 1;
|
|
41
|
+
return Response.json(state.tokenStatus === 200 ? { access_token: `fixture-access-${state.count}`, refresh_token: `fixture-refresh-${state.count}`,
|
|
42
|
+
token_type: "Bearer", expires_in: 60, expires: Math.floor(state.time / 1000) + 60, domain_prefix: domainPrefix, scope: selectedScopes.join(" "), ...state.tokenPatch }
|
|
43
|
+
: { error: "invalid_grant", error_description: "private provider details" }, { status: state.tokenStatus });
|
|
44
|
+
}
|
|
45
|
+
assert.match(url.pathname, /^\/api\/2026-07\/(products|customers|outlets|sales|inventory)$/u);
|
|
46
|
+
assert.equal(init.method, url.pathname.endsWith("/inventory") ? "POST" : "GET"); assert.equal(init.redirect, "error");
|
|
47
|
+
assert.equal(new Headers(init.headers).get("authorization"), `Bearer fixture-access-${state.count}`);
|
|
48
|
+
assert.equal(url.searchParams.has("access_token"), false);
|
|
49
|
+
if (state.hang) return new Promise((_, reject) => {
|
|
50
|
+
const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
|
|
51
|
+
const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
|
|
52
|
+
if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
|
|
53
|
+
});
|
|
54
|
+
if (state.status === 204) return new Response(null, { status: 204 });
|
|
55
|
+
if (typeof state.value === "string") return new Response(state.value, { status: state.status });
|
|
56
|
+
return Response.json(state.value === undefined ? page : state.value, { status: state.status });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const service = createConnectionService(options);
|
|
60
|
+
const start = async () => {
|
|
61
|
+
const { authorizationUrl } = await service.beginAuthorization({ ...args, verificationInput: { page_size: 1 } }); const url = new URL(authorizationUrl);
|
|
62
|
+
const redirect = new URL(callback); redirect.searchParams.set("code", "fixture-code"); redirect.searchParams.set("state", url.searchParams.get("state"));
|
|
63
|
+
redirect.searchParams.set("domain_prefix", domainPrefix); redirect.searchParams.set("scope", selectedScopes.join(" "));
|
|
64
|
+
return { url, callbackUrl: redirect.href };
|
|
65
|
+
};
|
|
66
|
+
const connect = async () => service.completeAuthorization({ ...args, callbackUrl: (await start()).callbackUrl });
|
|
67
|
+
return { directory, service, options, state, requests, start, connect, api };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("Lightspeed uses central code consent, a configured store token endpoint and encrypted restartable files", async (t) => {
|
|
71
|
+
const f = await fixture(t); const started = await f.start();
|
|
72
|
+
assert.equal(started.url.href.split("?")[0], "https://secure.retail.lightspeed.app/connect");
|
|
73
|
+
assert.equal(started.url.searchParams.get("scope"), scopes.join(" "));
|
|
74
|
+
assert.equal(started.url.searchParams.get("redirect_uri"), callback);
|
|
75
|
+
assert.equal(started.url.searchParams.get("code_challenge_method"), "S256"); assert.equal(started.url.searchParams.has("client_secret"), false);
|
|
76
|
+
await f.service.completeAuthorization({ ...args, callbackUrl: started.callbackUrl });
|
|
77
|
+
assert.ok(new URLSearchParams(f.requests[0].init.body).get("code_verifier"));
|
|
78
|
+
assert.equal(f.requests[1].url.search, "?page_size=1&deleted=false");
|
|
79
|
+
for (const file of await readdir(f.directory)) {
|
|
80
|
+
const text = await readFile(path.join(f.directory, file), "utf8");
|
|
81
|
+
for (const secret of ["fixture-code", "fixture-client-secret", "fixture-access-1", "fixture-refresh-1"]) assert.equal(text.includes(secret), false);
|
|
82
|
+
}
|
|
83
|
+
const restarted = createConnectionService(f.options);
|
|
84
|
+
assert.equal((await restarted.status(args)).status, "connected");
|
|
85
|
+
assert.deepEqual(await restarted.invoke({ ...args, operation: "products.list" }), page);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Lightspeed reads version pages, empty collections and nullable customer fields without traversing automatically", async (t) => {
|
|
89
|
+
const f = await fixture(t); await f.connect();
|
|
90
|
+
for (const operation of ["products.list", "customers.list", "outlets.list"]) {
|
|
91
|
+
const before = f.requests.length;
|
|
92
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation, input: { page_size: 2, after: 100, before: 2000000000, deleted: true } }), page);
|
|
93
|
+
assert.equal(f.requests.length, before + 1); assert.equal(f.requests.at(-1).url.search, "?after=100&before=2000000000&page_size=2&deleted=true");
|
|
94
|
+
f.state.value = { data: [], version: { min: null, max: null } };
|
|
95
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation }), f.state.value);
|
|
96
|
+
f.state.value = undefined;
|
|
97
|
+
}
|
|
98
|
+
f.state.value = { data: [{ ...item, first_name: null, last_name: null }] };
|
|
99
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation: "customers.list" }), f.state.value);
|
|
100
|
+
f.state.value = { data: [] }; assert.deepEqual(await f.service.invoke({ ...args, operation: "customers.list" }), { data: [] });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("Lightspeed rejects invalid version bounds, unsupported searches, arbitrary URLs and writes before transport", async (t) => {
|
|
104
|
+
const f = await fixture(t); await f.connect(); const before = f.requests.length;
|
|
105
|
+
for (const input of [{ after: -1 }, { after: 1.5 }, { after: Number.MAX_SAFE_INTEGER + 1 }, { before: -1 }, { before: 2.5 },
|
|
106
|
+
{ after: 10, before: 10 }, { after: 11, before: 10 }, { page_size: 101 }, { page_size: 0 }, { page_size: 1.5 }, { deleted: "wrong" },
|
|
107
|
+
{ url: "https://other.test" }, { domain_prefix: "other-store" }, { sku: "fixture" }, { name: "fixture" }]) {
|
|
108
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list", input }), { code: "connector_input_invalid" });
|
|
109
|
+
}
|
|
110
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.create" }), { code: "connector_operation_unknown" });
|
|
111
|
+
assert.equal(f.requests.length, before);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("Lightspeed uses actual granted scopes and host ownership before any store read", async (t) => {
|
|
115
|
+
const f = await fixture(t); f.state.tokenPatch.scope = "products:read"; await f.connect();
|
|
116
|
+
assert.deepEqual((await f.service.status(args)).grantedScopes, ["products:read"]);
|
|
117
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "customers.list" }), { code: "connector_scope_missing" });
|
|
118
|
+
for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
|
|
119
|
+
await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "products.list" }), { code: "connector_reconnect_required" });
|
|
120
|
+
}
|
|
121
|
+
const denied = createConnectionService({ ...f.options, authorize: async () => null });
|
|
122
|
+
await assert.rejects(denied.invoke({ ...args, operation: "products.list" }), { code: "connector_access_denied" });
|
|
123
|
+
assert.equal(f.requests.length, 2);
|
|
124
|
+
const reduced = await fixture(t); reduced.state.tokenPatch.scope = "customers:read";
|
|
125
|
+
await assert.rejects(reduced.connect(), { code: "connector_scope_missing" });
|
|
126
|
+
assert.equal((await reduced.service.status(args)).status, "disconnected"); assert.equal(reduced.requests.length, 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("Lightspeed persists every rotated refresh token even if the subsequent API read fails", async (t) => {
|
|
130
|
+
const f = await fixture(t); await f.connect(); f.state.time += 40_000;
|
|
131
|
+
await Promise.all([f.service.invoke({ ...args, operation: "products.list" }), f.service.invoke({ ...args, operation: "products.list" })]);
|
|
132
|
+
assert.equal(f.state.count, 2);
|
|
133
|
+
f.state.time += 40_000; f.state.status = 429;
|
|
134
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), { code: "connector_rate_limited" });
|
|
135
|
+
f.state.status = 200;
|
|
136
|
+
const restarted = createConnectionService(f.options); await restarted.invoke({ ...args, operation: "products.list" });
|
|
137
|
+
assert.equal(f.state.count, 3); assert.deepEqual((await restarted.status(args)).grantedScopes, scopes);
|
|
138
|
+
f.state.time += 40_000; await restarted.invoke({ ...args, operation: "customers.list" }); assert.equal(f.state.count, 4);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("Lightspeed rejects mismatched store grants and never follows callback-supplied destinations", async (t) => {
|
|
142
|
+
for (const prefix of [undefined, "other-store", "fixture-store.attacker.test", "https://fixture-store.retail.lightspeed.app"]) {
|
|
143
|
+
const f = await fixture(t); f.state.tokenPatch.domain_prefix = prefix;
|
|
144
|
+
const start = await f.start(); const redirect = new URL(start.callbackUrl); redirect.searchParams.set("domain_prefix", "attacker.test");
|
|
145
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: redirect.href }), { code: "connector_response_invalid" });
|
|
146
|
+
assert.equal(f.requests.length, 1); assert.equal(f.requests[0].url.origin, f.api);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("Lightspeed requires scopes, expiry and replacement refresh tokens from successful token responses", async (t) => {
|
|
151
|
+
for (const patch of [{ scope: undefined }, { scope: "" }, { scope: "products:read\ncustomers:read" }, { expires_in: undefined },
|
|
152
|
+
{ expires_in: 0 }, { expires_in: 1.5 }, { refresh_token: undefined }, { refresh_token: "" }]) {
|
|
153
|
+
const f = await fixture(t); f.state.tokenPatch = patch;
|
|
154
|
+
await assert.rejects(f.connect(), { code: "connector_response_invalid" }); assert.equal(f.requests.length, 1);
|
|
155
|
+
}
|
|
156
|
+
const f = await fixture(t); await f.connect(); f.state.time += 40_000; f.state.tokenPatch.refresh_token = undefined;
|
|
157
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), { code: "connector_response_invalid" });
|
|
158
|
+
assert.equal(f.requests.length, 3);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("Lightspeed configuration changes invalidate grants and denied, cancelled or replayed consent preserves saved access", async (t) => {
|
|
162
|
+
const f = await fixture(t); await f.connect();
|
|
163
|
+
const configuration = structuredClone(f.options.configuration); configuration.integrations.retail.settings.domainPrefix = "other-store";
|
|
164
|
+
await assert.rejects(createConnectionService({ ...f.options, configuration }).invoke({ ...args, operation: "products.list" }), { code: "connector_reconnect_required" });
|
|
165
|
+
const start = await f.start(); const denied = new URL(start.callbackUrl); denied.searchParams.delete("code"); denied.searchParams.set("error", "access_denied");
|
|
166
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: denied.href }), { code: "connector_consent_denied" });
|
|
167
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
168
|
+
const pending = await f.start(); await f.service.cancelAuthorization({ ...args, state: pending.url.searchParams.get("state") });
|
|
169
|
+
await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: pending.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
170
|
+
assert.equal((await f.service.status(args)).status, "connected");
|
|
171
|
+
await f.service.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected");
|
|
172
|
+
assert.equal(f.requests.length, 2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("Lightspeed rejects malformed successes and oversized pages and redacts provider failures", async (t) => {
|
|
176
|
+
const f = await fixture(t); await f.connect();
|
|
177
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [500, "connector_provider_failed"], [429, "connector_rate_limited"]]) {
|
|
178
|
+
f.state.status = status; f.state.value = { message: "private provider details" };
|
|
179
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), (error) => error.code === code && !error.message.includes("private"));
|
|
180
|
+
}
|
|
181
|
+
f.state.status = 200;
|
|
182
|
+
for (const value of [null, "invalid json", {}, { data: [] }, { data: [{ ...item, version: Number.MAX_SAFE_INTEGER + 1 }], version: page.version },
|
|
183
|
+
{ data: [{ ...item, id: null }], version: page.version }, { data: [], version: { min: 0, max: null } },
|
|
184
|
+
{ data: [item], version: { min: item.version + 1, max: item.version } }, { data: [item, item], version: page.version }]) {
|
|
185
|
+
f.state.value = value;
|
|
186
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list", input: { page_size: 1 } }), (error) => ["connector_response_invalid", "connector_provider_failed"].includes(error.code));
|
|
187
|
+
}
|
|
188
|
+
f.state.value = undefined; f.state.status = 204;
|
|
189
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), { code: "connector_response_invalid" });
|
|
190
|
+
f.state.status = 401;
|
|
191
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), { code: "connector_reconnect_required" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("Lightspeed invalid refresh tokens require reconnect without returning provider text", async (t) => {
|
|
195
|
+
const f = await fixture(t); await f.connect(); f.state.time += 40_000; f.state.tokenStatus = 400;
|
|
196
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "products.list" }), (error) => error.code === "connector_reconnect_required" && !error.message.includes("private"));
|
|
197
|
+
assert.equal((await f.service.status(args)).status, "reconnect-required"); assert.equal(f.requests.length, 3);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("Lightspeed delivers cancellation and timeout without retry", async (t) => {
|
|
201
|
+
const f = await fixture(t); await f.connect(); f.state.hang = true;
|
|
202
|
+
const abort = new AbortController(); const result = f.service.invoke({ ...args, operation: "customers.list", signal: abort.signal });
|
|
203
|
+
setTimeout(() => abort.abort(), 10); await assert.rejects(result, { code: "connector_cancelled" });
|
|
204
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "customers.list" }), { code: "connector_provider_timeout" });
|
|
205
|
+
assert.equal(f.requests.length, 4);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("Lightspeed guide and form share strict store/reference validation and all 49 permissions", async () => {
|
|
209
|
+
const guide = await readFile(new URL("../docs/lightspeed.md", import.meta.url), "utf8");
|
|
210
|
+
const json = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
|
|
211
|
+
const configuration = parseIntegrationConfiguration(json, { providers: [lightspeedProvider] });
|
|
212
|
+
assert.equal(configuration.integrations.retail.settings.domainPrefix, "your-store");
|
|
213
|
+
assert.equal(lightspeedProvider.scopes.length, 49); assert.equal(lightspeedProvider.scopes.filter((scope) => scope.recommended).length, 16);
|
|
214
|
+
for (const value of ["", "https://your-store.retail.lightspeed.app", "a.b", "a b", "a/b", "a@b", "a?b", "-a", "a-", "STORE", "a".repeat(64)]) {
|
|
215
|
+
const invalid = JSON.parse(json); invalid.integrations.retail.settings.domainPrefix = value;
|
|
216
|
+
assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [lightspeedProvider] }));
|
|
217
|
+
}
|
|
218
|
+
for (const [field, value] of [["clientSecretRef", "raw-secret"], ["callbackUrlRef", callback]]) {
|
|
219
|
+
const invalid = JSON.parse(json); invalid.registrations.lightspeed[field] = value;
|
|
220
|
+
assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [lightspeedProvider] }));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
test("Lightspeed inventory and sales preserve quantities, paging and permission boundaries", async t => {
|
|
226
|
+
const f = await fixture(t, "fixture-store", [...scopes, "inventory:read", "sales:read"]); await f.connect();
|
|
227
|
+
f.state.value = [{ product_id: item.id, outlet_id: "outlet-1", version: 100, current_inventory_level: "4.000", quantity_to_procure: "2.000" }];
|
|
228
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation: "inventory.list", input: { size: 2, product_id: item.id, variants: true } }), f.state.value);
|
|
229
|
+
assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { size: 2, include_deleted: false, sort_direction: "asc", product_id: item.id, variants: true });
|
|
230
|
+
await f.service.invoke({ ...args, operation: "inventory.list", input: { after: 100, size: 2 } });
|
|
231
|
+
assert.equal(JSON.parse(f.requests.at(-1).init.body).after, 100);
|
|
232
|
+
f.state.value = [];
|
|
233
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation: "inventory.list" }), []);
|
|
234
|
+
f.state.value = { data: [{ ...item, customer_id: "customer-1", total_price: "42.50" }], version: page.version };
|
|
235
|
+
assert.deepEqual(await f.service.invoke({ ...args, operation: "sales.list", input: { page_size: 2 } }), f.state.value);
|
|
236
|
+
assert.equal(f.requests.at(-1).url.search, "?page_size=2");
|
|
237
|
+
const before = f.requests.length;
|
|
238
|
+
for (const invalid of [{ variants: true }, { after: 2, before: 1 }, { size: 1001 }, { product_id: "../other" }])
|
|
239
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "inventory.list", input: invalid }), { code: "connector_input_invalid" });
|
|
240
|
+
const denied = createConnectionService({ ...f.options, authorize: async (owner, request) => request.operation === "sales.list" ? null : owner });
|
|
241
|
+
await assert.rejects(denied.invoke({ ...args, operation: "sales.list" }), { code: "connector_access_denied" });
|
|
242
|
+
assert.equal(f.requests.length, before);
|
|
243
|
+
for (const malformed of [{ data: [] }, [{ version: "unknown" }], [{ version: 1 }, { version: 2 }]]) {
|
|
244
|
+
f.state.value = malformed;
|
|
245
|
+
await assert.rejects(f.service.invoke({ ...args, operation: "inventory.list", input: { size: 1 } }), { code: "connector_response_invalid" });
|
|
246
|
+
}
|
|
247
|
+
const limited = await fixture(t); await limited.connect(); const count = limited.requests.length;
|
|
248
|
+
await assert.rejects(limited.service.invoke({ ...args, operation: "inventory.list" }), { code: "connector_scope_missing" });
|
|
249
|
+
assert.equal(limited.requests.length, count);
|
|
250
|
+
});
|