@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,314 @@
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 { workdayProvider } from "../src/server/workday.js";
10
+
11
+ const context = { applicationId: "app-one", subjectId: "user-one" };
12
+ const args = { context, integrationId: "workday" };
13
+ const callback = "https://app.example.test/oauth/workday/callback";
14
+ const me = { id: "0123456789abcdef0123456789abcdef", descriptor: "Fixture Worker", workerId: "21005", person: { descriptor: "Fixture Person" } };
15
+
16
+ async function fixture(t, { prefix = "/ccx", version = "v1", method = "client_secret_post" } = {}) {
17
+ const directory = await mkdtemp(path.join(tmpdir(), "workday-connector-"));
18
+ t.after(() => rm(directory, { recursive: true, force: true }));
19
+ const configuration = { schemaVersion: 1, registrations: { tenant: { source: "own", clientId: "fixture-client",
20
+ clientSecretRef: "env:WORKDAY_SECRET", callbackUrlRef: "env:WORKDAY_CALLBACK", tokenEndpointAuthMethod: method } }, integrations: {
21
+ workday: { provider: "workday", accountMode: "per-user", scopes: [], authentication: { method: "oauth2", registrationRef: "tenant" }, settings: {
22
+ restApiEndpoint: `https://wd5-services1.myworkday.com${prefix}/api/${version}/acme_corp`,
23
+ tokenEndpoint: "https://wd5-services1.myworkday.com/ccx/oauth2/acme_corp/token",
24
+ authorizationEndpoint: "https://acme.wd5.myworkday.com/acme_corp/authorize"
25
+ } }
26
+ } };
27
+ const state = { time: Date.now(), count: 0, value: undefined, status: 200, tokenPatch: {}, tokenStatus: 200, hang: false };
28
+ const requests = [];
29
+ const options = { configuration, providers: [{ ...workdayProvider, requestTimeoutMs: 60 }], authorize: async (owner) => owner,
30
+ now: () => state.time, resolveReference: async (ref) => {
31
+ if (ref === "env:WORKDAY_SECRET") return "private-client-secret";
32
+ assert.equal(ref, "env:WORKDAY_CALLBACK"); return callback;
33
+ },
34
+ store: createFileConnectionStore({ directory, protection: createCredentialProtection({ keys: { current: new Uint8Array(32).fill(9) }, activeKeyId: "current" }) }),
35
+ fetchImpl: async (address, init) => {
36
+ const url = new URL(String(address)); requests.push({ url, init }); const headers = new Headers(init.headers);
37
+ if (url.href === configuration.integrations.workday.settings.tokenEndpoint) {
38
+ assert.equal(init.method, "POST"); const body = new URLSearchParams(init.body);
39
+ if (method === "client_secret_post") {
40
+ assert.equal(body.get("client_id"), "fixture-client"); assert.equal(body.get("client_secret"), "private-client-secret");
41
+ assert.equal(headers.has("authorization"), false);
42
+ } else {
43
+ const authorization = headers.get("authorization"); assert.ok(authorization.startsWith("Basic "));
44
+ assert.equal(decodeURIComponent(Buffer.from(authorization.slice(6), "base64").toString()), "fixture-client:private-client-secret");
45
+ assert.equal(body.has("client_secret"), false);
46
+ }
47
+ assert.equal(body.has("code_verifier"), false); assert.equal(body.has("scope"), false);
48
+ if (body.get("grant_type") === "authorization_code") {
49
+ assert.equal(body.get("redirect_uri"), callback); assert.equal(body.get("code"), "private-code");
50
+ } else {
51
+ assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), `private-refresh-${state.count}`);
52
+ }
53
+ state.count++;
54
+ return Response.json(state.tokenStatus === 200 ? { access_token: `private-access-${state.count}`, token_type: "Bearer", expires_in: 120,
55
+ refresh_token: `private-refresh-${state.count}`, ...state.tokenPatch } : { error: "invalid_grant", message: "private-provider-error" }, { status: state.tokenStatus });
56
+ }
57
+ assert.equal(url.origin, "https://wd5-services1.myworkday.com"); assert.equal(init.method, "GET");
58
+ assert.equal(init.redirect, "error"); assert.equal(init.credentials, "omit");
59
+ assert.ok([`${prefix}/api/staffing/v7/acme_corp/workers`, `${prefix}/api/staffing/v7/acme_corp/workers/me`].includes(url.pathname) ||
60
+ url.pathname.startsWith(`${prefix}/api/staffing/v7/acme_corp/supervisoryOrganizations`) ||
61
+ url.pathname.startsWith(`${prefix}/api/absenceManagement/v5/acme_corp/`) ||
62
+ url.pathname.startsWith("/ccx/service/customreport2/acme_corp/"));
63
+ assert.equal(headers.get("authorization"), `Bearer private-access-${state.count}`);
64
+ if (state.hang) return new Promise((_, reject) => {
65
+ const timer = setTimeout(() => reject(new Error("Missing abort")), 1000);
66
+ const abort = () => { clearTimeout(timer); reject(init.signal.reason); };
67
+ if (init.signal.aborted) abort(); else init.signal.addEventListener("abort", abort, { once: true });
68
+ });
69
+ if (typeof state.value === "string") return new Response(state.value, { status: state.status });
70
+ return Response.json(state.value === undefined ? url.pathname.endsWith("/me") ? me : { data: [me], total: 1 } : state.value, { status: state.status });
71
+ }
72
+ };
73
+ const service = createConnectionService(options);
74
+ const start = async () => {
75
+ const { authorizationUrl } = await service.beginAuthorization(args); const url = new URL(authorizationUrl);
76
+ const returned = new URL(callback); returned.searchParams.set("state", url.searchParams.get("state")); returned.searchParams.set("code", "private-code");
77
+ return { url, callbackUrl: returned.href };
78
+ };
79
+ const connect = async () => service.completeAuthorization({ ...args, callbackUrl: (await start()).callbackUrl });
80
+ return { directory, options, service, requests, state, start, connect };
81
+ }
82
+
83
+ test("Workday confidential consent uses the tenant's distinct authorization host without PKCE or invented scopes", async (t) => {
84
+ for (const method of ["client_secret_post", "client_secret_basic"]) {
85
+ const f = await fixture(t, { method }); const start = await f.start(); const second = await f.start();
86
+ assert.equal(start.url.origin + start.url.pathname, f.options.configuration.integrations.workday.settings.authorizationEndpoint);
87
+ assert.equal(start.url.searchParams.get("response_type"), "code"); assert.equal(start.url.searchParams.get("client_id"), "fixture-client");
88
+ assert.equal(start.url.searchParams.get("redirect_uri"), callback);
89
+ assert.notEqual(start.url.searchParams.get("state"), second.url.searchParams.get("state"));
90
+ for (const key of ["scope", "code_challenge", "code_challenge_method", "client_secret"]) assert.equal(start.url.searchParams.has(key), false);
91
+ const result = await f.service.completeAuthorization({ ...args, callbackUrl: start.callbackUrl });
92
+ assert.equal(result.status, "connected"); assert.deepEqual(result.grantedScopes, []); assert.equal(JSON.stringify(result).includes("private"), false);
93
+ assert.equal(f.requests.at(-1).url.pathname, "/ccx/api/staffing/v7/acme_corp/workers/me");
94
+ const restarted = createConnectionService(f.options); assert.equal((await restarted.status(args)).status, "connected");
95
+ assert.deepEqual(await restarted.invoke({ ...args, operation: "workers.me" }), me);
96
+ for (const file of await readdir(f.directory)) {
97
+ const value = await readFile(path.join(f.directory, file), "utf8");
98
+ for (const secret of ["private-code", "private-client-secret", "private-access-1", "private-refresh-1"]) assert.equal(value.includes(secret), false);
99
+ }
100
+ }
101
+ });
102
+
103
+ test("Workday retains the configured API prefix and tenant while selecting Staffing v7", async (t) => {
104
+ for (const prefix of ["", "/ccx"]) for (const version of ["v1", "staffing/v7"]) {
105
+ const f = await fixture(t, { prefix, version }); await f.connect();
106
+ await f.service.invoke({ ...args, operation: "workers.list" });
107
+ assert.equal(f.requests.at(-1).url.pathname, `${prefix}/api/staffing/v7/acme_corp/workers`);
108
+ assert.equal(f.requests.at(-1).url.search, "?limit=20&offset=0");
109
+ }
110
+ });
111
+
112
+ test("Workday reads bounded worker pages and preserves optional worker data and empty results", async (t) => {
113
+ const f = await fixture(t); await f.connect();
114
+ const input = { limit: 100, offset: 40, search: "Fixture & 21005", includeTerminatedWorkers: true, filterByOrgVisibility: false };
115
+ const result = await f.service.invoke({ ...args, operation: "workers.list", input }); assert.deepEqual(result, { data: [me], total: 1 });
116
+ assert.deepEqual(Object.fromEntries(f.requests.at(-1).url.searchParams), { limit: "100", offset: "40", search: "Fixture & 21005", includeTerminatedWorkers: "true", filterByOrgVisibility: "false" });
117
+ f.state.value = { data: [], total: 0 }; assert.deepEqual(await f.service.invoke({ ...args, operation: "workers.list" }), f.state.value);
118
+ });
119
+
120
+ test("Workday rejects invalid paging and caller endpoint or identity overrides before transport", async (t) => {
121
+ const f = await fixture(t); await f.connect(); const count = f.requests.length;
122
+ for (const input of [{ limit: 0 }, { limit: 101 }, { limit: 1.5 }, { offset: -1 }, { offset: Number.MAX_SAFE_INTEGER + 1 }, { search: "" },
123
+ { search: "a".repeat(513) }, { tenant: "other" }, { restApiEndpoint: "https://attacker.test" }, { tokenEndpoint: "https://attacker.test" }, { userId: "other" }]) {
124
+ await assert.rejects(f.service.invoke({ ...args, operation: "workers.list", input }));
125
+ }
126
+ await assert.rejects(f.service.invoke({ ...args, operation: "workers.me", input: { id: "other" } }));
127
+ const denied = createConnectionService({ ...f.options, authorize: async () => { throw new Error("Denied by host"); } });
128
+ await assert.rejects(denied.invoke({ ...args, operation: "workers.me" }), /Denied by host/u);
129
+ assert.equal(f.requests.length, count);
130
+ });
131
+
132
+ test("Workday binds connections and pending attempts to owner, application and all endpoint settings", async (t) => {
133
+ const f = await fixture(t); await f.connect(); const count = f.requests.length;
134
+ for (const owner of [{ ...context, applicationId: "other" }, { ...context, subjectId: "other" }]) {
135
+ await assert.rejects(f.service.invoke({ ...args, context: owner, operation: "workers.me" }), { code: "connector_reconnect_required" });
136
+ }
137
+ for (const change of [
138
+ (settings) => { settings.authorizationEndpoint = "https://other.wd5.myworkday.com/acme_corp/authorize"; },
139
+ (settings) => { settings.restApiEndpoint = settings.restApiEndpoint.replace("/v1/", "/staffing/v7/"); },
140
+ (settings) => { for (const key of Object.keys(settings)) settings[key] = settings[key].replaceAll("acme_corp", "acme_sandbox"); },
141
+ (settings) => { for (const key of ["restApiEndpoint", "tokenEndpoint"]) settings[key] = settings[key].replace("wd5-services1", "wd5-services2"); }
142
+ ]) {
143
+ const configuration = structuredClone(f.options.configuration); change(configuration.integrations.workday.settings);
144
+ const changed = createConnectionService({ ...f.options, configuration });
145
+ assert.equal((await changed.status(args)).status, "reconnect-required");
146
+ await assert.rejects(changed.invoke({ ...args, operation: "workers.me" }), { code: "connector_reconnect_required" });
147
+ const start = await f.start(); await assert.rejects(changed.completeAuthorization({ ...args, callbackUrl: start.callbackUrl }), { code: "connector_attempt_invalid" });
148
+ }
149
+ assert.equal(f.requests.length, count);
150
+ });
151
+
152
+ test("Workday refreshes expiring grants, retains rotation and reports absent or revoked refresh access", async (t) => {
153
+ const f = await fixture(t); await f.connect(); f.state.time += 121_000;
154
+ await f.service.invoke({ ...args, operation: "workers.me" }); assert.equal(f.state.count, 2);
155
+ f.state.time += 121_000; await f.service.invoke({ ...args, operation: "workers.me" }); assert.equal(f.state.count, 3);
156
+ f.state.time += 121_000; f.state.tokenStatus = 400;
157
+ await assert.rejects(f.service.invoke({ ...args, operation: "workers.me" }), { code: "connector_reconnect_required" });
158
+ const short = await fixture(t); short.state.tokenPatch.refresh_token = undefined; await short.connect(); short.state.time += 121_000;
159
+ await assert.rejects(short.service.invoke({ ...args, operation: "workers.me" }), { code: "connector_reconnect_required" }); assert.equal(short.requests.length, 2);
160
+ const unknown = await fixture(t); unknown.state.tokenPatch.expires_in = undefined; await unknown.connect(); unknown.state.time += 86_400_000;
161
+ await unknown.service.invoke({ ...args, operation: "workers.me" }); assert.equal(unknown.state.count, 1);
162
+ unknown.state.status = 401; await assert.rejects(unknown.service.invoke({ ...args, operation: "workers.me" }), { code: "connector_reconnect_required" });
163
+ });
164
+
165
+ test("Workday consumes denied, cancelled, expired and replayed attempts without destroying earlier access", async (t) => {
166
+ const f = await fixture(t); await f.connect();
167
+ const denied = await f.start(); const url = new URL(denied.callbackUrl); url.searchParams.delete("code"); url.searchParams.set("error", "access_denied");
168
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: url.href }), { code: "connector_consent_denied" });
169
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: denied.callbackUrl }), { code: "connector_attempt_invalid" });
170
+ const cancelled = await f.start(); await f.service.cancelAuthorization({ ...args, state: cancelled.url.searchParams.get("state") });
171
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
172
+ const expired = await f.start(); f.state.time += 601_000;
173
+ await assert.rejects(f.service.completeAuthorization({ ...args, callbackUrl: expired.callbackUrl }), { code: "connector_attempt_invalid" });
174
+ assert.equal((await f.service.status(args)).status, "connected"); assert.equal(f.requests.length, 2);
175
+ });
176
+
177
+ test("Workday rejects malformed worker and page responses before marking or reporting success", async (t) => {
178
+ for (const value of [null, {}, [], { id: "bad", descriptor: "Worker" }, { id: me.id }, { ...me, workerId: 42 }]) {
179
+ const f = await fixture(t); f.state.value = value;
180
+ await assert.rejects(f.connect(), { code: "connector_response_invalid" }); assert.equal((await f.service.status(args)).status, "disconnected");
181
+ }
182
+ const f = await fixture(t); await f.connect();
183
+ for (const value of [null, {}, [], { data: [] }, { data: [], total: -1 }, { data: [{}], total: 1 }, { data: null, total: 0 },
184
+ { data: [], total: 0, errors: [] }, { data: [], total: 0, error: "private" }, { data: Array(101).fill(me), total: 101 }, "<html>private-error</html>"]) {
185
+ f.state.value = value; await assert.rejects(f.service.invoke({ ...args, operation: "workers.list" }), { code: "connector_response_invalid" });
186
+ }
187
+ });
188
+
189
+ test("Workday sanitizes provider failures, aborts pending reads and disconnects locally", async (t) => {
190
+ const f = await fixture(t); await f.connect();
191
+ for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
192
+ f.state.status = status; f.state.value = { error: "private-provider-error" };
193
+ await assert.rejects(f.service.invoke({ ...args, operation: "workers.me" }), (error) => error.code === code && !error.message.includes("private"));
194
+ }
195
+ f.state.hang = true; const controller = new AbortController();
196
+ const pending = f.service.invoke({ ...args, operation: "workers.me", signal: controller.signal }); setTimeout(() => controller.abort(), 10);
197
+ await assert.rejects(pending, { code: "connector_cancelled" });
198
+ await assert.rejects(f.service.invoke({ ...args, operation: "workers.me" }), { code: "connector_provider_timeout" });
199
+ f.state.hang = false; await f.service.disconnect(args); assert.equal((await f.service.status(args)).status, "disconnected");
200
+ });
201
+
202
+ test("Workday guide and CLI share endpoint, owner, confidential-client and reference validation", async () => {
203
+ const guide = await readFile(new URL("../docs/workday.md", import.meta.url), "utf8");
204
+ const config = JSON.parse([...guide.matchAll(/```json\n([\s\S]*?)\n```/gu)][0][1]);
205
+ const parse = (value) => parseIntegrationConfiguration(JSON.stringify(value), { providers: [workdayProvider] });
206
+ assert.deepEqual(parse(config), config);
207
+ for (const [key, values] of Object.entries({
208
+ restApiEndpoint: ["", "http://wd5-services1.myworkday.com/ccx/api/v1/acme_corp", "https://127.0.0.1/ccx/api/v1/acme_corp", "https://wd5-services1.myworkday.com.attacker.test/ccx/api/v1/acme_corp", config.integrations.workday.settings.restApiEndpoint + "?x=1", "https://wd5-services1.myworkday.com:443/ccx/api/v1/acme_corp", "https://wd5-services1.myworkday.com/ccx/api/v1/../acme_corp"],
209
+ tokenEndpoint: ["", "https://other.myworkday.com/ccx/oauth2/acme_corp/token", "https://wd5-services1.myworkday.com/ccx/oauth2/other/token", config.integrations.workday.settings.tokenEndpoint + "#bad"],
210
+ authorizationEndpoint: ["", "https://attacker.test/acme_corp/authorize", "https://acme.wd5.myworkday.com/other/authorize", config.integrations.workday.settings.authorizationEndpoint + "?redirect_uri=bad"]
211
+ })) for (const value of values) {
212
+ const invalid = structuredClone(config); invalid.integrations.workday.settings[key] = value;
213
+ assert.throws(() => parse(invalid), (error) => Boolean(error.fieldErrors[`integrations.workday.settings.${key}`]));
214
+ }
215
+ for (const [key, value] of [["clientSecretRef", "private-secret"], ["callbackUrlRef", callback], ["tokenEndpointAuthMethod", "none"], ["grantType", "client_credentials"]]) {
216
+ const invalid = structuredClone(config); invalid.registrations["workday-tenant"][key] = value; assert.throws(() => parse(invalid));
217
+ }
218
+ for (const accountMode of ["shared", "assistant"]) { const invalid = structuredClone(config); invalid.integrations.workday.accountMode = accountMode; assert.throws(() => parse(invalid)); }
219
+ const scoped = structuredClone(config); scoped.integrations.workday.scopes = ["Staffing"]; assert.throws(() => parse(scoped));
220
+ });
221
+
222
+ test("Workday organization reads retain tenant, pagination and reporting relationships under the user's grant", async (t) => {
223
+ const f = await fixture(t); await f.connect();
224
+ const organization = { id: "Organization_Reference_ID=engineering", descriptor: "Engineering", name: "Engineering", managers: [{ id: me.id }] };
225
+ f.state.value = { data: [organization], total: 12 };
226
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "organizations.list", input: { limit: 2, offset: 4, includeInactive: false } }), f.state.value);
227
+ let url = f.requests.at(-1).url;
228
+ assert.equal(url.pathname, "/ccx/api/staffing/v7/acme_corp/supervisoryOrganizations");
229
+ assert.equal(url.searchParams.get("includeInactive"), "false"); assert.equal(url.searchParams.get("offset"), "4");
230
+ f.state.value = organization;
231
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "organizations.get", input: { id: organization.id } }), organization);
232
+ assert.equal(f.requests.at(-1).url.pathname, "/ccx/api/staffing/v7/acme_corp/supervisoryOrganizations/Organization_Reference_ID%3Dengineering");
233
+ for (const part of ["members", "orgChart"]) {
234
+ const record = part === "members" ? { id: me.id, descriptor: "Engineer", worker: me, businessTitle: "Engineer" } :
235
+ { ...organization, superior: { id: "parent", descriptor: "Company" }, subordinates: [] };
236
+ f.state.value = { data: [record], total: 1 };
237
+ assert.deepEqual(await f.service.invoke({ ...args, operation: `organizations.${part}`, input: { id: organization.id, limit: 5, offset: 0 } }), f.state.value);
238
+ assert.ok(f.requests.at(-1).url.pathname.endsWith(`/${part}`));
239
+ assert.equal(f.requests.at(-1).url.searchParams.get("limit"), "5");
240
+ }
241
+ const count = f.requests.length;
242
+ for (const id of ["../other", "me", "https://attacker.test", ""]) {
243
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.get", input: { id } }));
244
+ }
245
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list", input: { limit: 101 } }));
246
+ assert.equal(f.requests.length, count);
247
+ f.state.status = 403;
248
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_permission_denied" });
249
+ f.state.status = 200; f.state.value = { data: [{}], total: 1 };
250
+ await assert.rejects(f.service.invoke({ ...args, operation: "organizations.list" }), { code: "connector_response_invalid" });
251
+ });
252
+
253
+ test("Workday absence reads preserve balances, units and repeated request filters without changing users", async (t) => {
254
+ const f = await fixture(t); await f.connect();
255
+ const status = "0391102bd1b542538d996936c8fa2fa7";
256
+ const submitted = "dd817fe688db4ac7bf84e3ef79f72948";
257
+ f.state.value = { data: [{ quantity: 12.5, unit: { descriptor: "Hours" }, absencePlan: { id: status, descriptor: "Vacation" }, worker: me }], total: 1 };
258
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "timeOff.balances", input: { worker: me.id, effective: "2026-09-13", limit: 5, offset: 10 } }), f.state.value);
259
+ let url = f.requests.at(-1).url;
260
+ assert.equal(url.pathname, "/ccx/api/absenceManagement/v5/acme_corp/balances");
261
+ assert.equal(url.searchParams.get("worker"), me.id); assert.equal(url.searchParams.get("effective"), "2026-09-13");
262
+ assert.equal(url.searchParams.get("offset"), "10");
263
+ f.state.value = { data: [{ quantity: 4, unit: { descriptor: "Hours" }, status: { id: status, descriptor: "Approved" }, date: "2026-09-14", worker: me }], total: 1 };
264
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "timeOff.details", input: { worker: me.id, fromDate: "2026-09-01", toDate: "2026-09-30", status: [status, submitted], timeOffType: [status] } }), f.state.value);
265
+ url = f.requests.at(-1).url;
266
+ assert.equal(url.pathname, `/ccx/api/absenceManagement/v5/acme_corp/workers/${me.id}/timeOffDetails`);
267
+ assert.deepEqual(url.searchParams.getAll("status"), [status, submitted]); assert.equal(url.searchParams.has("worker"), false);
268
+ f.state.value = { data: [{ id: status, descriptor: "Approved" }], total: 1 };
269
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "timeOff.statuses" }), f.state.value);
270
+ assert.ok(f.requests.at(-1).url.pathname.endsWith("/values/timeOff/status/"));
271
+ const count = f.requests.length;
272
+ for (const input of [{}, { worker: "../other" }, { worker: me.id, fromDate: "2026-02-30" }, { worker: me.id, fromDate: "2026-10-01", toDate: "2026-09-01" }, { worker: me.id, status: [] }, { worker: me.id, status: ["Approved"] }, { worker: me.id, status: Array(21).fill(status) }]) {
273
+ await assert.rejects(f.service.invoke({ ...args, operation: "timeOff.details", input }));
274
+ }
275
+ assert.equal(f.requests.length, count);
276
+ for (const operation of ["timeOff.balances", "timeOff.details"]) {
277
+ f.state.status = 403;
278
+ await assert.rejects(f.service.invoke({ ...args, operation, input: { worker: me.id } }), { code: "connector_permission_denied" });
279
+ f.state.status = 200; f.state.value = { data: [], total: 0 };
280
+ assert.deepEqual(await f.service.invoke({ ...args, operation, input: { worker: me.id } }), f.state.value);
281
+ f.state.value = { data: [{ quantity: "unknown" }], total: 1 };
282
+ await assert.rejects(f.service.invoke({ ...args, operation, input: { worker: me.id } }), { code: "connector_response_invalid" });
283
+ }
284
+ });
285
+
286
+ test("Workday custom reports keep the configured tenant and preserve tenant-defined JSON fields", async (t) => {
287
+ const f = await fixture(t); await f.connect();
288
+ const input = { owner: "report.owner", report: "Team_Headcount", prompts: [
289
+ { name: "Organization!WID", value: me.id }, { name: "Include_Subordinate_Organizations", value: "1" }
290
+ ] };
291
+ f.state.value = { Report_Entry: [{ Department: "Engineering", Headcount: "8", Custom_Field: { text: "tenant-specific" } }], ResultCountEntry: [{ ResultCount: "1" }] };
292
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "reports.read", input }), f.state.value);
293
+ const url = f.requests.at(-1).url;
294
+ assert.equal(url.origin, "https://wd5-services1.myworkday.com");
295
+ assert.equal(url.pathname, "/ccx/service/customreport2/acme_corp/report.owner/Team_Headcount");
296
+ assert.equal(url.searchParams.get("Organization!WID"), me.id); assert.equal(url.searchParams.get("format"), "json");
297
+ const count = f.requests.length;
298
+ for (const patch of [{ owner: ".." }, { report: "../other" }, { report: "%2fother" }, { tenant: "other" }, { url: "https://attacker.test" },
299
+ { prompts: [{ name: "format", value: "csv" }] }, { prompts: [{ name: "A", value: "1" }, { name: "A", value: "2" }] }]) {
300
+ await assert.rejects(f.service.invoke({ ...args, operation: "reports.read", input: { ...input, ...patch } }));
301
+ }
302
+ assert.equal(f.requests.length, count);
303
+ for (const value of [{}, { Report_Entry: null }, { Report_Entry: [null] }, { error: "private", Report_Entry: [] }]) {
304
+ f.state.value = value;
305
+ await assert.rejects(f.service.invoke({ ...args, operation: "reports.read", input }), { code: "connector_response_invalid" });
306
+ }
307
+ f.state.value = { Report_Entry: [] };
308
+ assert.deepEqual(await f.service.invoke({ ...args, operation: "reports.read", input }), f.state.value);
309
+ f.state.status = 403;
310
+ await assert.rejects(f.service.invoke({ ...args, operation: "reports.read", input }), { code: "connector_permission_denied" });
311
+ f.state.status = 500; const before = f.requests.length;
312
+ await assert.rejects(f.service.invoke({ ...args, operation: "reports.read", input }), { code: "connector_provider_failed" });
313
+ assert.equal(f.requests.length, before + 1);
314
+ });
@@ -0,0 +1,256 @@
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, ConnectorError } 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 { xTwitterProvider } from "../src/server/x-twitter.js";
10
+
11
+ const context = { applicationId: "research-app", subjectId: "research-team" };
12
+ const input = { context, integrationId: "social" };
13
+ const verification = { ...input, verificationInput: { username: "Example" } };
14
+ const profile = { data: { id: "1234567890123456789", name: "Example", username: "example" } };
15
+ const page = { data: [{ id: "1934567890123456789", text: "A public post" }], meta: { result_count: 1, next_token: "opaque-cursor" } };
16
+
17
+ async function fixture(t) {
18
+ const directory = await mkdtemp(path.join(tmpdir(), "x-twitter-connector-"));
19
+ t.after(() => rm(directory, { recursive: true, force: true }));
20
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(8) }, activeKeyId: "current" });
21
+ const requests = [];
22
+ const state = { key: "fixture%2Fapp-token", status: 200, response: profile, wait: false, resolutions: 0 };
23
+ const options = {
24
+ configuration: { schemaVersion: 1, registrations: {}, integrations: { social: {
25
+ provider: "x-twitter", displayName: "Public research", accountMode: "shared", scopes: [],
26
+ authentication: { method: "api-key", secretRef: "env:X_APP_BEARER_TOKEN" }, extensions: { fromCli: true }
27
+ } }, extensions: { preserved: true } },
28
+ providers: [xTwitterProvider], authorize: async (owner) => owner,
29
+ store: createFileConnectionStore({ directory, protection }),
30
+ resolveReference: async (ref) => { state.resolutions++; assert.equal(ref, "env:X_APP_BEARER_TOKEN"); return state.key; },
31
+ fetchImpl: async (address, init) => {
32
+ requests.push({ url: new URL(String(address)), init, headers: new Headers(init.headers) });
33
+ if (state.wait) {
34
+ return new Promise((_, reject) => {
35
+ if (init.signal.aborted) reject(init.signal.reason);
36
+ else init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
37
+ });
38
+ }
39
+ return Response.json(state.response, { status: state.status });
40
+ }
41
+ };
42
+ return { service: createConnectionService(options), options, requests, directory, protection, state };
43
+ }
44
+
45
+ test("X verifies public lookup using an app token and preserves file ownership, rotation and disconnect", async (t) => {
46
+ assert.deepEqual(xTwitterProvider.verificationFields.filter((field) => field.required).map((field) => field.name), Object.keys(verification.verificationInput));
47
+ const { service, options, requests, directory, protection, state } = await fixture(t);
48
+ const connected = await service.connectApiKey(verification);
49
+ assert.equal(connected.status, "connected");
50
+ assert.equal(requests[0].url.href, "https://api.x.com/2/users/by/username/Example?user.fields=description%2Cprofile_image_url%2Cpublic_metrics");
51
+ assert.equal(requests[0].init.method, "GET"); assert.equal(requests[0].init.body, undefined);
52
+ assert.equal(requests[0].init.redirect, "error");
53
+ assert.equal(requests[0].headers.get("authorization"), `Bearer ${state.key}`);
54
+ for (const name of await readdir(directory)) {
55
+ const stored = await readFile(path.join(directory, name), "utf8");
56
+ assert.doesNotThrow(() => JSON.parse(stored)); assert.equal(stored.includes(state.key), false);
57
+ }
58
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
59
+ assert.deepEqual(await restarted.status(input), connected);
60
+ state.key = "rotated-app-token";
61
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "users.lookup", input: { username: "example" } }), profile);
62
+ assert.equal(requests.at(-1).headers.get("authorization"), "Bearer rotated-app-token");
63
+ for (const owner of [{ ...context, applicationId: "other-app" }, { ...context, subjectId: "other-team" }]) {
64
+ await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "users.lookup", input: { username: "example" } }), { 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
+ assert.equal(requests.length, 2);
70
+ });
71
+
72
+ test("X configuration accepts token references for shared and assistant access without OAuth or user login", async (t) => {
73
+ const { service, options, state, requests } = await fixture(t);
74
+ const parse = (configuration) => parseIntegrationConfiguration(JSON.stringify(configuration), { providers: [xTwitterProvider] });
75
+ const parsed = parse(options.configuration);
76
+ assert.deepEqual(parsed.extensions, { preserved: true }); assert.deepEqual(parsed.integrations.social.extensions, { fromCli: true });
77
+ for (const accountMode of ["shared", "assistant"]) {
78
+ const configuration = structuredClone(options.configuration); configuration.integrations.social.accountMode = accountMode;
79
+ assert.equal(parse(configuration).integrations.social.accountMode, accountMode);
80
+ }
81
+ for (const change of [
82
+ { authentication: { method: "api-key", secretRef: "raw-token" } },
83
+ { authentication: { method: "api-key", secretRef: "https://example.test/token" } },
84
+ { authentication: { method: "api-key" } }, { accountMode: "per-user" },
85
+ { authentication: { method: "oauth2", registrationRef: "x" } }, { scopes: ["tweet.read"] }
86
+ ]) {
87
+ const configuration = structuredClone(options.configuration); Object.assign(configuration.integrations.social, change);
88
+ assert.throws(() => parse(configuration), (error) => Boolean(error.fieldErrors));
89
+ }
90
+ for (const key of ["", "Bearer token", "key\nheader", "key\u0000", "é", "a".repeat(8193)]) {
91
+ state.key = key; await assert.rejects(service.connectApiKey(verification), { code: "connector_binding_missing" });
92
+ }
93
+ assert.equal(requests.length, 0);
94
+ });
95
+
96
+ test("X requires an explicit verification username and rejects injected lookup paths or request options", async (t) => {
97
+ const { service, requests } = await fixture(t);
98
+ for (const values of [{}, ...["", "@example", " example", "example ", "a".repeat(16), "../me", "https://example.test", "a?x=y"].map((username) => ({ username })),
99
+ { username: "example", url: "https://other.test" }, { username: "example", headers: { Authorization: "other" } }]) {
100
+ await assert.rejects(service.connectApiKey({ ...input, verificationInput: values }), { code: "connector_input_invalid" });
101
+ }
102
+ assert.equal(requests.length, 0); assert.equal((await service.status(input)).status, "disconnected");
103
+ await service.connectApiKey(verification);
104
+ for (const operation of ["users.me", "posts.create", "messages.send"]) {
105
+ await assert.rejects(service.invoke({ ...input, operation }), { code: "connector_operation_unknown" });
106
+ }
107
+ assert.equal(requests.length, 1);
108
+ });
109
+
110
+ test("X public post pages preserve large string IDs and explicit opaque cursors, including empty timelines", async (t) => {
111
+ const { service, state, requests } = await fixture(t);
112
+ await service.connectApiKey(verification); state.response = page;
113
+ const operation = { ...input, operation: "users.posts" };
114
+ assert.deepEqual(await service.invoke({ ...operation, input: { userId: profile.data.id } }), page);
115
+ assert.equal(requests.at(-1).url.href, `https://api.x.com/2/users/${profile.data.id}/tweets?max_results=10`);
116
+ for (const maxResults of [5, 100]) {
117
+ await service.invoke({ ...operation, input: { userId: profile.data.id, maxResults, paginationToken: "opaque+/=cursor" } });
118
+ assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), { max_results: String(maxResults), pagination_token: "opaque+/=cursor" });
119
+ }
120
+ const count = requests.length;
121
+ for (const values of [
122
+ ...[undefined, 123, Number(profile.data.id), "", "1".repeat(20), "../me", "12?x=y", " 12"].map((userId) => ({ userId })),
123
+ ...[4, 101, 5.5].map((maxResults) => ({ userId: profile.data.id, maxResults })),
124
+ ...["", "bad\n", "a".repeat(4097)].map((paginationToken) => ({ userId: profile.data.id, paginationToken })),
125
+ { userId: profile.data.id, url: "https://other.test" }
126
+ ]) await assert.rejects(service.invoke({ ...operation, input: values }), { code: "connector_input_invalid" });
127
+ assert.equal(requests.length, count);
128
+ for (const response of [{ meta: { result_count: 0 } }, { data: [], meta: { result_count: 0 } }]) {
129
+ state.response = response;
130
+ assert.deepEqual(await service.invoke({ ...operation, input: { userId: profile.data.id } }), response);
131
+ }
132
+ assert.equal(requests.length, count + 2);
133
+ });
134
+
135
+ test("X rejects malformed profiles, post envelopes, pagination and lossy IDs", async (t) => {
136
+ const { service, state } = await fixture(t);
137
+ for (const response of [null, [], {}, { errors: {} }, { ...profile, errors: {} },
138
+ ...[{ id: Number(profile.data.id) }, { username: "someone_else" }, { name: null }, { username: "@example" }].map((change) => ({ data: { ...profile.data, ...change } }))]) {
139
+ state.response = response;
140
+ await assert.rejects(service.connectApiKey(verification), { code: "connector_response_invalid" });
141
+ assert.equal((await service.status(input)).status, "disconnected");
142
+ }
143
+ state.response = profile; await service.connectApiKey(verification);
144
+ for (const response of [{}, { ...page, data: {} }, { meta: { result_count: 1 } }, { ...page, errors: {} },
145
+ ...[{ result_count: "1" }, { result_count: 2 }, { result_count: -1 }, { next_token: "" }, { previous_token: "bad\n" }, { newest_id: 123 }].map((change) => ({ ...page, meta: { ...page.meta, ...change } })),
146
+ { data: Array(11).fill(page.data[0]), meta: { result_count: 11 } },
147
+ ...[{ id: Number(page.data[0].id) }, { id: "1".repeat(20) }, { text: null }].map((change) => ({ ...page, data: [{ ...page.data[0], ...change }] }))]) {
148
+ state.response = response;
149
+ await assert.rejects(service.invoke({ ...input, operation: "users.posts", input: { userId: profile.data.id } }), { code: "connector_response_invalid" });
150
+ }
151
+ });
152
+
153
+ test("X partial successes and unavailable lookup results never establish a false connection", async (t) => {
154
+ const { service, state } = await fixture(t);
155
+ for (const [response, code] of [
156
+ [{ errors: [{ type: "https://api.x.com/2/problems/resource-not-found", detail: state.key }] }, "connector_resource_not_found"],
157
+ [{ errors: [{ type: "https://api.twitter.com/2/problems/resource-not-found" }] }, "connector_resource_not_found"],
158
+ [{ ...profile, errors: [{ detail: state.key }] }, "connector_response_incomplete"],
159
+ [{ errors: [{ type: "unknown", detail: state.key }] }, "connector_response_incomplete"]
160
+ ]) {
161
+ state.response = response;
162
+ await assert.rejects(service.connectApiKey(verification), (error) => error.code === code && !JSON.stringify(error).includes(state.key));
163
+ assert.equal((await service.status(input)).status, "disconnected");
164
+ }
165
+ state.response = { ...profile, errors: [] }; await service.connectApiKey(verification);
166
+ state.response = { ...page, errors: [{ detail: state.key }] };
167
+ await assert.rejects(service.invoke({ ...input, operation: "users.posts", input: { userId: profile.data.id } }), { code: "connector_response_incomplete" });
168
+ });
169
+
170
+ test("X transport errors distinguish billing, missing resources, access and capacity without disclosing provider messages", async (t) => {
171
+ const { service, state, requests } = await fixture(t);
172
+ for (const [status, code] of [[401, "connector_reconnect_required"], [402, "connector_billing_required"], [403, "connector_permission_denied"],
173
+ [404, "connector_resource_not_found"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
174
+ state.status = status; state.response = { detail: state.key };
175
+ const count = requests.length;
176
+ await assert.rejects(service.connectApiKey(verification), (error) => error.code === code && !JSON.stringify(error).includes(state.key));
177
+ assert.equal(requests.length, count + 1); assert.equal((await service.status(input)).status, "disconnected");
178
+ }
179
+ state.status = 200; state.response = profile; await service.connectApiKey(verification);
180
+ state.status = 401;
181
+ await assert.rejects(service.invoke({ ...input, operation: "users.lookup", input: { username: "example" } }), { code: "connector_reconnect_required" });
182
+ assert.equal((await service.status(input)).status, "reconnect-required");
183
+ });
184
+
185
+ test("X host authorization precedes token resolution and API calls", async (t) => {
186
+ const { service, options, state, requests } = await fixture(t);
187
+ await service.connectApiKey(verification);
188
+ const denied = createConnectionService({ ...options, authorize: async (_owner, request) => {
189
+ assert.equal(request.operation, "users.posts"); assert.equal(request.input.userId, profile.data.id);
190
+ throw new ConnectorError("connector_permission_denied", "Denied", { statusCode: 403 });
191
+ } });
192
+ const count = requests.length, resolutions = state.resolutions;
193
+ await assert.rejects(denied.invoke({ ...input, operation: "users.posts", input: { userId: profile.data.id } }), { code: "connector_permission_denied" });
194
+ assert.equal(requests.length, count); assert.equal(state.resolutions, resolutions);
195
+ });
196
+
197
+ test("X cancellation and timeout do not replay reads or discard a valid connection", async (t) => {
198
+ const { service, options, state, requests } = await fixture(t);
199
+ await service.connectApiKey(verification); state.wait = true;
200
+ const controller = new AbortController();
201
+ const operation = { ...input, operation: "users.lookup", input: { username: "example" } };
202
+ const pending = service.invoke({ ...operation, signal: controller.signal });
203
+ setTimeout(() => controller.abort(), 15);
204
+ await assert.rejects(pending, { code: "connector_cancelled" });
205
+ const timeoutService = createConnectionService({ ...options, providers: [{ ...xTwitterProvider, requestTimeoutMs: 20 }] });
206
+ const keepAlive = setTimeout(() => {}, 1000);
207
+ try { await assert.rejects(timeoutService.invoke(operation), { code: "connector_provider_timeout" }); }
208
+ finally { clearTimeout(keepAlive); }
209
+ assert.equal(requests.length, 3); assert.equal((await service.status(input)).status, "connected");
210
+ state.wait = false; assert.deepEqual(await service.invoke(operation), profile);
211
+ });
212
+
213
+ test("X guide configuration uses the same CLI validator as the editor", async () => {
214
+ const guide = await readFile(new URL("../docs/x-twitter.md", import.meta.url), "utf8");
215
+ const configuration = parseIntegrationConfiguration(guide.match(/```json\n([\s\S]*?)\n```/u)[1], { providers: [xTwitterProvider] });
216
+ assert.equal(configuration.integrations.social.provider, "x-twitter");
217
+ assert.equal(configuration.integrations.social.authentication.secretRef, "env:X_APP_BEARER_TOKEN");
218
+ assert.deepEqual(configuration.registrations, {});
219
+ });
220
+
221
+
222
+ test("X recent search preserves queries, pagination and expanded public display data", async (t) => {
223
+ const { service, state, requests } = await fixture(t);
224
+ await service.connectApiKey(verification);
225
+ const result = { ...page, includes: { users: [profile.data], media: [{ media_key: "3_123", type: "photo", url: "https://pbs.twimg.com/example.jpg" }] } };
226
+ state.response = result;
227
+ const call = { ...input, operation: "posts.searchRecent" };
228
+ const query = '("pet grooming" OR #dogs) lang:en -is:retweet';
229
+ assert.deepEqual(await service.invoke({ ...call, input: { query } }), result);
230
+ const request = requests.at(-1);
231
+ assert.equal(request.url.origin, "https://api.x.com");
232
+ assert.equal(request.url.pathname, "/2/tweets/search/recent");
233
+ assert.equal(request.url.searchParams.get("query"), query);
234
+ assert.equal(request.url.searchParams.get("expansions"), "author_id,attachments.media_keys");
235
+ assert.equal(request.url.searchParams.get("max_results"), "10");
236
+ assert.equal(request.headers.get("authorization"), `Bearer ${state.key}`);
237
+ await service.invoke({ ...call, input: { query, maxResults: 100, nextToken: "opaque+/=cursor" } });
238
+ assert.equal(requests.at(-1).url.searchParams.get("next_token"), "opaque+/=cursor");
239
+ const count = requests.length;
240
+ for (const values of [{}, { query: " " }, { query: "x".repeat(513) }, { query: "a\nb" },
241
+ { query, maxResults: 9 }, { query, maxResults: 101 }, { query, nextToken: "" },
242
+ { query, url: "https://other.test" }]) {
243
+ await assert.rejects(service.invoke({ ...call, input: values }), { code: "connector_input_invalid" });
244
+ }
245
+ assert.equal(requests.length, count);
246
+ state.response = { meta: { result_count: 0 } };
247
+ assert.deepEqual(await service.invoke({ ...call, input: { query } }), state.response);
248
+ state.response = { ...result, errors: [{ detail: state.key }] };
249
+ await assert.rejects(service.invoke({ ...call, input: { query } }), { code: "connector_response_incomplete" });
250
+ for (const [status, code] of [[402, "connector_billing_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"]]) {
251
+ state.status = status;
252
+ const before = requests.length;
253
+ await assert.rejects(service.invoke({ ...call, input: { query } }), { code });
254
+ assert.equal(requests.length, before + 1);
255
+ }
256
+ });