@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.
Files changed (399) hide show
  1. package/README.md +192 -0
  2. package/docs/ai-upstream-notices.md +51 -0
  3. package/docs/ai.md +191 -0
  4. package/docs/airtable.md +130 -0
  5. package/docs/algolia.md +190 -0
  6. package/docs/amazon-redshift.md +229 -0
  7. package/docs/amplitude.md +210 -0
  8. package/docs/apify.md +162 -0
  9. package/docs/apollo-io.md +200 -0
  10. package/docs/asana.md +126 -0
  11. package/docs/ashby.md +188 -0
  12. package/docs/atlassian.md +224 -0
  13. package/docs/attention.md +162 -0
  14. package/docs/aws-athena.md +150 -0
  15. package/docs/aws-credentials.md +75 -0
  16. package/docs/aws-s3.md +193 -0
  17. package/docs/bigquery.md +146 -0
  18. package/docs/brevo.md +122 -0
  19. package/docs/calendly.md +158 -0
  20. package/docs/canva.md +191 -0
  21. package/docs/chargebee.md +192 -0
  22. package/docs/clay.md +194 -0
  23. package/docs/clickhouse.md +242 -0
  24. package/docs/confidence-exp.md +74 -0
  25. package/docs/confidence-flags.md +75 -0
  26. package/docs/confidence-oauth.md +137 -0
  27. package/docs/contentful.md +123 -0
  28. package/docs/databricks.md +180 -0
  29. package/docs/dbt-semantic-layer.md +175 -0
  30. package/docs/elevenlabs.md +102 -0
  31. package/docs/figma.md +132 -0
  32. package/docs/firebase-cloud-messaging.md +262 -0
  33. package/docs/firecrawl.md +137 -0
  34. package/docs/fireflies.md +95 -0
  35. package/docs/fireworks-ai.md +53 -0
  36. package/docs/gatewayapi.md +113 -0
  37. package/docs/gemini-enterprise.md +224 -0
  38. package/docs/github-api.md +133 -0
  39. package/docs/gitlab-api.md +138 -0
  40. package/docs/gmail.md +133 -0
  41. package/docs/gong.md +205 -0
  42. package/docs/google-ads.md +156 -0
  43. package/docs/google-analytics.md +202 -0
  44. package/docs/google-docs.md +105 -0
  45. package/docs/google-drive.md +108 -0
  46. package/docs/google-maps-platform.md +208 -0
  47. package/docs/google-oauth.md +123 -0
  48. package/docs/google-search-console.md +124 -0
  49. package/docs/google-sheets.md +94 -0
  50. package/docs/google-slides.md +95 -0
  51. package/docs/granola.md +178 -0
  52. package/docs/hex.md +161 -0
  53. package/docs/heygen.md +124 -0
  54. package/docs/hubspot.md +142 -0
  55. package/docs/incident-io.md +118 -0
  56. package/docs/inngest.md +178 -0
  57. package/docs/klipy.md +149 -0
  58. package/docs/lexware.md +144 -0
  59. package/docs/lightspeed.md +193 -0
  60. package/docs/linear.md +137 -0
  61. package/docs/linkedin.md +196 -0
  62. package/docs/logo-dev.md +152 -0
  63. package/docs/mailgun.md +153 -0
  64. package/docs/mapbox.md +174 -0
  65. package/docs/microsoft-excel.md +97 -0
  66. package/docs/microsoft-fabric.md +249 -0
  67. package/docs/microsoft-oauth.md +143 -0
  68. package/docs/microsoft-onedrive.md +76 -0
  69. package/docs/microsoft-onenote.md +75 -0
  70. package/docs/microsoft-outlook.md +90 -0
  71. package/docs/microsoft-powerpoint.md +119 -0
  72. package/docs/microsoft-sharepoint.md +75 -0
  73. package/docs/microsoft-teams.md +72 -0
  74. package/docs/microsoft-word.md +119 -0
  75. package/docs/miro.md +117 -0
  76. package/docs/n8n.md +169 -0
  77. package/docs/notion.md +137 -0
  78. package/docs/oura.md +161 -0
  79. package/docs/paddle.md +168 -0
  80. package/docs/perplexity.md +85 -0
  81. package/docs/pipedrive.md +120 -0
  82. package/docs/polar.md +63 -0
  83. package/docs/posthog.md +227 -0
  84. package/docs/prestashop.md +151 -0
  85. package/docs/replicate.md +60 -0
  86. package/docs/resend.md +153 -0
  87. package/docs/salesforce.md +234 -0
  88. package/docs/sanity.md +122 -0
  89. package/docs/semrush.md +276 -0
  90. package/docs/sentry.md +112 -0
  91. package/docs/sevdesk.md +107 -0
  92. package/docs/shopify.md +206 -0
  93. package/docs/slack.md +257 -0
  94. package/docs/snowflake.md +271 -0
  95. package/docs/storyblok.md +130 -0
  96. package/docs/stripe.md +77 -0
  97. package/docs/supabase.md +51 -0
  98. package/docs/tally.md +105 -0
  99. package/docs/telegram.md +165 -0
  100. package/docs/tiktok.md +201 -0
  101. package/docs/twilio.md +284 -0
  102. package/docs/twitch.md +428 -0
  103. package/docs/wave.md +440 -0
  104. package/docs/wix.md +1249 -0
  105. package/docs/wiz.md +177 -0
  106. package/docs/woocommerce.md +400 -0
  107. package/docs/wordpress-com.md +358 -0
  108. package/docs/wordpress-self-hosted.md +252 -0
  109. package/docs/workday.md +292 -0
  110. package/docs/x-twitter.md +188 -0
  111. package/docs/xero.md +429 -0
  112. package/docs/zoho-books.md +214 -0
  113. package/docs/zoho-crm.md +192 -0
  114. package/package.json +153 -0
  115. package/patterns/ai-connections/PATTERN.md +84 -0
  116. package/patterns/ai-connections/example/ai-model.js +11 -0
  117. package/patterns/ai-connections/example/perplexity-answer.js +29 -0
  118. package/patterns/api-key-connection/PATTERN.md +368 -0
  119. package/patterns/api-key-connection/example/connections.js +18 -0
  120. package/patterns/api-key-connection/example/integrations.json +19 -0
  121. package/patterns/assistant-mcp/PATTERN.md +110 -0
  122. package/patterns/assistant-mcp/example/integrations.json +13 -0
  123. package/patterns/assistant-mcp-oauth/PATTERN.md +235 -0
  124. package/patterns/assistant-mcp-oauth/example/integrations.json +102 -0
  125. package/patterns/aws-storage-queries/PATTERN.md +169 -0
  126. package/patterns/aws-storage-queries/example/formats/data-formats.js +35 -0
  127. package/patterns/aws-storage-queries/example/formats/package-lock.json +49 -0
  128. package/patterns/aws-storage-queries/example/formats/package.json +12 -0
  129. package/patterns/aws-storage-queries/example/formats/verify-formats.mjs +37 -0
  130. package/patterns/aws-storage-queries/example/integrations.json +38 -0
  131. package/patterns/aws-storage-queries/example/s3-transfer.js +34 -0
  132. package/patterns/event-delivery/PATTERN.md +151 -0
  133. package/patterns/event-delivery/example/integrations.json +17 -0
  134. package/patterns/firebase-messaging/PATTERN.md +156 -0
  135. package/patterns/firebase-messaging/example/server/notifications.js +23 -0
  136. package/patterns/google-ads-search/PATTERN.md +92 -0
  137. package/patterns/google-ads-search/example/ads-setup.js +25 -0
  138. package/patterns/oauth-connection/PATTERN.md +724 -0
  139. package/patterns/oauth-connection/example/integrations.json +20 -0
  140. package/patterns/paddle-catalogue/PATTERN.md +78 -0
  141. package/patterns/paddle-catalogue/example/create-products.js +38 -0
  142. package/patterns/public-image/PATTERN.md +84 -0
  143. package/patterns/public-image/example/integrations.json +13 -0
  144. package/patterns/public-image/example/logo-url.js +16 -0
  145. package/patterns/redshift-queries/PATTERN.md +150 -0
  146. package/patterns/redshift-queries/example/integrations.json +33 -0
  147. package/patterns/source-scanning/PATTERN.md +83 -0
  148. package/patterns/source-scanning/example/source-scanner.js +13 -0
  149. package/src/client/firebase-cloud-messaging.js +10 -0
  150. package/src/client/google-analytics.js +72 -0
  151. package/src/client/google-maps-platform.js +39 -0
  152. package/src/client/logo-dev.js +36 -0
  153. package/src/server/ai.js +46 -0
  154. package/src/server/airtable.js +67 -0
  155. package/src/server/algolia.js +63 -0
  156. package/src/server/amazon-redshift.js +96 -0
  157. package/src/server/amplitude.js +31 -0
  158. package/src/server/apify.js +68 -0
  159. package/src/server/apollo-io.js +85 -0
  160. package/src/server/asana.js +64 -0
  161. package/src/server/ashby.js +61 -0
  162. package/src/server/atlassian.js +32 -0
  163. package/src/server/attention.js +93 -0
  164. package/src/server/aws-athena.js +82 -0
  165. package/src/server/aws-s3.js +49 -0
  166. package/src/server/awsTransport.js +75 -0
  167. package/src/server/bigquery.js +97 -0
  168. package/src/server/brevo.js +97 -0
  169. package/src/server/calendly.js +77 -0
  170. package/src/server/canva.js +13 -0
  171. package/src/server/chargebee.js +88 -0
  172. package/src/server/clay.js +78 -0
  173. package/src/server/clickhouse.js +66 -0
  174. package/src/server/confidence.js +29 -0
  175. package/src/server/contentful.js +40 -0
  176. package/src/server/databricks.js +55 -0
  177. package/src/server/dbt-semantic-layer.js +95 -0
  178. package/src/server/elevenlabs.js +81 -0
  179. package/src/server/figma.js +24 -0
  180. package/src/server/firebase-cloud-messaging.js +111 -0
  181. package/src/server/firecrawl.js +70 -0
  182. package/src/server/fireflies.js +31 -0
  183. package/src/server/fireworks-ai.js +16 -0
  184. package/src/server/gatewayapi.js +48 -0
  185. package/src/server/gemini-enterprise.js +47 -0
  186. package/src/server/github-api.js +90 -0
  187. package/src/server/gitlab-api.js +67 -0
  188. package/src/server/gmail.js +60 -0
  189. package/src/server/gong.js +52 -0
  190. package/src/server/google-ads-search.js +134 -0
  191. package/src/server/google-ads.js +80 -0
  192. package/src/server/google-docs.js +36 -0
  193. package/src/server/google-drive.js +71 -0
  194. package/src/server/google-maps-platform.js +90 -0
  195. package/src/server/google-search-console.js +31 -0
  196. package/src/server/google-sheets.js +60 -0
  197. package/src/server/google-slides.js +36 -0
  198. package/src/server/google.js +61 -0
  199. package/src/server/granola.js +115 -0
  200. package/src/server/graphqlOperation.js +15 -0
  201. package/src/server/hex.js +29 -0
  202. package/src/server/heygen.js +54 -0
  203. package/src/server/hubspot.js +85 -0
  204. package/src/server/incident-io.js +68 -0
  205. package/src/server/inngest.js +86 -0
  206. package/src/server/jsonOperation.js +34 -0
  207. package/src/server/klipy.js +34 -0
  208. package/src/server/lexware.js +59 -0
  209. package/src/server/lightspeed.js +82 -0
  210. package/src/server/linear.js +52 -0
  211. package/src/server/linkedin.js +74 -0
  212. package/src/server/mailgun.js +76 -0
  213. package/src/server/mapbox.js +46 -0
  214. package/src/server/mcpRegistration.js +41 -0
  215. package/src/server/mcpTransport.js +60 -0
  216. package/src/server/microsoft-excel.js +67 -0
  217. package/src/server/microsoft-fabric.js +81 -0
  218. package/src/server/microsoft-onedrive.js +58 -0
  219. package/src/server/microsoft-onenote.js +52 -0
  220. package/src/server/microsoft-outlook.js +58 -0
  221. package/src/server/microsoft-powerpoint.js +5 -0
  222. package/src/server/microsoft-sharepoint.js +46 -0
  223. package/src/server/microsoft-teams.js +29 -0
  224. package/src/server/microsoft-word.js +5 -0
  225. package/src/server/microsoft.js +88 -0
  226. package/src/server/miro.js +29 -0
  227. package/src/server/n8n.js +78 -0
  228. package/src/server/notion.js +76 -0
  229. package/src/server/oura.js +48 -0
  230. package/src/server/paddle.js +83 -0
  231. package/src/server/perplexity.js +15 -0
  232. package/src/server/pipedrive.js +95 -0
  233. package/src/server/polar.js +20 -0
  234. package/src/server/posthog.js +44 -0
  235. package/src/server/prestashop.js +42 -0
  236. package/src/server/replicate.js +42 -0
  237. package/src/server/resend.js +93 -0
  238. package/src/server/salesforce.js +84 -0
  239. package/src/server/sanity.js +31 -0
  240. package/src/server/semrush.js +359 -0
  241. package/src/server/sentry.js +30 -0
  242. package/src/server/sevdesk.js +20 -0
  243. package/src/server/shopify.js +99 -0
  244. package/src/server/slack.js +143 -0
  245. package/src/server/snowflake.js +139 -0
  246. package/src/server/storyblok.js +49 -0
  247. package/src/server/stripe.js +13 -0
  248. package/src/server/supabase.js +13 -0
  249. package/src/server/tally.js +78 -0
  250. package/src/server/telegram.js +55 -0
  251. package/src/server/tiktok.js +86 -0
  252. package/src/server/twilio.js +90 -0
  253. package/src/server/twitch.js +409 -0
  254. package/src/server/wave.js +520 -0
  255. package/src/server/wix.js +1048 -0
  256. package/src/server/wiz.js +117 -0
  257. package/src/server/woocommerce.js +412 -0
  258. package/src/server/wordpress-com.js +236 -0
  259. package/src/server/wordpress-self-hosted.js +134 -0
  260. package/src/server/workday.js +136 -0
  261. package/src/server/x-twitter.js +110 -0
  262. package/src/server/xero.js +397 -0
  263. package/src/server/zoho-books.js +90 -0
  264. package/src/server/zoho-crm.js +89 -0
  265. package/src/server/zohoOAuth.js +21 -0
  266. package/src/shared/ai-models.json +8049 -0
  267. package/src/shared/ai.js +63 -0
  268. package/src/shared/algolia.js +29 -0
  269. package/src/shared/amazon-redshift.js +51 -0
  270. package/src/shared/amplitude.js +28 -0
  271. package/src/shared/atlassian.js +43 -0
  272. package/src/shared/aws.js +72 -0
  273. package/src/shared/canva.js +66 -0
  274. package/src/shared/chargebee.js +26 -0
  275. package/src/shared/clickhouse.js +38 -0
  276. package/src/shared/confidence.js +28 -0
  277. package/src/shared/databricks.js +33 -0
  278. package/src/shared/dbt-semantic-layer.js +32 -0
  279. package/src/shared/definitions.js +140 -0
  280. package/src/shared/figma.js +21 -0
  281. package/src/shared/firebase-cloud-messaging.js +45 -0
  282. package/src/shared/gemini-enterprise.js +33 -0
  283. package/src/shared/gong.js +33 -0
  284. package/src/shared/google-ads-search.js +31 -0
  285. package/src/shared/google-ads.js +28 -0
  286. package/src/shared/google-maps-platform.js +28 -0
  287. package/src/shared/google.js +133 -0
  288. package/src/shared/granola.js +25 -0
  289. package/src/shared/hex.js +28 -0
  290. package/src/shared/inngest.js +35 -0
  291. package/src/shared/lightspeed.js +75 -0
  292. package/src/shared/linkedin.js +27 -0
  293. package/src/shared/logo-dev.js +21 -0
  294. package/src/shared/mapbox.js +38 -0
  295. package/src/shared/mcp.js +92 -0
  296. package/src/shared/microsoft-fabric.js +47 -0
  297. package/src/shared/microsoft.js +231 -0
  298. package/src/shared/miro.js +27 -0
  299. package/src/shared/oura.js +23 -0
  300. package/src/shared/posthog.js +33 -0
  301. package/src/shared/prestashop.js +23 -0
  302. package/src/shared/salesforce.js +36 -0
  303. package/src/shared/semrush.js +24 -0
  304. package/src/shared/shopify.js +51 -0
  305. package/src/shared/siteUrl.js +12 -0
  306. package/src/shared/slack.js +88 -0
  307. package/src/shared/snowflake.js +55 -0
  308. package/src/shared/tiktok.js +22 -0
  309. package/src/shared/tokens.js +747 -0
  310. package/src/shared/twilio.js +41 -0
  311. package/src/shared/twitch.js +49 -0
  312. package/src/shared/wave.js +52 -0
  313. package/src/shared/wix.js +40 -0
  314. package/src/shared/wiz.js +41 -0
  315. package/src/shared/wordpress-com.js +30 -0
  316. package/src/shared/wordpress.js +69 -0
  317. package/src/shared/workday.js +50 -0
  318. package/src/shared/x-twitter.js +19 -0
  319. package/src/shared/xero.js +46 -0
  320. package/src/shared/zoho-books.js +39 -0
  321. package/src/shared/zoho-crm.js +43 -0
  322. package/src/shared/zoho-regions.js +12 -0
  323. package/test/ai.test.js +136 -0
  324. package/test/airtable.test.js +125 -0
  325. package/test/algolia.test.js +207 -0
  326. package/test/amazon-redshift.test.js +272 -0
  327. package/test/amplitude.test.js +239 -0
  328. package/test/apify.test.js +93 -0
  329. package/test/apollo-io.test.js +125 -0
  330. package/test/asana.test.js +99 -0
  331. package/test/ashby.test.js +97 -0
  332. package/test/atlassian.test.js +249 -0
  333. package/test/attention.test.js +79 -0
  334. package/test/aws.test.js +405 -0
  335. package/test/brevo.test.js +71 -0
  336. package/test/calendly.test.js +136 -0
  337. package/test/canva.test.js +258 -0
  338. package/test/chargebee.test.js +195 -0
  339. package/test/clickhouse.test.js +278 -0
  340. package/test/confidence.test.js +205 -0
  341. package/test/databricks.test.js +253 -0
  342. package/test/dbt-semantic-layer.test.js +221 -0
  343. package/test/designMcp.test.js +336 -0
  344. package/test/elevenlabs.test.js +52 -0
  345. package/test/firebase-cloud-messaging.test.js +242 -0
  346. package/test/firecrawl.test.js +66 -0
  347. package/test/gemini-enterprise.test.js +218 -0
  348. package/test/github-api.test.js +167 -0
  349. package/test/gitlab-api.test.js +160 -0
  350. package/test/gong.test.js +192 -0
  351. package/test/google-ads.test.js +350 -0
  352. package/test/google-maps-platform.test.js +191 -0
  353. package/test/google.test.js +710 -0
  354. package/test/granola.test.js +240 -0
  355. package/test/hex.test.js +253 -0
  356. package/test/heygen.test.js +121 -0
  357. package/test/hubspot.test.js +103 -0
  358. package/test/inngest.test.js +219 -0
  359. package/test/lightspeed.test.js +250 -0
  360. package/test/linear.test.js +146 -0
  361. package/test/linkedin.test.js +243 -0
  362. package/test/logo-dev.test.js +79 -0
  363. package/test/mailgun.test.js +107 -0
  364. package/test/mapbox.test.js +181 -0
  365. package/test/mcp.test.js +227 -0
  366. package/test/microsoft-fabric.test.js +258 -0
  367. package/test/microsoft.test.js +554 -0
  368. package/test/n8nOAuth.test.js +188 -0
  369. package/test/notionOAuth.test.js +183 -0
  370. package/test/oura.test.js +211 -0
  371. package/test/paddleCatalogue.test.js +51 -0
  372. package/test/paddlePermissions.test.js +101 -0
  373. package/test/perplexityComposition.test.js +39 -0
  374. package/test/pipedriveOAuth.test.js +140 -0
  375. package/test/posthog.test.js +200 -0
  376. package/test/prestashop.test.js +207 -0
  377. package/test/resend.test.js +163 -0
  378. package/test/salesforce.test.js +276 -0
  379. package/test/semrush.test.js +479 -0
  380. package/test/sentry.test.js +35 -0
  381. package/test/shopify.test.js +247 -0
  382. package/test/slack.test.js +320 -0
  383. package/test/snowflake.test.js +391 -0
  384. package/test/tally.test.js +75 -0
  385. package/test/telegram.test.js +58 -0
  386. package/test/tiktok.test.js +243 -0
  387. package/test/tokens.test.js +1079 -0
  388. package/test/twilio.test.js +221 -0
  389. package/test/twitch.test.js +573 -0
  390. package/test/wave.test.js +678 -0
  391. package/test/wix.test.js +1099 -0
  392. package/test/wiz.test.js +185 -0
  393. package/test/wordpress-com.test.js +469 -0
  394. package/test/wordpress.test.js +660 -0
  395. package/test/workday.test.js +314 -0
  396. package/test/x-twitter.test.js +256 -0
  397. package/test/xero.test.js +700 -0
  398. package/test/zoho-books.test.js +253 -0
  399. package/test/zoho-crm.test.js +223 -0
@@ -0,0 +1,253 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { createConnectionService } from "../../connectors-core/src/server/index.js";
7
+ import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
8
+ import { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
9
+ import { zohoBooksProvider } from "../src/server/zoho-books.js";
10
+
11
+ const context = { applicationId: "application-one", subjectId: "user-one" };
12
+ const args = { context, integrationId: "books" };
13
+ const callback = "https://app.example.test/oauth/zoho-books/callback";
14
+ const scopes = ["ZohoBooks.settings.READ", "ZohoBooks.contacts.READ", "ZohoBooks.invoices.READ"];
15
+ const org = "10234695";
16
+ const organizations = { code: 0, message: "success", organizations: [
17
+ { organization_id: "10234694", name: "Other organisation", is_default_org: true, is_org_active: true },
18
+ { organization_id: org, name: "Chosen organisation", is_default_org: false, is_org_active: true }
19
+ ] };
20
+ const regions = {
21
+ us: ["https://accounts.zoho.com", "com"], eu: ["https://accounts.zoho.eu", "eu"],
22
+ in: ["https://accounts.zoho.in", "in"], au: ["https://accounts.zoho.com.au", "com.au"],
23
+ jp: ["https://accounts.zoho.jp", "jp"], ca: ["https://accounts.zohocloud.ca", "ca"],
24
+ cn: ["https://accounts.zoho.com.cn", "com.cn"], sa: ["https://accounts.zoho.sa", "sa"]
25
+ };
26
+
27
+ async function fixture(t, settings = { region: "eu" }, selectedScopes = scopes) {
28
+ const directory = await mkdtemp(path.join(tmpdir(), "zoho-books-connector-"));
29
+ t.after(() => rm(directory, { recursive: true, force: true }));
30
+ const [accounts, suffix] = regions[settings.region || "eu"];
31
+ const api = `https://www.zohoapis.${suffix}`;
32
+ const state = { time: Date.now(), count: 0, status: 200, value: undefined, tokenValue: undefined, hang: false };
33
+ const requests = [];
34
+ const configuration = { schemaVersion: 1, registrations: { zoho: { source: "own", clientId: "fixture-books-client", clientSecretRef: "env:BOOKS_SECRET", callbackUrlRef: "env:BOOKS_CALLBACK" } },
35
+ integrations: { books: { provider: "zoho-books", accountMode: "per-user", settings, scopes: selectedScopes, authentication: { method: "oauth2", registrationRef: "zoho" } } } };
36
+ const options = {
37
+ configuration, providers: [{ ...zohoBooksProvider, requestTimeoutMs: 50 }], authorize: async (owner) => owner,
38
+ now: () => state.time, resolveReference: async (ref) => ({ "env:BOOKS_SECRET": "fixture-client-secret", "env:BOOKS_CALLBACK": callback })[ref],
39
+ store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(8) }, activeKeyId: "current" }) }),
40
+ fetchImpl: async (address, init) => {
41
+ const url = new URL(String(address)); requests.push({ url, init });
42
+ if (url.origin === accounts) {
43
+ assert.equal(url.pathname, "/oauth/v2/token"); assert.equal(url.search, ""); assert.equal(init.method, "POST");
44
+ assert.match(new Headers(init.headers).get("content-type"), /application\/x-www-form-urlencoded/u);
45
+ const body = new URLSearchParams(init.body);
46
+ assert.equal(body.get("client_id"), "fixture-books-client"); assert.equal(body.get("client_secret"), "fixture-client-secret");
47
+ if (body.get("grant_type") === "authorization_code") {
48
+ assert.equal(body.get("redirect_uri"), callback); assert.ok(body.get("code_verifier"));
49
+ } else {
50
+ assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), "fixture-refresh-token");
51
+ }
52
+ state.count += 1;
53
+ return Response.json(state.tokenValue ?? { access_token: `fixture-access-${state.count}`, ...(state.count === 1 ? { refresh_token: "fixture-refresh-token" } : {}),
54
+ token_type: "Bearer", api_domain: api, expires_in: 60 });
55
+ }
56
+ assert.equal(url.origin, api); assert.equal(init.method, "GET"); assert.equal(init.redirect, "error"); assert.equal(init.credentials, "omit");
57
+ assert.equal(new Headers(init.headers).get("authorization"), `Zoho-oauthtoken fixture-access-${state.count}`);
58
+ assert.equal(url.searchParams.has("access_token"), false);
59
+ if (state.hang) return new Promise((_, reject) => {
60
+ const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
61
+ const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
62
+ if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
63
+ });
64
+ if (state.status === 204) return new Response(null, { status: 204 });
65
+ if (typeof state.value === "string") return new Response(state.value, { status: state.status });
66
+ const resource = url.pathname.split("/").at(-1);
67
+ const value = resource === "organizations" ? organizations : { code: 0, message: "success",
68
+ [resource]: [{ [resource === "contacts" ? "contact_id" : "invoice_id"]: "460000000026049", notes: null }],
69
+ page_context: { page: Number(url.searchParams.get("page")), per_page: Number(url.searchParams.get("per_page")), has_more_page: true } };
70
+ if (resource === "invoices") value.page_context = [value.page_context];
71
+ return Response.json(state.value === undefined ? value : state.value, { status: state.status });
72
+ }
73
+ };
74
+ const service = createConnectionService(options);
75
+ const start = async () => {
76
+ const { authorizationUrl } = await service.beginAuthorization(args); const url = new URL(authorizationUrl);
77
+ const redirect = new URL(callback); redirect.searchParams.set("code", "fixture-code"); redirect.searchParams.set("state", url.searchParams.get("state"));
78
+ redirect.searchParams.set("accounts-server", "https://attacker.test");
79
+ return { url, callbackUrl: redirect.href };
80
+ };
81
+ const connect = async () => service.completeAuthorization({ ...args, callbackUrl: (await start()).callbackUrl });
82
+ return { directory, service, options, state, requests, start, connect, api, accounts };
83
+ }
84
+
85
+ test("Zoho Books uses regional PKCE consent and persists verified credentials in encrypted files", async (t) => {
86
+ const f = await fixture(t); const start = await f.start();
87
+ assert.equal(start.url.origin, "https://accounts.zoho.eu"); assert.equal(start.url.pathname, "/oauth/v2/auth");
88
+ assert.equal(start.url.searchParams.get("scope"), scopes.join(","));
89
+ assert.equal(start.url.searchParams.get("access_type"), "offline"); assert.equal(start.url.searchParams.get("prompt"), "consent");
90
+ assert.equal(start.url.searchParams.get("code_challenge_method"), "S256"); assert.equal(start.url.searchParams.has("client_secret"), false);
91
+ await f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl });
92
+ assert.equal(f.requests[1].url.pathname, "/books/v3/organizations"); assert.equal(f.requests[1].url.search, "");
93
+ for (const name of await readdir(f.directory)) {
94
+ const text = await readFile(path.join(f.directory, name), "utf8");
95
+ for (const secret of ["fixture-code", "fixture-client-secret", "fixture-access-1", "fixture-refresh-token"]) assert.equal(text.includes(secret), false);
96
+ }
97
+ const restarted = createConnectionService(f.options);
98
+ assert.equal((await restarted.status(args)).status, "connected");
99
+ assert.deepEqual(await restarted.invoke({ ...args, operation: "organizations.list" }), organizations);
100
+ });
101
+
102
+ test("Zoho Books routes all eight data centers to fixed accounts and API hosts", async (t) => {
103
+ for (const region of Object.keys(regions)) {
104
+ const f = await fixture(t, { region }); const start = await f.start(); assert.equal(start.url.origin, f.accounts);
105
+ await f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl });
106
+ await f.service.invoke({ ...args, operation: "invoices.list", input: { organization_id: org } });
107
+ assert.equal(f.requests.at(-1).url.origin, f.api);
108
+ }
109
+ });
110
+
111
+ test("Zoho Books requires an explicit organisation and never selects the first or default discovery result", async (t) => {
112
+ const f = await fixture(t); await f.connect();
113
+ await assert.rejects(f.service.invoke({ ...args, operation: "contacts.list" }), { code: "connector_input_invalid" });
114
+ assert.equal(f.requests.length, 2);
115
+ for (const operation of ["contacts.list", "invoices.list"]) {
116
+ const result = await f.service.invoke({ ...args, operation, input: { organization_id: org, page: 2, per_page: 3 } });
117
+ assert.equal(f.requests.at(-1).url.searchParams.get("organization_id"), org);
118
+ assert.deepEqual(result.page_context, { page: 2, per_page: 3, has_more_page: true });
119
+ assert.equal(result[operation.split(".")[0]][0].notes, null);
120
+ }
121
+ assert.equal(f.requests.length, 4);
122
+ f.state.value = { code: 0, contacts: [], page_context: { page: 1, per_page: 100, has_more_page: false } };
123
+ assert.deepEqual((await f.service.invoke({ ...args, operation: "contacts.list", input: { organization_id: org } })).contacts, []);
124
+ f.state.value = { code: 0, invoices: [], page_context: { page: 1, per_page: 100, has_more_page: false } };
125
+ assert.deepEqual((await f.service.invoke({ ...args, operation: "invoices.list", input: { organization_id: org } })).page_context, f.state.value.page_context);
126
+ });
127
+
128
+ test("Zoho Books validates configured organisation access, prevents overrides and allows empty unconfigured discovery", async (t) => {
129
+ const f = await fixture(t, { region: "sa", organizationId: org }); await f.connect();
130
+ await f.service.invoke({ ...args, operation: "contacts.list" });
131
+ assert.equal(f.requests.at(-1).url.searchParams.get("organization_id"), org);
132
+ await f.service.invoke({ ...args, operation: "contacts.list", input: { organization_id: org } });
133
+ const before = f.requests.length;
134
+ await assert.rejects(f.service.invoke({ ...args, operation: "contacts.list", input: { organization_id: "10234694" } }), { code: "connector_input_invalid" });
135
+ assert.equal(f.requests.length, before);
136
+ for (const entries of [[], [{ organization_id: org, name: "Inactive", is_org_active: false }], organizations.organizations.slice(0, 1)]) {
137
+ const missing = await fixture(t, { region: "eu", organizationId: org }); missing.state.value = { code: 0, organizations: entries };
138
+ await assert.rejects(missing.connect(), { code: "connector_permission_denied" });
139
+ assert.equal((await missing.service.status(args)).status, "disconnected");
140
+ }
141
+ const empty = await fixture(t); empty.state.value = { code: 0, organizations: [] }; await empty.connect();
142
+ assert.deepEqual((await empty.service.invoke({ ...args, operation: "organizations.list" })).organizations, []);
143
+ });
144
+
145
+ test("Zoho Books rejects invalid IDs, oversized pages and arbitrary requests before transport", async (t) => {
146
+ const f = await fixture(t); await f.connect(); const before = f.requests.length;
147
+ for (const input of [{ organization_id: "../secret" }, { organization_id: "001" }, { organization_id: "1&other=2" }, { organization_id: "1".repeat(31) },
148
+ { organization_id: org, page: 0 }, { organization_id: org, page: 1.2 }, { organization_id: org, page: 1_000_001 },
149
+ { organization_id: org, per_page: 0 }, { organization_id: org, per_page: 201 }, { organization_id: org, url: "https://invalid.test" }]) {
150
+ await assert.rejects(f.service.invoke({ ...args, operation: "invoices.list", input }), { code: "connector_input_invalid" });
151
+ }
152
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list", input: { organization_id: org } }), { code: "connector_input_invalid" });
153
+ await assert.rejects(f.service.invoke({ ...args, operation: "invoices.create" }), { code: "connector_operation_unknown" });
154
+ assert.equal(f.requests.length, before);
155
+ });
156
+
157
+ test("Zoho Books enforces read or full scopes and isolates application and user owners", async (t) => {
158
+ const f = await fixture(t, { region: "eu" }, ["ZohoBooks.settings.READ"]); await f.connect();
159
+ await assert.rejects(f.service.invoke({ ...args, operation: "contacts.list", input: { organization_id: org } }), { code: "connector_scope_missing" });
160
+ for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
161
+ await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "organizations.list" }), { code: "connector_reconnect_required" });
162
+ }
163
+ const denied = createConnectionService({ ...f.options, authorize: async () => null });
164
+ await assert.rejects(denied.invoke({ ...args, operation: "organizations.list" }), { code: "connector_access_denied" });
165
+ assert.equal(f.requests.length, 2);
166
+ const full = await fixture(t, { region: "eu", organizationId: org }, ["ZohoBooks.settings.READ", "ZohoBooks.contacts.ALL", "ZohoBooks.invoices.ALL"]);
167
+ await full.connect(); await full.service.invoke({ ...args, operation: "contacts.list" }); await full.service.invoke({ ...args, operation: "invoices.list" });
168
+ });
169
+
170
+ test("Zoho Books refresh retains tokens and scopes across restart and persists before a failed API request", async (t) => {
171
+ const f = await fixture(t); await f.connect(); f.state.time += 40_000;
172
+ await Promise.all([f.service.invoke({ ...args, operation: "organizations.list" }), f.service.invoke({ ...args, operation: "organizations.list" })]);
173
+ assert.equal(f.state.count, 2); f.state.time += 40_000; f.state.status = 429;
174
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_rate_limited" });
175
+ f.state.status = 200;
176
+ const restarted = createConnectionService(f.options); await restarted.invoke({ ...args, operation: "organizations.list" });
177
+ assert.equal(f.state.count, 3); assert.deepEqual((await restarted.status(args)).grantedScopes, scopes);
178
+ f.state.time += 40_000; f.state.tokenValue = { error: "invalid_code", error_description: "sensitive provider text" };
179
+ await assert.rejects(restarted.invoke({ ...args, operation: "organizations.list" }), { code: "connector_reconnect_required" });
180
+ });
181
+
182
+ test("Zoho Books rejects token host substitution and binds saved connections to region and organisation settings", async (t) => {
183
+ for (const api_domain of [undefined, "https://www.zohoapis.com", "https://www.zohoapis.eu.attacker.test", "https://www.zohoapis.eu/path"]) {
184
+ const f = await fixture(t); f.state.tokenValue = { access_token: "unused", token_type: "Bearer", expires_in: 3600, api_domain };
185
+ await assert.rejects(f.connect(), { code: "connector_response_invalid" }); assert.equal(f.requests.length, 1);
186
+ assert.equal(f.requests[0].url.origin, "https://accounts.zoho.eu");
187
+ }
188
+ const f = await fixture(t); await f.connect();
189
+ for (const change of [{ region: "sa" }, { organizationId: org }]) {
190
+ const configuration = structuredClone(f.options.configuration); Object.assign(configuration.integrations.books.settings, change);
191
+ await assert.rejects(createConnectionService({ ...f.options, configuration }).invoke({ ...args, operation: "organizations.list" }), { code: "connector_reconnect_required" });
192
+ }
193
+ });
194
+
195
+ test("Zoho Books denial, cancellation and callback replay preserve a previous grant; disconnect stays local", async (t) => {
196
+ const f = await fixture(t); await f.connect();
197
+ const start = await f.start(); const denied = new URL(start.callbackUrl); denied.searchParams.delete("code"); denied.searchParams.set("error", "access_denied");
198
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: denied.href }), { code: "connector_consent_denied" });
199
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl }), { code: "connector_attempt_invalid" });
200
+ const pending = await f.start(); await f.service.cancelAuthorization({ ...args, state: pending.url.searchParams.get("state") });
201
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: pending.callbackUrl }), { code: "connector_attempt_invalid" });
202
+ assert.equal((await f.service.status(args)).status, "connected");
203
+ await f.service.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected"); assert.equal(f.requests.length, 2);
204
+ });
205
+
206
+ test("Zoho Books rejects error envelopes and mismatched pages and redacts provider failures", async (t) => {
207
+ const f = await fixture(t); await f.connect();
208
+ for (const [status, code] of [[403, "connector_permission_denied"], [404, "connector_resource_not_found"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
209
+ f.state.status = status; f.state.value = { code: 45, message: "sensitive provider text" };
210
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), (error) => error.code === code && !error.message.includes("sensitive"));
211
+ }
212
+ f.state.status = 200;
213
+ for (const value of [null, "invalid JSON", { code: 1000, message: "sensitive provider text" }, { code: 0 },
214
+ { code: 0, contacts: [{ contact_id: 123 }], page_context: { page: 1, per_page: 100, has_more_page: false } },
215
+ { code: 0, contacts: [], page_context: { page: 2, per_page: 100, has_more_page: false } },
216
+ { code: 0, contacts: [], page_context: { page: 1, per_page: 101, has_more_page: false } },
217
+ { code: 0, contacts: [], page_context: { page: 1, per_page: 100 } }]) {
218
+ f.state.value = value;
219
+ await assert.rejects(f.service.invoke({ ...args, operation: "contacts.list", input: { organization_id: org } }), { code: "connector_response_invalid" });
220
+ }
221
+ f.state.value = { code: 0, organizations: [{ organization_id: 1, name: "Invalid", is_org_active: true }] };
222
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_response_invalid" });
223
+ for (const page_context of [[], [{ page: 1, per_page: 100, has_more_page: false }, { page: 2, per_page: 100, has_more_page: false }], [null]]) {
224
+ f.state.value = { code: 0, invoices: [], page_context };
225
+ await assert.rejects(f.service.invoke({ ...args, operation: "invoices.list", input: { organization_id: org } }), { code: "connector_response_invalid" });
226
+ }
227
+ f.state.status = 204; await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_response_invalid" });
228
+ f.state.status = 401; await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_reconnect_required" });
229
+ });
230
+
231
+ test("Zoho Books delivers cancellation and timeout without retrying", async (t) => {
232
+ const f = await fixture(t); await f.connect(); f.state.hang = true;
233
+ const abort = new AbortController(); const result = f.service.invoke({ ...args, operation: "organizations.list", signal: abort.signal });
234
+ setTimeout(() => abort.abort(), 10);
235
+ await assert.rejects(result, { code: "connector_cancelled" });
236
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_provider_timeout" });
237
+ assert.equal(f.requests.length, 4);
238
+ });
239
+
240
+ test("Zoho Books guide parses through shared validation and rejects unknown regions and raw credentials", async () => {
241
+ const guide = await readFile(new URL("../docs/zoho-books.md", import.meta.url), "utf8");
242
+ const json = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
243
+ const configuration = parseIntegrationConfiguration(json, { providers: [zohoBooksProvider] });
244
+ assert.equal(configuration.integrations.books.settings.organizationId, org);
245
+ const optional = JSON.parse(json); delete optional.integrations.books.settings.organizationId;
246
+ assert.equal(parseIntegrationConfiguration(JSON.stringify(optional), { providers: [zohoBooksProvider] }).integrations.books.settings.organizationId, undefined);
247
+ for (const change of [{ region: "anything" }, { organizationId: "not-digits" }, { organizationId: " " }, { environment: "sandbox" }]) {
248
+ const invalid = JSON.parse(json); Object.assign(invalid.integrations.books.settings, change);
249
+ assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [zohoBooksProvider] }));
250
+ }
251
+ const invalid = JSON.parse(json); invalid.registrations.zoho.clientSecretRef = "raw-secret";
252
+ assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [zohoBooksProvider] }));
253
+ });
@@ -0,0 +1,223 @@
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 { zohoCrmProvider } from "../src/server/zoho-crm.js";
10
+
11
+ const context = { applicationId: "application-one", subjectId: "user-one" };
12
+ const args = { context, integrationId: "crm" };
13
+ const scopes = ["ZohoCRM.users.READ", "ZohoCRM.modules.leads.READ", "ZohoCRM.modules.contacts.READ", "ZohoCRM.modules.accounts.READ", "ZohoCRM.modules.deals.READ"];
14
+ const callback = "https://app.example.test/oauth/zoho-crm/callback";
15
+ const profile = { users: [{ id: "5725767000000411001", full_name: "Fixture User" }] };
16
+ const regions = {
17
+ us: ["https://accounts.zoho.com", "com"], eu: ["https://accounts.zoho.eu", "eu"],
18
+ in: ["https://accounts.zoho.in", "in"], au: ["https://accounts.zoho.com.au", "com.au"],
19
+ jp: ["https://accounts.zoho.jp", "jp"], ca: ["https://accounts.zohocloud.ca", "ca"], cn: ["https://accounts.zoho.com.cn", "com.cn"]
20
+ };
21
+
22
+ async function fixture(t, settings = { region: "us", environment: "production" }, selectedScopes = scopes) {
23
+ const directory = await mkdtemp(path.join(tmpdir(), "zoho-crm-connector-"));
24
+ t.after(() => rm(directory, { recursive: true, force: true }));
25
+ const [accounts, suffix] = regions[settings.region];
26
+ const api = `https://${settings.environment === "production" ? "www" : settings.environment}.zohoapis.${suffix}`;
27
+ const state = { time: Date.now(), count: 0, status: 200, value: undefined, tokenStatus: 200, tokenValue: undefined, hang: false };
28
+ const requests = [];
29
+ const configuration = { schemaVersion: 1, registrations: { zoho: { source: "own", clientId: "fixture-zoho-client", clientSecretRef: "env:ZOHO_SECRET", callbackUrlRef: "env:ZOHO_CALLBACK" } },
30
+ integrations: { crm: { provider: "zoho-crm", accountMode: "per-user", settings, scopes: selectedScopes, authentication: { method: "oauth2", registrationRef: "zoho" } } } };
31
+ const options = {
32
+ configuration, providers: [{ ...zohoCrmProvider, requestTimeoutMs: 50 }], authorize: async (owner) => owner,
33
+ now: () => state.time, resolveReference: async (ref) => ({ "env:ZOHO_SECRET": "fixture-client-secret", "env:ZOHO_CALLBACK": callback })[ref],
34
+ store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(5) }, activeKeyId: "current" }) }),
35
+ fetchImpl: async (address, init) => {
36
+ const url = new URL(String(address));
37
+ requests.push({ url, init });
38
+ if (url.origin === accounts) {
39
+ assert.equal(url.pathname, "/oauth/v2/token"); assert.equal(init.method, "POST");
40
+ const body = new URLSearchParams(init.body);
41
+ assert.equal(body.get("client_id"), "fixture-zoho-client"); assert.equal(body.get("client_secret"), "fixture-client-secret");
42
+ if (body.get("grant_type") === "authorization_code") assert.equal(body.get("redirect_uri"), callback);
43
+ else { assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), "fixture-refresh-token"); }
44
+ state.count += 1;
45
+ return Response.json(state.tokenValue ?? { access_token: `fixture-access-${state.count}`, ...(state.count === 1 ? { refresh_token: "fixture-refresh-token" } : {}),
46
+ token_type: "Bearer", api_domain: api, expires_in: 60 }, { status: state.tokenStatus });
47
+ }
48
+ assert.equal(url.origin, api); assert.equal(init.method, "GET"); assert.equal(init.redirect, "error"); assert.equal(init.credentials, "omit");
49
+ assert.equal(new Headers(init.headers).get("authorization"), `Zoho-oauthtoken fixture-access-${state.count}`);
50
+ assert.equal(url.searchParams.has("access_token"), false);
51
+ if (state.hang) return new Promise((_, reject) => {
52
+ const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
53
+ const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
54
+ if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
55
+ });
56
+ if (state.status === 204) return new Response(null, { status: 204 });
57
+ if (typeof state.value === "string") return new Response(state.value, { status: state.status });
58
+ const value = url.pathname.endsWith("/users") ? profile : { data: [{ id: "5725767000000411002", Last_Name: "Fixture", Email: null }],
59
+ info: { count: 1, per_page: Number(url.searchParams.get("per_page")), ...(url.searchParams.has("page") ? { page: Number(url.searchParams.get("page")) } : {}), more_records: true, next_page_token: "fixture-next", previous_page_token: null } };
60
+ return Response.json(state.value === undefined ? value : state.value, { status: state.status });
61
+ }
62
+ };
63
+ const service = createConnectionService(options);
64
+ const start = async () => {
65
+ const { authorizationUrl } = await service.beginAuthorization(args); const url = new URL(authorizationUrl);
66
+ const redirect = new URL(callback); redirect.searchParams.set("code", "fixture-code"); redirect.searchParams.set("state", url.searchParams.get("state"));
67
+ redirect.searchParams.set("location", settings.region); redirect.searchParams.set("accounts-server", accounts);
68
+ return { url, callbackUrl: redirect.href };
69
+ };
70
+ const connect = async () => service.completeAuthorization({ ...args, callbackUrl: (await start()).callbackUrl });
71
+ return { directory, service, options, state, requests, start, connect, api, accounts };
72
+ }
73
+
74
+ test("Zoho CRM uses regional code consent, PKCE, offline access and encrypted restartable file storage", async (t) => {
75
+ const f = await fixture(t); const started = await f.start();
76
+ assert.equal(started.url.href.split("?")[0], "https://accounts.zoho.com/oauth/v2/auth");
77
+ assert.equal(started.url.searchParams.get("scope"), scopes.join(","));
78
+ assert.equal(started.url.searchParams.get("access_type"), "offline"); assert.equal(started.url.searchParams.get("prompt"), "consent");
79
+ assert.equal(started.url.searchParams.get("code_challenge_method"), "S256"); assert.equal(started.url.searchParams.has("client_secret"), false);
80
+ await f.service.completeAuthorization({ ...args, callbackUrl: started.callbackUrl });
81
+ assert.ok(new URLSearchParams(f.requests[0].init.body).get("code_verifier"));
82
+ assert.equal(f.requests[1].url.search, "?type=CurrentUser");
83
+ for (const file of await readdir(f.directory)) {
84
+ const text = await readFile(path.join(f.directory, file), "utf8");
85
+ for (const secret of ["fixture-code", "fixture-client-secret", "fixture-access-1", "fixture-refresh-token"]) assert.equal(text.includes(secret), false);
86
+ }
87
+ const restarted = createConnectionService(f.options);
88
+ assert.equal((await restarted.status(args)).status, "connected");
89
+ assert.deepEqual(await restarted.invoke({ ...args, operation: "users.current" }), profile);
90
+ });
91
+
92
+ test("Zoho CRM routes all seven data centers and three environments to fixed selected hosts", async (t) => {
93
+ for (const region of Object.keys(regions)) for (const environment of ["production", "sandbox", "developer"]) {
94
+ const f = await fixture(t, { region, environment }); const started = await f.start();
95
+ assert.equal(started.url.origin, f.accounts);
96
+ await f.service.completeAuthorization({ ...args, callbackUrl: started.callbackUrl });
97
+ await f.service.invoke({ ...args, operation: "accounts.list" });
98
+ assert.equal(f.requests.at(-1).url.origin, f.api);
99
+ }
100
+ });
101
+
102
+ test("Zoho CRM reads typed field selections and bounded pages without following cursors automatically", async (t) => {
103
+ const f = await fixture(t); await f.connect();
104
+ for (const operation of ["leads.list", "contacts.list", "accounts.list", "deals.list"]) {
105
+ const result = await f.service.invoke({ ...args, operation, input: { per_page: 2, page: 10, fields: "id,Custom_Field" } });
106
+ assert.equal(result.data[0].Email, null); assert.equal(result.info.next_page_token, "fixture-next");
107
+ assert.equal(f.requests.at(-1).url.searchParams.get("fields"), "id,Custom_Field");
108
+ }
109
+ await f.service.invoke({ ...args, operation: "leads.list", input: { page_token: "fixture-next", per_page: 2, fields: "id,Custom_Field" } });
110
+ assert.equal(f.requests.at(-1).url.searchParams.has("page"), false);
111
+ assert.equal(f.requests.length, 7);
112
+ f.state.status = 204;
113
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "leads.list" }), { data: [], info: { count: 0, per_page: 100, more_records: false } });
114
+ });
115
+
116
+ test("Zoho CRM rejects ambiguous paging, arbitrary URLs, oversized fields and unknown writes before transport", async (t) => {
117
+ const f = await fixture(t); await f.connect(); const before = f.requests.length;
118
+ for (const input of [{ page: 0 }, { page: 11 }, { page: 1.5 }, { per_page: 201 }, { per_page: 0 }, { page: 1, page_token: "next" },
119
+ { page_token: " " }, { page_token: "a".repeat(4097) }, { fields: "Email,Email" }, { fields: "../Secrets" },
120
+ { fields: Array.from({ length: 51 }, (_, i) => `Field_${i}`).join(",") }, { url: "https://invalid.test" }, { module: "Users" }]) {
121
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.list", input }), { code: "connector_input_invalid" });
122
+ }
123
+ await assert.rejects(f.service.invoke({ ...args, operation: "users.current", input: { type: "AllUsers" } }), { code: "connector_input_invalid" });
124
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.create" }), { code: "connector_operation_unknown" });
125
+ assert.equal(f.requests.length, before);
126
+ });
127
+
128
+ test("Zoho CRM honors reduced scopes and full-module grants without cross-user connection reuse", async (t) => {
129
+ const f = await fixture(t, { region: "us", environment: "production" }, ["ZohoCRM.users.READ"]); await f.connect();
130
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.list" }), { code: "connector_scope_missing" });
131
+ for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
132
+ await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "users.current" }), { code: "connector_reconnect_required" });
133
+ }
134
+ const denied = createConnectionService({ ...f.options, authorize: async () => null });
135
+ await assert.rejects(denied.invoke({ ...args, operation: "users.current" }), { code: "connector_access_denied" });
136
+ assert.equal(f.requests.length, 2);
137
+ const all = await fixture(t, { region: "us", environment: "production" }, ["ZohoCRM.users.ALL", "ZohoCRM.modules.ALL"]);
138
+ await all.connect(); await all.service.invoke({ ...args, operation: "deals.list" });
139
+ });
140
+
141
+ test("Zoho CRM refresh retains the original refresh token and scopes across restart and failed reads", async (t) => {
142
+ const f = await fixture(t); await f.connect(); f.state.time += 40_000;
143
+ await Promise.all([f.service.invoke({ ...args, operation: "users.current" }), f.service.invoke({ ...args, operation: "users.current" })]);
144
+ assert.equal(f.state.count, 2);
145
+ f.state.time += 40_000; f.state.status = 429;
146
+ await assert.rejects(f.service.invoke({ ...args, operation: "users.current" }), { code: "connector_rate_limited" });
147
+ f.state.status = 200;
148
+ const restarted = createConnectionService(f.options); await restarted.invoke({ ...args, operation: "users.current" });
149
+ assert.equal(f.state.count, 3); assert.deepEqual((await restarted.status(args)).grantedScopes, scopes);
150
+ f.state.time += 40_000;
151
+ await restarted.invoke({ ...args, operation: "leads.list" }); assert.equal(f.state.count, 4);
152
+ });
153
+
154
+ test("Zoho CRM rejects tokens for a wrong or missing API domain and never follows callback-supplied hosts", async (t) => {
155
+ for (const domain of [undefined, "https://www.zohoapis.eu", "https://sandbox.zohoapis.com", "https://www.zohoapis.com.attacker.test", "https://www.zohoapis.com/path"]) {
156
+ const f = await fixture(t); f.state.tokenValue = { access_token: "unused", token_type: "Bearer", expires_in: 3600, api_domain: domain };
157
+ const start = await f.start(); const redirect = new URL(start.callbackUrl); redirect.searchParams.set("accounts-server", "https://attacker.test");
158
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: redirect.href }), { code: "connector_response_invalid" });
159
+ assert.equal(f.requests.length, 1); assert.equal(f.requests[0].url.origin, f.accounts);
160
+ }
161
+ const f = await fixture(t); await f.connect(); f.state.time += 40_000;
162
+ f.state.tokenValue = { error: "invalid_code", error_description: "sensitive provider text" };
163
+ await assert.rejects(f.service.invoke({ ...args, operation: "users.current" }), { code: "connector_reconnect_required" });
164
+ assert.equal((await f.service.status(args)).status, "reconnect-required");
165
+ });
166
+
167
+ test("Zoho CRM settings changes require reconnect and consent denial, cancellation and replay preserve saved grants", async (t) => {
168
+ const f = await fixture(t); await f.connect();
169
+ for (const change of [{ region: "eu" }, { environment: "sandbox" }]) {
170
+ const configuration = structuredClone(f.options.configuration); Object.assign(configuration.integrations.crm.settings, change);
171
+ await assert.rejects(createConnectionService({ ...f.options, configuration }).invoke({ ...args, operation: "users.current" }), { code: "connector_reconnect_required" });
172
+ }
173
+ const start = await f.start(); const denied = new URL(start.callbackUrl); denied.searchParams.delete("code"); denied.searchParams.set("error", "access_denied");
174
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: denied.href }), { code: "connector_consent_denied" });
175
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl }), { code: "connector_attempt_invalid" });
176
+ const pending = await f.start(); await f.service.cancelAuthorization({ ...args, state: pending.url.searchParams.get("state") });
177
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: pending.callbackUrl }), { code: "connector_attempt_invalid" });
178
+ assert.equal((await f.service.status(args)).status, "connected");
179
+ await f.service.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected");
180
+ assert.equal(f.requests.length, 2);
181
+ });
182
+
183
+ test("Zoho CRM maps provider errors and rejects malformed successes without leaking provider text", async (t) => {
184
+ const f = await fixture(t); await f.connect();
185
+ for (const [status, value, code] of [[403, { message: "sensitive provider text" }, "connector_permission_denied"],
186
+ [404, {}, "connector_resource_not_found"], [500, {}, "connector_provider_failed"], [429, {}, "connector_rate_limited"],
187
+ [401, { code: "OAUTH_SCOPE_MISMATCH" }, "connector_scope_missing"]]) {
188
+ f.state.status = status; f.state.value = value;
189
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.list" }), (error) => error.code === code && !error.message.includes("sensitive"));
190
+ }
191
+ f.state.status = 200;
192
+ for (const value of [null, "invalid json", {}, { status: "error", code: "BAD_REQUEST" }, { data: [{ id: 123 }], info: { count: 1, per_page: 100, page: 1, more_records: false } },
193
+ { data: [], info: { count: 0, per_page: 100, page: 2, more_records: false } }, { data: [], info: { count: 1, per_page: 100, page: 1, more_records: false } }]) {
194
+ f.state.value = value;
195
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.list" }), { code: "connector_response_invalid" });
196
+ }
197
+ f.state.value = undefined; f.state.status = 204;
198
+ await assert.rejects(f.service.invoke({ ...args, operation: "users.current" }), { code: "connector_response_invalid" });
199
+ f.state.status = 401;
200
+ await assert.rejects(f.service.invoke({ ...args, operation: "users.current" }), { code: "connector_reconnect_required" });
201
+ });
202
+
203
+ test("Zoho CRM delivers cancellation and timeout to the provider without replay", async (t) => {
204
+ const f = await fixture(t); await f.connect(); f.state.hang = true;
205
+ const abort = new AbortController(); const result = f.service.invoke({ ...args, operation: "leads.list", signal: abort.signal });
206
+ setTimeout(() => abort.abort(), 10);
207
+ await assert.rejects(result, { code: "connector_cancelled" });
208
+ await assert.rejects(f.service.invoke({ ...args, operation: "leads.list" }), { code: "connector_provider_timeout" });
209
+ assert.equal(f.requests.length, 4);
210
+ });
211
+
212
+ test("Zoho CRM guide uses the shared configuration schema and rejects invalid regions or raw secrets", async () => {
213
+ const guide = await readFile(new URL("../docs/zoho-crm.md", import.meta.url), "utf8");
214
+ const json = guide.match(/```json\n([\s\S]*?)\n```/u)[1];
215
+ const configuration = parseIntegrationConfiguration(json, { providers: [zohoCrmProvider] });
216
+ assert.equal(configuration.integrations.crm.settings.region, "eu");
217
+ for (const [field, value] of [["region", "attacker.test"], ["environment", "anything"]]) {
218
+ const invalid = JSON.parse(json); invalid.integrations.crm.settings[field] = value;
219
+ assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [zohoCrmProvider] }));
220
+ }
221
+ const invalid = JSON.parse(json); invalid.registrations.zoho.clientSecretRef = "raw-secret";
222
+ assert.throws(() => parseIntegrationConfiguration(JSON.stringify(invalid), { providers: [zohoCrmProvider] }));
223
+ });