@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,350 @@
|
|
|
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 { validateIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { createGoogleAdsSearchService } from "../src/server/google-ads-search.js";
|
|
10
|
+
import { validateGoogleAdsSearchPlan } from "../src/shared/google-ads-search.js";
|
|
11
|
+
import { dispatchAdsSetup } from "../patterns/google-ads-search/example/ads-setup.js";
|
|
12
|
+
import { googleAdsProvider as provider } from "../src/server/google-ads.js";
|
|
13
|
+
|
|
14
|
+
const context = { applicationId: "ads-app", subjectId: "user-one" };
|
|
15
|
+
const input = { context, integrationId: "ads" };
|
|
16
|
+
const callback = "https://app.example.test/connections/google-ads/callback";
|
|
17
|
+
const scope = "https://www.googleapis.com/auth/adwords";
|
|
18
|
+
const customerId = "1234567890";
|
|
19
|
+
const report = { results: [{ campaign: { id: "9007199254740993", name: "Fixture" }, metrics: { impressions: "9007199254740995", costMicros: "2200000" } }],
|
|
20
|
+
fieldMask: "campaign.id,campaign.name,metrics.impressions,metrics.costMicros", nextPageToken: "fixture+/=", totalResultsCount: "9007199254740996" };
|
|
21
|
+
const query = "SELECT campaign.id, campaign.name, metrics.impressions, metrics.cost_micros FROM campaign WHERE campaign.name = 'A + B & 😀' LIMIT 20";
|
|
22
|
+
|
|
23
|
+
async function fixture(t, settings = { loginCustomerId: "9876543210" }) {
|
|
24
|
+
const directory = await mkdtemp(path.join(tmpdir(), "google-ads-connector-"));
|
|
25
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
26
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(6) }, activeKeyId: "current" });
|
|
27
|
+
const configuration = { schemaVersion: 1, integrations: { ads: { provider: "google-ads", accountMode: "per-user", settings,
|
|
28
|
+
scopes: [scope], authentication: { method: "oauth2", registrationRef: "google" } } },
|
|
29
|
+
registrations: { google: { source: "own", clientId: "fixture-client", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK" } } };
|
|
30
|
+
const state = { time: Date.now(), calls: [], resolved: [], secret: "secret-fixture", grants: 0,
|
|
31
|
+
response: report, accounts: { resourceNames: ["customers/1234567890", "customers/9876543210"] }, scope, status: 200, tokenStatus: 200,
|
|
32
|
+
refresh: true, deny: false, stall: false };
|
|
33
|
+
const options = { configuration, providers: [provider], store: createFileConnectionStore({ directory, protection }), now: () => state.time,
|
|
34
|
+
authorize: async (owner) => { if (state.deny) throw new Error("Host denied"); return owner; },
|
|
35
|
+
resolveReference: async (ref) => {
|
|
36
|
+
state.resolved.push(ref);
|
|
37
|
+
if (ref === "env:CALLBACK") return callback;
|
|
38
|
+
if (ref === "env:SECRET") return state.secret;
|
|
39
|
+
throw new Error("Unknown reference");
|
|
40
|
+
},
|
|
41
|
+
fetchImpl: async (address, init) => {
|
|
42
|
+
const url = new URL(String(address)); const headers = new Headers(init.headers);
|
|
43
|
+
state.calls.push({ url, init, headers });
|
|
44
|
+
const token = url.href === "https://oauth2.googleapis.com/token";
|
|
45
|
+
if (state.stall === true || state.stall === (token ? "token" : "api")) return new Promise((resolve, reject) => {
|
|
46
|
+
init.signal.throwIfAborted(); init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
47
|
+
});
|
|
48
|
+
if (token) {
|
|
49
|
+
assert.equal(init.redirect, "manual"); assert.equal(headers.has("developer-token"), false); assert.equal(headers.has("login-customer-id"), false);
|
|
50
|
+
const body = new URLSearchParams(init.body);
|
|
51
|
+
assert.equal(body.get("client_id"), "fixture-client"); assert.equal(body.get("client_secret"), state.secret);
|
|
52
|
+
if (body.get("grant_type") === "refresh_token") assert.equal(body.get("refresh_token"), state.lastRefresh);
|
|
53
|
+
if (state.tokenStatus !== 200) return Response.json({ error: "invalid_grant", error_description: "private-binding-value" }, { status: state.tokenStatus });
|
|
54
|
+
state.grants++;
|
|
55
|
+
if (state.refresh) state.lastRefresh = `refresh-fixture-${state.grants}`;
|
|
56
|
+
return Response.json({ access_token: `access-fixture-${state.grants}`, token_type: "Bearer", expires_in: 3600, scope: state.scope,
|
|
57
|
+
...(state.refresh ? { refresh_token: state.lastRefresh } : {}) });
|
|
58
|
+
}
|
|
59
|
+
assert.equal(url.origin, "https://googleads.googleapis.com"); assert.ok(url.pathname.startsWith("/v25/"));
|
|
60
|
+
assert.equal(init.credentials, "omit"); assert.equal(init.redirect, "error");
|
|
61
|
+
assert.equal(headers.get("authorization"), `Bearer access-fixture-${state.grants}`);
|
|
62
|
+
assert.equal(headers.get("developer-token"), null);
|
|
63
|
+
assert.equal(url.search, "");
|
|
64
|
+
const accounts = url.pathname.endsWith(":listAccessibleCustomers");
|
|
65
|
+
assert.equal(init.method, accounts ? "GET" : "POST");
|
|
66
|
+
assert.equal(headers.get("login-customer-id"), accounts ? null : settings.loginCustomerId || null);
|
|
67
|
+
if (accounts) assert.equal(init.body, undefined);
|
|
68
|
+
else { assert.equal(headers.get("content-type"), "application/json"); assert.equal(typeof init.body, "string"); }
|
|
69
|
+
if (state.raw !== undefined) return new Response(state.raw, { status: state.status, headers: { "Content-Type": "application/json" } });
|
|
70
|
+
if (state.status !== 200 || state.error) return Response.json({ error: state.error || { status: "UNKNOWN", message: "private-binding-value" } }, { status: state.status });
|
|
71
|
+
return Response.json(accounts ? state.accounts : state.respond ? state.respond(url, JSON.parse(init.body)) : state.response);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const service = createConnectionService(options);
|
|
75
|
+
const start = async (target = service) => {
|
|
76
|
+
const result = await target.beginAuthorization(input); const url = new URL(result.authorizationUrl);
|
|
77
|
+
const returned = new URL(callback); returned.searchParams.set("code", "fixture-code"); returned.searchParams.set("state", url.searchParams.get("state"));
|
|
78
|
+
return { url, callbackUrl: returned.href };
|
|
79
|
+
};
|
|
80
|
+
const connect = async (target = service) => target.completeAuthorization({ ...input, callbackUrl: (await start(target)).callbackUrl });
|
|
81
|
+
const invoke = (values = { customerId, query }, target = service) => target.invoke({ ...input, operation: "reports.search", input: values });
|
|
82
|
+
return { options, service, state, directory, protection, configuration, start, connect, invoke };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("Google Ads CLI validates project access settings and rejects retired credential modes", async (t) => {
|
|
86
|
+
const f = await fixture(t);
|
|
87
|
+
for (const settings of [{}, { loginCustomerId: customerId }]) {
|
|
88
|
+
const config = structuredClone(f.configuration); config.integrations.ads.settings = settings;
|
|
89
|
+
assert.deepEqual(validateIntegrationConfiguration(config, { providers: [provider] }).integrations.ads.settings, settings);
|
|
90
|
+
}
|
|
91
|
+
for (const settings of [{ apiAccess: "developer-token" }, { apiAccess: "cloud-organization" }, { developerTokenRef: "env:ADS_TOKEN" },
|
|
92
|
+
...["123-456-7890", "123", " 1234567890", "123456789x"].map(loginCustomerId => ({ loginCustomerId }))]) {
|
|
93
|
+
const config = structuredClone(f.configuration); config.integrations.ads.settings = settings;
|
|
94
|
+
assert.throws(() => validateIntegrationConfiguration(config, { providers: [provider] }));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("Google Ads verifies direct accounts through fixed OAuth PKCE and omits retired developer tokens and scopes manager headers", async (t) => {
|
|
99
|
+
for (const settings of [{ loginCustomerId: "9876543210" }, {}]) {
|
|
100
|
+
const f = await fixture(t, settings); const { url, callbackUrl } = await f.start();
|
|
101
|
+
assert.equal(url.origin + url.pathname, "https://accounts.google.com/o/oauth2/v2/auth");
|
|
102
|
+
assert.equal(url.searchParams.get("scope"), scope); assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
103
|
+
assert.equal(url.searchParams.get("access_type"), "offline"); assert.equal(url.searchParams.get("prompt"), "consent");
|
|
104
|
+
assert.equal(url.searchParams.get("redirect_uri"), callback); assert.equal(url.searchParams.has("client_secret"), false);
|
|
105
|
+
assert.equal((await f.service.completeAuthorization({ ...input, callbackUrl })).status, "connected");
|
|
106
|
+
assert.equal(f.state.calls.length, 2); assert.equal(f.state.calls[1].url.pathname, "/v25/customers:listAccessibleCustomers");
|
|
107
|
+
assert.equal(f.state.resolved.includes("env:ADS_TOKEN"), false);
|
|
108
|
+
await assert.rejects(f.service.completeAuthorization({ ...input, callbackUrl }));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("Google Ads reports preserve GAQL, opaque pages and int64 strings; hierarchy reads have fixed queries", async (t) => {
|
|
113
|
+
for (const settings of [{ loginCustomerId: "9876543210" }, {}]) {
|
|
114
|
+
const f = await fixture(t, settings); await f.connect();
|
|
115
|
+
assert.deepEqual(await f.invoke(), report);
|
|
116
|
+
assert.deepEqual(JSON.parse(f.state.calls.at(-1).init.body), { query });
|
|
117
|
+
await f.invoke({ customerId, query, pageToken: report.nextPageToken });
|
|
118
|
+
assert.deepEqual(JSON.parse(f.state.calls.at(-1).init.body), { query, pageToken: report.nextPageToken });
|
|
119
|
+
assert.equal(f.state.calls.at(-1).url.pathname, `/v25/customers/${customerId}/googleAds:search`);
|
|
120
|
+
await f.service.invoke({ ...input, operation: "customers.listClients", input: { customerId } });
|
|
121
|
+
const body = JSON.parse(f.state.calls.at(-1).init.body);
|
|
122
|
+
assert.ok(body.query.includes("FROM customer_client")); assert.ok(body.query.includes("customer_client.level"));
|
|
123
|
+
for (const empty of [{}, { results: [] }, { fieldMask: "campaign.id", totalResultsCount: "0", nextPageToken: "" }]) {
|
|
124
|
+
f.state.response = empty; assert.deepEqual(await f.invoke(), empty);
|
|
125
|
+
}
|
|
126
|
+
f.state.accounts = {};
|
|
127
|
+
assert.deepEqual(await f.service.invoke({ ...input, operation: "customers.listAccessible" }), {});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("Google Ads rejects malformed IDs, unbounded queries and caller transport fields before API access", async (t) => {
|
|
132
|
+
const f = await fixture(t); await f.connect();
|
|
133
|
+
const invalid = [{ customerId: "123-456-7890", query }, { customerId: "1234567890/../x", query },
|
|
134
|
+
{ customerId, query: "DELETE FROM campaign" }, { customerId, query: "SELECT\nx FROM campaign" }, { customerId, query: "SELECT " + "a".repeat(16000) },
|
|
135
|
+
{ customerId, query: "SELECT '" + "😀".repeat(8001) + "' FROM campaign" }, { customerId, query, pageToken: "a\nb" },
|
|
136
|
+
{ customerId, query, pageToken: "a".repeat(4097) }, { customerId, query, pageSize: 50 }, { customerId, query, headers: { "developer-token": "evil" } },
|
|
137
|
+
{ customerId, query, url: "https://evil.invalid" }, { customerId, query, loginCustomerId: "1111111111" }];
|
|
138
|
+
for (const values of invalid) {
|
|
139
|
+
const calls = f.state.calls.length; const resolved = f.state.resolved.filter((ref) => ref === "env:ADS_TOKEN").length;
|
|
140
|
+
await assert.rejects(f.invoke(values), { code: "connector_input_invalid" });
|
|
141
|
+
assert.equal(f.state.calls.length, calls); assert.equal(f.state.resolved.filter((ref) => ref === "env:ADS_TOKEN").length, resolved);
|
|
142
|
+
}
|
|
143
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "customers.listClients", input: { customerId, query } }), { code: "connector_input_invalid" });
|
|
144
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "customers.listAccessible", input: { customerId } }), { code: "connector_input_invalid" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("Google Ads encrypted file grants survive restart, isolate owners, renew once and retain refresh tokens", async (t) => {
|
|
148
|
+
const f = await fixture(t); await f.connect();
|
|
149
|
+
const restarted = createConnectionService({ ...f.options, store: createFileConnectionStore({ directory: f.directory, protection: f.protection }) });
|
|
150
|
+
assert.equal((await restarted.status(input)).status, "connected");
|
|
151
|
+
await assert.rejects(restarted.invoke({ ...input, context: { ...context, subjectId: "user-two" }, operation: "customers.listAccessible" }), { code: "connector_reconnect_required" });
|
|
152
|
+
await assert.rejects(restarted.invoke({ ...input, context: { ...context, applicationId: "other-app" }, operation: "customers.listAccessible" }), { code: "connector_reconnect_required" });
|
|
153
|
+
f.state.time += 3600000; f.state.refresh = false;
|
|
154
|
+
await Promise.all([f.invoke(undefined, restarted), f.invoke(undefined, createConnectionService(f.options))]);
|
|
155
|
+
assert.equal(f.state.grants, 2); assert.equal(f.state.lastRefresh, "refresh-fixture-1");
|
|
156
|
+
f.state.time += 3600000; f.state.refresh = true; await f.invoke(undefined, restarted);
|
|
157
|
+
assert.equal(f.state.grants, 3); assert.equal(f.state.lastRefresh, "refresh-fixture-3");
|
|
158
|
+
for (const file of await readdir(f.directory)) {
|
|
159
|
+
const text = await readFile(path.join(f.directory, file), "utf8");
|
|
160
|
+
for (const secret of ["secret-fixture", "access-fixture", "refresh-fixture"]) assert.equal(text.includes(secret), false);
|
|
161
|
+
}
|
|
162
|
+
const status = JSON.stringify(await restarted.status(input)); assert.equal(status.includes("access-fixture"), false);
|
|
163
|
+
await restarted.disconnect(input); assert.equal((await restarted.status(input)).status, "disconnected");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("Google Ads settings and client changes invalidate saved access and in-progress consent", async (t) => {
|
|
167
|
+
const f = await fixture(t); await f.connect();
|
|
168
|
+
for (const change of ["manager", "client"]) {
|
|
169
|
+
const started = await f.start(); const configuration = structuredClone(f.configuration);
|
|
170
|
+
if (change === "manager") configuration.integrations.ads.settings.loginCustomerId = "1111111111";
|
|
171
|
+
if (change === "client") configuration.registrations.google.clientId = "different-client";
|
|
172
|
+
const changed = createConnectionService({ ...f.options, configuration }); const calls = f.state.calls.length;
|
|
173
|
+
await assert.rejects(changed.invoke({ ...input, operation: "customers.listAccessible" }), { code: "connector_reconnect_required" });
|
|
174
|
+
await assert.rejects(changed.completeAuthorization({ ...input, callbackUrl: started.callbackUrl }));
|
|
175
|
+
assert.equal(f.state.calls.length, calls);
|
|
176
|
+
}
|
|
177
|
+
const configuration = structuredClone(f.configuration);
|
|
178
|
+
configuration.registrations.google.clientSecretRef = "env:MISSING";
|
|
179
|
+
const missing = createConnectionService({ ...f.options, configuration });
|
|
180
|
+
await assert.rejects(f.invoke(undefined, missing), { code: "connector_binding_missing" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("Google Ads validates malformed report/account responses and handles protobuf empty fields", async (t) => {
|
|
184
|
+
const f = await fixture(t); await f.connect();
|
|
185
|
+
for (const response of [[], null, { results: {} }, { results: [null] }, { results: Array(10001).fill({}) }, { nextPageToken: 5 },
|
|
186
|
+
{ nextPageToken: "a\nb" }, { nextPageToken: "a".repeat(4097) }, { totalResultsCount: 5 }, { totalResultsCount: "-1" }, { fieldMask: [] }]) {
|
|
187
|
+
f.state.response = response; await assert.rejects(f.invoke(), { code: "connector_response_invalid" });
|
|
188
|
+
}
|
|
189
|
+
for (const accounts of [{ resourceNames: {} }, { resourceNames: ["customers/123-456-7890"] }, { resourceNames: [null] }]) {
|
|
190
|
+
f.state.accounts = accounts;
|
|
191
|
+
await assert.rejects(f.service.invoke({ ...input, operation: "customers.listAccessible" }), { code: "connector_response_invalid" });
|
|
192
|
+
}
|
|
193
|
+
f.state.raw = "not JSON"; await assert.rejects(f.invoke(), { code: "connector_response_invalid" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("Google Ads distinguishes API approval, quota, scope, account and query failures without exposing details or replaying", async (t) => {
|
|
197
|
+
const f = await fixture(t); await f.connect();
|
|
198
|
+
const failure = (field, code) => ({ message: "private-binding-value", details: [{ errors: [{ errorCode: { [field]: code }, message: query }] }] });
|
|
199
|
+
for (const [status, error, code] of [
|
|
200
|
+
[403, failure("authorizationError", "CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION"), "connector_api_access_invalid"],
|
|
201
|
+
[401, failure("authenticationError", "CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION"), "connector_api_access_invalid"],
|
|
202
|
+
[400, failure("quotaError", "RESOURCE_EXHAUSTED"), "connector_rate_limited"],
|
|
203
|
+
[429, { status: "RESOURCE_EXHAUSTED" }, "connector_rate_limited"],
|
|
204
|
+
[403, { details: [{ reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT" }] }, "connector_scope_missing"],
|
|
205
|
+
[400, failure("queryError", "UNRECOGNIZED_FIELD"), "connector_input_invalid"],
|
|
206
|
+
[403, failure("authorizationError", "USER_PERMISSION_DENIED"), "connector_permission_denied"],
|
|
207
|
+
[404, {}, "connector_resource_not_found"], [500, {}, "connector_provider_failed"], [302, {}, "connector_provider_failed"],
|
|
208
|
+
[200, { message: "private-binding-value" }, "connector_provider_failed"]
|
|
209
|
+
]) {
|
|
210
|
+
f.state.status = status; f.state.error = error; const calls = f.state.calls.length;
|
|
211
|
+
await assert.rejects(f.invoke(), (actual) => actual.code === code && !actual.message.includes("private-binding-value") && !actual.message.includes(query));
|
|
212
|
+
assert.equal(f.state.calls.length, calls + 1); assert.equal((await f.service.status(input)).status, "connected");
|
|
213
|
+
}
|
|
214
|
+
f.state.status = 401; f.state.error = failure("authenticationError", "OAUTH_TOKEN_REVOKED");
|
|
215
|
+
await assert.rejects(f.invoke(), { code: "connector_reconnect_required" });
|
|
216
|
+
assert.equal((await f.service.status(input)).status, "reconnect-required");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("Google Ads host policy sees immutable account/query inputs and denies before credentials or HTTP", async (t) => {
|
|
220
|
+
const f = await fixture(t); await f.connect(); const values = { customerId, query };
|
|
221
|
+
const authorized = createConnectionService({ ...f.options, authorize: async (owner, operation) => {
|
|
222
|
+
if (operation.operation === "reports.search") {
|
|
223
|
+
assert.deepEqual(operation.input, { customerId, query }); operation.input.customerId = "1111111111"; values.query = "SELECT customer.id FROM customer";
|
|
224
|
+
}
|
|
225
|
+
return owner;
|
|
226
|
+
} });
|
|
227
|
+
await f.invoke(values, authorized); assert.equal(JSON.parse(f.state.calls.at(-1).init.body).query, query);
|
|
228
|
+
assert.equal(f.state.calls.at(-1).url.pathname, `/v25/customers/${customerId}/googleAds:search`);
|
|
229
|
+
f.state.deny = true; const calls = f.state.calls.length; const resolved = f.state.resolved.length;
|
|
230
|
+
await assert.rejects(f.invoke()); assert.equal(f.state.calls.length, calls); assert.equal(f.state.resolved.length, resolved);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("Google Ads rejects declined or insufficient consent and keeps managed assignment explicitly unavailable", async (t) => {
|
|
234
|
+
const f = await fixture(t); const started = await f.start();
|
|
235
|
+
const declined = new URL(started.callbackUrl); declined.searchParams.delete("code"); declined.searchParams.set("error", "access_denied");
|
|
236
|
+
await assert.rejects(f.service.completeAuthorization({ ...input, callbackUrl: declined.href })); assert.equal(f.state.calls.length, 0);
|
|
237
|
+
f.state.scope = "openid"; await assert.rejects(f.connect(), { code: "connector_scope_missing" }); assert.equal(f.state.calls.length, 1);
|
|
238
|
+
const configuration = structuredClone(f.configuration);
|
|
239
|
+
configuration.registrations.google = { source: "managed", serviceUrlRef: "env:GATEWAY", serviceCredentialRef: "env:IDENTITY", assignmentRef: "public" };
|
|
240
|
+
assert.throws(() => createConnectionService({ ...f.options, configuration }), { code: "integration_configuration_invalid" });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("Google Ads refresh scope reduction, API cancellation and timeout do not replay requests", async (t) => {
|
|
244
|
+
const f = await fixture(t); await f.connect(); f.state.stall = "api";
|
|
245
|
+
const timed = createConnectionService({ ...f.options, providers: [{ ...provider, requestTimeoutMs: 20 }] });
|
|
246
|
+
await assert.rejects(f.invoke(undefined, timed), { code: "connector_provider_timeout" });
|
|
247
|
+
const controller = new AbortController(); const pending = f.service.invoke({ ...input, operation: "reports.search", input: { customerId, query }, signal: controller.signal });
|
|
248
|
+
const timer = setTimeout(() => controller.abort(), 20); await assert.rejects(pending, { code: "connector_cancelled" }); clearTimeout(timer);
|
|
249
|
+
f.state.stall = false; f.state.time += 3600000; f.state.scope = "openid";
|
|
250
|
+
const calls = f.state.calls.length; await assert.rejects(f.invoke(), { code: "connector_scope_missing" }); assert.equal(f.state.calls.length, calls + 1);
|
|
251
|
+
const revoked = await fixture(t); await revoked.connect(); revoked.state.time += 3600000; revoked.state.tokenStatus = 400;
|
|
252
|
+
await assert.rejects(revoked.invoke(), { code: "connector_reconnect_required" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("Google Ads provider guide JSON uses the real portable validator", async () => {
|
|
256
|
+
const text = await readFile(new URL("../docs/google-ads.md", import.meta.url), "utf8");
|
|
257
|
+
const config = JSON.parse(text.match(/```json\n([\s\S]*?)\n```/u)[1]);
|
|
258
|
+
assert.deepEqual(validateIntegrationConfiguration(config, { providers: [provider] }), config);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const searchPlan = { customerId, name: "Dog grooming leads", currency: "AUD", dailyBudgetMicros: "12000000", maxCpcMicros: "1500000",
|
|
262
|
+
finalUrl: "https://dog.example.test/book", conversionActionId: "22", locationIds: ["2036"], languageId: "1000", nonPolitical: true,
|
|
263
|
+
keywords: ["dog grooming"], headlines: ["Book Dog Grooming", "Local Groomers", "Make A Booking"], descriptions: ["Book an appointment for your dog.", "Meet our experienced team."] };
|
|
264
|
+
async function searchFixture(t) {
|
|
265
|
+
const f = await fixture(t); await f.connect();
|
|
266
|
+
f.configuration.extensions = { googleAdsSearch: { ads: structuredClone(searchPlan) } };
|
|
267
|
+
const live = { campaign: { id: "33", name: searchPlan.name, status: "PAUSED", advertisingChannelType: "SEARCH", biddingStrategyType: "MANUAL_CPC",
|
|
268
|
+
containsEuPoliticalAdvertising: "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING" }, campaignBudget: { amountMicros: "12000000", id: "44" } };
|
|
269
|
+
f.state.respond = (url, body) => {
|
|
270
|
+
if (url.pathname.endsWith("googleAds:mutate")) return body.validateOnly ? {} : { mutateOperationResponses: [{ campaignResult: { resourceName: `customers/${customerId}/campaigns/33` } }] };
|
|
271
|
+
if (url.pathname.endsWith("campaigns:mutate")) { live.campaign.status = body.operations[0].update.status; return { results: [{ resourceName: `customers/${customerId}/campaigns/33` }] }; }
|
|
272
|
+
if (url.pathname.endsWith("conversionActions:mutate")) return { results: [{ resourceName: `customers/${customerId}/conversionActions/22` }] };
|
|
273
|
+
const q = body.query;
|
|
274
|
+
if (q.includes("FROM customer ")) return { results: [{ customer: { id: customerId, descriptiveName: "Dog And Groom", currencyCode: "AUD", timeZone: "Australia/Perth", status: "ENABLED", manager: false } }] };
|
|
275
|
+
if (q.includes("FROM conversion_action")) return { results: [{ conversionAction: { id: "22", name: "Booking", type: "WEBPAGE", status: "ENABLED", tagSnippets: [{ eventSnippet: "<script>fixture-only</script>" }] } }] };
|
|
276
|
+
if (q.includes("FROM geo_target_constant")) return { results: [{ geoTargetConstant: { id: "2036", canonicalName: "Australia" } }] };
|
|
277
|
+
if (q.includes("FROM language_constant")) return { results: [{ languageConstant: { id: "1000", name: "English" } }] };
|
|
278
|
+
if (q.includes("FROM ad_group_ad")) return { results: [{ adGroupAd: { ad: { id: "55", finalUrls: [searchPlan.finalUrl] }, status: "ENABLED" }, adGroup: { cpcBidMicros: "1500000" } }] };
|
|
279
|
+
if (q.includes("FROM conversion_goal_campaign_config")) return { results: [{ conversionGoalCampaignConfig: { goalConfigLevel: "CAMPAIGN", customConversionGoal: `customers/${customerId}/customConversionGoals/66` } }] };
|
|
280
|
+
if (q.includes("FROM custom_conversion_goal")) return { results: [{ customConversionGoal: { status: "ENABLED", name: "Booking", conversionActions: [`customers/${customerId}/conversionActions/22`] } }] };
|
|
281
|
+
if (q.includes("FROM campaign_criterion")) return { results: [{ campaignCriterion: { type: "LOCATION", location: { geoTargetConstant: "geoTargetConstants/2036" } } }] };
|
|
282
|
+
if (q.includes("FROM keyword_view")) return { results: [{ adGroupCriterion: { keyword: { text: "dog grooming", matchType: "PHRASE" } } }] };
|
|
283
|
+
if (q.includes("FROM campaign")) return { results: [structuredClone(live)] };
|
|
284
|
+
throw new Error(`Unexpected fixture query: ${q}`);
|
|
285
|
+
};
|
|
286
|
+
return { ...f, live, ads: createGoogleAdsSearchService({ connections: f.service, configuration: f.configuration, context, integrationId: "ads" }) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("Search plan rejects ambiguous money, missing targeting and political campaigns before HTTP", () => {
|
|
290
|
+
assert.deepEqual(validateGoogleAdsSearchPlan(searchPlan), searchPlan);
|
|
291
|
+
for (const invalid of [{ dailyBudgetMicros: 12000000 }, { maxCpcMicros: "1.5" }, { dailyBudgetMicros: "0" }, { locationIds: [] },
|
|
292
|
+
{ headlines: ["Only one"] }, { nonPolitical: false }, { finalUrl: "javascript:alert(1)" }, { customerId: "123-456-7890" }, { currency: "aud" }]) {
|
|
293
|
+
assert.throws(() => validateGoogleAdsSearchPlan({ ...searchPlan, ...invalid }), JSON.stringify(invalid));
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("Search workflow uses existing consent, Google validation, atomic paused creation and separate reviewed launch", async t => {
|
|
298
|
+
const f = await searchFixture(t);
|
|
299
|
+
assert.equal((await f.ads.discover(customerId)).account.currencyCode, "AUD");
|
|
300
|
+
assert.equal((await f.ads.targets(customerId, "Australia")).languages[0].languageConstant.id, "1000");
|
|
301
|
+
await f.ads.createConversion({ customerId, name: "Booking" });
|
|
302
|
+
const conversionRequest = JSON.parse(f.state.calls.at(-1).init.body).operations[0].create;
|
|
303
|
+
assert.equal(conversionRequest.type, "WEBPAGE"); assert.equal(conversionRequest.countingType, "ONE_PER_CLICK");
|
|
304
|
+
const preview = await f.ads.preview();
|
|
305
|
+
assert.equal(JSON.parse(f.state.calls.at(-1).init.body).validateOnly, true);
|
|
306
|
+
assert.deepEqual(await f.ads.create(preview.reviewId), { campaignId: "33", status: "PAUSED" });
|
|
307
|
+
const batch = JSON.parse(f.state.calls.at(-1).init.body);
|
|
308
|
+
assert.equal(batch.validateOnly, false); assert.equal(batch.partialFailure, false);
|
|
309
|
+
assert.equal(batch.mutateOperations[1].campaignOperation.create.status, "PAUSED");
|
|
310
|
+
assert.equal(batch.mutateOperations[1].campaignOperation.create.networkSettings.targetContentNetwork, false);
|
|
311
|
+
assert.equal(batch.mutateOperations[0].campaignBudgetOperation.create.amountMicros, "12000000");
|
|
312
|
+
assert.deepEqual(batch.mutateOperations.find(x => x.customConversionGoalOperation).customConversionGoalOperation.create.conversionActions, [`customers/${customerId}/conversionActions/22`]);
|
|
313
|
+
const inspection = await f.ads.campaign("33");
|
|
314
|
+
await assert.rejects(f.ads.launch({ campaignId: "33", reviewId: inspection.reviewId, trackingConfirmed: false, billingConfirmed: true }));
|
|
315
|
+
assert.equal(f.live.campaign.status, "PAUSED");
|
|
316
|
+
await f.ads.launch({ campaignId: "33", reviewId: inspection.reviewId, trackingConfirmed: true, billingConfirmed: true });
|
|
317
|
+
assert.equal(f.live.campaign.status, "ENABLED");
|
|
318
|
+
await f.ads.pause("33"); assert.equal(f.live.campaign.status, "PAUSED");
|
|
319
|
+
assert.equal((await f.ads.report()).campaigns[0].campaign.id, "33");
|
|
320
|
+
const before = f.state.grants;
|
|
321
|
+
f.configuration.extensions.googleAdsSearch.ads.headlines[0] = "Fresh Booking Copy";
|
|
322
|
+
const restarted = createConnectionService({ ...f.options, configuration: f.configuration });
|
|
323
|
+
assert.equal((await restarted.status(input)).status, "connected");
|
|
324
|
+
assert.equal(f.state.grants, before, "campaign changes do not require fresh OAuth consent");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("Search rejects stale plans and live budgets, unsupported campaigns and denied writes without automatic retries", async t => {
|
|
328
|
+
const f = await searchFixture(t); const preview = await f.ads.preview();
|
|
329
|
+
f.configuration.extensions.googleAdsSearch.ads.dailyBudgetMicros = "13000000";
|
|
330
|
+
await assert.rejects(f.ads.create(preview.reviewId), /changed/);
|
|
331
|
+
assert.equal(f.state.calls.filter(call => call.url.pathname.endsWith("googleAds:mutate") && !JSON.parse(call.init.body).validateOnly).length, 0);
|
|
332
|
+
let checked = await f.ads.campaign("33"); f.live.campaignBudget.amountMicros = "99000000";
|
|
333
|
+
await assert.rejects(f.ads.launch({ campaignId: "33", reviewId: checked.reviewId, trackingConfirmed: true, billingConfirmed: true }), /changed/);
|
|
334
|
+
f.live.campaign.biddingStrategyType = "MAXIMIZE_CONVERSIONS"; checked = await f.ads.campaign("33");
|
|
335
|
+
await assert.rejects(f.ads.launch({ campaignId: "33", reviewId: checked.reviewId, trackingConfirmed: true, billingConfirmed: true }), /manual CPC/);
|
|
336
|
+
f.state.status = 500; const calls = f.state.calls.length;
|
|
337
|
+
await assert.rejects(f.ads.pause("33"), { code: "connector_provider_failed" });
|
|
338
|
+
assert.equal(f.state.calls.length, calls + 1);
|
|
339
|
+
f.state.status = 200; f.state.deny = true; const deniedCalls = f.state.calls.length;
|
|
340
|
+
await assert.rejects(f.ads.pause("33")); assert.equal(f.state.calls.length, deniedCalls);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("Search application command fragment reuses the native service and emits bounded display results", async t => {
|
|
344
|
+
const f = await searchFixture(t);
|
|
345
|
+
const request = { protocol: "vibe64.integration-setup.command.v1", requestId: "ads-fixture", operation: "ads-preview", ads: {} };
|
|
346
|
+
const reply = await dispatchAdsSetup(request, f.ads);
|
|
347
|
+
assert.equal(reply.status, "ads"); assert.equal(reply.operation, "ads-preview"); assert.equal(reply.data.plan.name, searchPlan.name);
|
|
348
|
+
assert.equal(JSON.stringify(reply).includes("access-fixture"), false);
|
|
349
|
+
await assert.rejects(dispatchAdsSetup({ ...request, operation: "ads-arbitrary" }, f.ads));
|
|
350
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { googleMapsPlatformProvider } from "../src/server/google-maps-platform.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "app-one", subjectId: "team-one" };
|
|
12
|
+
const input = { context, integrationId: "maps" };
|
|
13
|
+
const verificationInput = { address: "Perth WA, Australia" };
|
|
14
|
+
const places = { status: "OK", results: [{ formatted_address: "Perth WA, Australia", place_id: "fixture-place",
|
|
15
|
+
geometry: { location: { lat: -31.95, lng: 115.86 } }, address_components: [], types: ["locality"], partial_match: true }] };
|
|
16
|
+
|
|
17
|
+
async function fixture(t) {
|
|
18
|
+
const directory = await mkdtemp(path.join(tmpdir(), "google-maps-connector-"));
|
|
19
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
20
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(5) }, activeKeyId: "current" });
|
|
21
|
+
const requests = [];
|
|
22
|
+
const state = { key: "fixture-server-key", status: 200, response: places };
|
|
23
|
+
const options = {
|
|
24
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
25
|
+
maps: { provider: "google-maps-platform", accountMode: "shared", scopes: [],
|
|
26
|
+
authentication: { method: "api-key", secretRef: "env:GOOGLE_MAPS_SERVER_KEY" },
|
|
27
|
+
settings: { browserKeyRef: "env:GOOGLE_MAPS_BROWSER_KEY" } }
|
|
28
|
+
}, extensions: { fromCli: true } },
|
|
29
|
+
providers: [googleMapsPlatformProvider], authorize: async (owner) => owner,
|
|
30
|
+
resolveReference: async (ref) => {
|
|
31
|
+
assert.equal(ref, "env:GOOGLE_MAPS_SERVER_KEY", "The optional browser key must never be resolved by backend operations.");
|
|
32
|
+
return state.key;
|
|
33
|
+
},
|
|
34
|
+
store: createFileConnectionStore({ directory, protection }),
|
|
35
|
+
fetchImpl: async (address, init) => {
|
|
36
|
+
requests.push({ url: new URL(String(address)), init, headers: new Headers(init.headers) });
|
|
37
|
+
return Response.json(state.response, { status: state.status });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return { service: createConnectionService(options), options, directory, protection, requests, state };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("Google Maps verification requires an explicit address and saves only references across restart", async (t) => {
|
|
44
|
+
assert.deepEqual(googleMapsPlatformProvider.verificationFields.filter((field) => field.required).map((field) => field.name), Object.keys(verificationInput));
|
|
45
|
+
const { service, options, directory, protection, requests, state } = await fixture(t);
|
|
46
|
+
await assert.rejects(service.connectApiKey(input), { code: "connector_input_invalid" });
|
|
47
|
+
assert.equal(requests.length, 0);
|
|
48
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
49
|
+
const connected = await service.connectApiKey({ ...input, verificationInput });
|
|
50
|
+
assert.equal(connected.status, "connected");
|
|
51
|
+
assert.equal(requests[0].url.origin, "https://maps.googleapis.com");
|
|
52
|
+
assert.equal(requests[0].url.pathname, "/maps/api/geocode/json");
|
|
53
|
+
assert.deepEqual(Object.fromEntries(requests[0].url.searchParams), { ...verificationInput, key: state.key });
|
|
54
|
+
assert.equal(requests[0].headers.has("referer"), false);
|
|
55
|
+
assert.equal(requests[0].init.redirect, "error");
|
|
56
|
+
assert.equal(JSON.stringify(connected).includes(state.key), false);
|
|
57
|
+
for (const name of await readdir(directory)) assert.equal((await readFile(path.join(directory, name), "utf8")).includes(state.key), false);
|
|
58
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
59
|
+
assert.deepEqual(await restarted.status(input), connected);
|
|
60
|
+
state.key = "replacement-server-key";
|
|
61
|
+
assert.deepEqual(await restarted.invoke({ ...input, operation: "geocoding.forward", input: verificationInput }), places);
|
|
62
|
+
assert.equal(requests.at(-1).url.searchParams.get("key"), state.key);
|
|
63
|
+
for (const owner of [{ ...context, applicationId: "other-app" }, { ...context, subjectId: "other-team" }]) {
|
|
64
|
+
await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "geocoding.forward", input: verificationInput }), { code: "connector_reconnect_required" });
|
|
65
|
+
}
|
|
66
|
+
assert.equal(requests.length, 2);
|
|
67
|
+
await restarted.disconnect(input);
|
|
68
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Google Maps keeps optional browser configuration portable and requires verification after changes", async (t) => {
|
|
72
|
+
const { service, options, requests } = await fixture(t);
|
|
73
|
+
const parse = (configuration) => parseIntegrationConfiguration(JSON.stringify(configuration), { providers: [googleMapsPlatformProvider] });
|
|
74
|
+
assert.deepEqual(parse(options.configuration), options.configuration);
|
|
75
|
+
const changed = structuredClone(options.configuration);
|
|
76
|
+
changed.integrations.maps.settings.browserKeyRef = "raw-browser-key";
|
|
77
|
+
assert.throws(() => parse(changed), (error) => Boolean(error.fieldErrors["integrations.maps.settings.browserKeyRef"]));
|
|
78
|
+
delete changed.integrations.maps.settings.browserKeyRef;
|
|
79
|
+
assert.deepEqual(parse(changed), changed);
|
|
80
|
+
await service.connectApiKey({ ...input, verificationInput });
|
|
81
|
+
const replacement = createConnectionService({ ...options, configuration: changed });
|
|
82
|
+
assert.equal((await replacement.status(input)).status, "reconnect-required");
|
|
83
|
+
await assert.rejects(replacement.invoke({ ...input, operation: "geocoding.forward", input: verificationInput }), { code: "connector_reconnect_required" });
|
|
84
|
+
assert.equal(requests.length, 1);
|
|
85
|
+
await replacement.connectApiKey({ ...input, verificationInput });
|
|
86
|
+
assert.equal((await replacement.status(input)).status, "connected");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Google Maps preserves geocoding results, plus codes, language and legitimate empty results", async (t) => {
|
|
90
|
+
const { service, state, requests } = await fixture(t);
|
|
91
|
+
await service.connectApiKey({ ...input, verificationInput });
|
|
92
|
+
const query = { address: "849VCWC8+R9 & Café", language: "pt-BR", region: "au" };
|
|
93
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "geocoding.forward", input: query }), places);
|
|
94
|
+
assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), { ...query, key: state.key });
|
|
95
|
+
assert.ok(requests.at(-1).url.href.includes("%2B"));
|
|
96
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "geocoding.reverse", input: { latitude: -31.95, longitude: 115.86, language: "en" } }), places);
|
|
97
|
+
assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), { latlng: "-31.95,115.86", language: "en", key: state.key });
|
|
98
|
+
state.response = { status: "ZERO_RESULTS", results: [] };
|
|
99
|
+
assert.deepEqual(await service.invoke({ ...input, operation: "geocoding.reverse", input: { latitude: 90, longitude: -180 } }), state.response);
|
|
100
|
+
assert.equal((await service.connectApiKey({ ...input, verificationInput })).status, "connected");
|
|
101
|
+
assert.equal(requests.length, 5);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("Google Maps rejects invalid inputs before transport without accepting key or destination overrides", async (t) => {
|
|
105
|
+
const { service, requests } = await fixture(t);
|
|
106
|
+
await service.connectApiKey({ ...input, verificationInput });
|
|
107
|
+
for (const query of [{}, { address: "" }, { address: " " }, { address: "x".repeat(1001) },
|
|
108
|
+
{ ...verificationInput, language: "en&key=other" }, { ...verificationInput, region: "a1" },
|
|
109
|
+
{ ...verificationInput, key: "other" }, { ...verificationInput, url: "https://attacker.invalid" }]) {
|
|
110
|
+
await assert.rejects(service.invoke({ ...input, operation: "geocoding.forward", input: query }), { code: "connector_input_invalid" });
|
|
111
|
+
}
|
|
112
|
+
for (const query of [{}, { latitude: 0 }, { longitude: 0 }, { latitude: -91, longitude: 0 },
|
|
113
|
+
{ latitude: 0, longitude: 181 }, { latitude: NaN, longitude: 0 }, { latitude: 0, longitude: Infinity },
|
|
114
|
+
{ latitude: 0, longitude: 0, latlng: "10,20" }]) {
|
|
115
|
+
await assert.rejects(service.invoke({ ...input, operation: "geocoding.reverse", input: query }), { code: "connector_input_invalid" });
|
|
116
|
+
}
|
|
117
|
+
assert.equal(requests.length, 1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("Google Maps treats HTTP-200 provider errors as failures without guessing ambiguous billing or key causes", async (t) => {
|
|
121
|
+
const { service, state } = await fixture(t);
|
|
122
|
+
for (const [status, code] of [["OVER_QUERY_LIMIT", "connector_rate_limited"], ["OVER_DAILY_LIMIT", "connector_configuration_invalid"],
|
|
123
|
+
["REQUEST_DENIED", "connector_permission_denied"], ["INVALID_REQUEST", "connector_input_invalid"], ["UNKNOWN_ERROR", "connector_provider_failed"]]) {
|
|
124
|
+
state.response = { status, results: [], error_message: `Diagnostic contains ${state.key}` };
|
|
125
|
+
await assert.rejects(service.connectApiKey({ ...input, verificationInput }), (error) => {
|
|
126
|
+
assert.equal(error.code, code);
|
|
127
|
+
assert.equal(error.stack.includes(state.key), false);
|
|
128
|
+
assert.equal(error.cause, undefined);
|
|
129
|
+
return true;
|
|
130
|
+
});
|
|
131
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
132
|
+
}
|
|
133
|
+
for (const response of [{}, { status: "unexpected", results: [] }, { status: "OK", results: [] },
|
|
134
|
+
{ status: "ZERO_RESULTS", results: places.results }, { status: "OK", results: [{}] },
|
|
135
|
+
{ status: "OK", results: [{ ...places.results[0], geometry: { location: { lat: 91, lng: 0 } } }] }]) {
|
|
136
|
+
state.response = response;
|
|
137
|
+
await assert.rejects(service.connectApiKey({ ...input, verificationInput }), { code: "connector_response_invalid" });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("Google Maps transport errors preserve safe failure categories and revoked connections require reconnecting", async (t) => {
|
|
142
|
+
const { service, state } = await fixture(t);
|
|
143
|
+
await service.connectApiKey({ ...input, verificationInput });
|
|
144
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"], [401, "connector_reconnect_required"]]) {
|
|
145
|
+
state.status = status;
|
|
146
|
+
state.response = { error_message: state.key };
|
|
147
|
+
await assert.rejects(service.invoke({ ...input, operation: "geocoding.forward", input: verificationInput }), (error) => {
|
|
148
|
+
assert.equal(error.code, code);
|
|
149
|
+
assert.equal(error.stack.includes(state.key), false);
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
test("Maps Places and Routes use bounded inputs, billing masks and only the server key", async t => {
|
|
158
|
+
const { service, state, requests } = await fixture(t);
|
|
159
|
+
await service.connectApiKey({ ...input, verificationInput });
|
|
160
|
+
const invoke = (operation, body) => service.invoke({ ...input, operation, input: body });
|
|
161
|
+
state.response = { places: [{ id: "place-one", displayName: { text: "Dog groomer" }, location: { latitude: -31.95, longitude: 115.86 } }], nextPageToken: "opaque" };
|
|
162
|
+
assert.equal((await invoke("places.search", { textQuery: "dog groomers Perth", pageToken: "prior" })).nextPageToken, "opaque");
|
|
163
|
+
let request = requests.at(-1);
|
|
164
|
+
assert.equal(request.url.href, "https://places.googleapis.com/v1/places:searchText");
|
|
165
|
+
assert.equal(request.headers.get("x-goog-api-key"), state.key); assert.equal(request.url.searchParams.has("key"), false);
|
|
166
|
+
assert.equal(request.headers.get("x-goog-fieldmask").includes("*"), false);
|
|
167
|
+
assert.equal(request.headers.get("x-goog-fieldmask").includes("places.attributions"), true);
|
|
168
|
+
assert.deepEqual(JSON.parse(request.init.body), { textQuery: "dog groomers Perth", pageSize: 10, pageToken: "prior" });
|
|
169
|
+
state.response = { id: "place-one" };
|
|
170
|
+
await invoke("places.get", { placeId: "place-one" });
|
|
171
|
+
assert.equal(requests.at(-1).url.pathname, "/v1/places/place-one");
|
|
172
|
+
const routeInput = { originLatitude: -31.95, originLongitude: 115.86, destinationLatitude: -31.96, destinationLongitude: 115.87, travelMode: "WALK" };
|
|
173
|
+
state.response = { routes: [{ duration: "120s", distanceMeters: 400, polyline: { geoJsonLinestring: { type: "LineString", coordinates: [[115.86, -31.95], [115.87, -31.96]] } } }] };
|
|
174
|
+
assert.equal((await invoke("routes.compute", routeInput)).routes[0].distanceMeters, 400);
|
|
175
|
+
request = requests.at(-1);
|
|
176
|
+
assert.equal(request.url.href, "https://routes.googleapis.com/directions/v2:computeRoutes");
|
|
177
|
+
const body = JSON.parse(request.init.body);
|
|
178
|
+
assert.deepEqual(body.origin.location.latLng, { latitude: -31.95, longitude: 115.86 });
|
|
179
|
+
assert.equal(body.polylineEncoding, "GEO_JSON_LINESTRING"); assert.equal(body.travelMode, "WALK");
|
|
180
|
+
state.response = {}; assert.deepEqual(await invoke("routes.compute", routeInput), {});
|
|
181
|
+
const before = requests.length;
|
|
182
|
+
await assert.rejects(invoke("routes.compute", { ...routeInput, destinationLatitude: 91 }));
|
|
183
|
+
await assert.rejects(invoke("places.get", { placeId: "../keys" }));
|
|
184
|
+
await assert.rejects(invoke("places.search", { textQuery: "Perth", pageSize: 21 }));
|
|
185
|
+
assert.equal(requests.length, before);
|
|
186
|
+
state.status = 403;
|
|
187
|
+
await assert.rejects(invoke("places.search", { textQuery: "Perth" }), { code: "connector_permission_denied" });
|
|
188
|
+
state.status = 429;
|
|
189
|
+
await assert.rejects(invoke("routes.compute", routeInput), { code: "connector_rate_limited" });
|
|
190
|
+
assert.equal(requests.length, before + 2);
|
|
191
|
+
});
|