@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,700 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createConnectionService } from "../../connectors-core/src/server/index.js";
7
+ import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
8
+ import { validateIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
9
+ import { xeroProvider } from "../src/server/xero.js";
10
+
11
+ const tenantId = "70784a63-d24b-46a9-a4db-0e70a274b056";
12
+ const otherTenant = "e0da6937-de07-4a14-adee-37abfac298ce";
13
+ const recordId = "bd2270c3-8706-4c11-9cfb-000b551c3f51";
14
+ const callback = "https://app.example.test/connections/xero/callback";
15
+ const context = { applicationId: "app-one", subjectId: "user-one" };
16
+ const input = { context, integrationId: "xero" };
17
+ const connection = { id: recordId, tenantId, tenantType: "ORGANISATION", tenantName: "Fixture organisation" };
18
+ const defaults = xeroProvider.scopes.filter((scope) => scope.recommended).map((scope) => scope.value);
19
+
20
+ async function fixture(t) {
21
+ const directory = await mkdtemp(path.join(tmpdir(), "xero-connector-"));
22
+ t.after(() => rm(directory, { recursive: true, force: true }));
23
+ const requests = [];
24
+ const state = { time: Date.now(), tokenCount: 0, tokenScope: defaults.join(" "), status: 200, connections: [connection],
25
+ response: { Contacts: [{ ContactID: recordId, Name: "Customer & Co" }] }, tokenStatus: 200, deny: false, stall: false };
26
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(4) }, activeKeyId: "current" });
27
+ const options = {
28
+ configuration: { schemaVersion: 1, integrations: { xero: { provider: "xero", accountMode: "per-user", scopes: defaults,
29
+ authentication: { method: "oauth2", registrationRef: "xero" } } },
30
+ registrations: { xero: { source: "own", tokenEndpointAuthMethod: "client_secret_basic", clientId: "fixture-client",
31
+ clientSecretRef: "env:XERO_SECRET", callbackUrlRef: "env:XERO_CALLBACK" } } },
32
+ providers: [xeroProvider], authorize: async (owner) => { if (state.deny) throw new Error("Denied by host"); return owner; }, now: () => state.time,
33
+ resolveReference: async (ref) => { if (ref === "env:XERO_CALLBACK") return callback; if (ref === "env:XERO_SECRET") return "fixture-client-secret"; throw new Error("Missing reference"); },
34
+ store: createFileConnectionStore({ directory, protection }),
35
+ fetchImpl: async (address, init) => {
36
+ const url = new URL(String(address));
37
+ const headers = new Headers(init.headers);
38
+ requests.push({ url, init, headers });
39
+ if (url.origin === "https://identity.xero.com") {
40
+ assert.equal(init.redirect, "manual");
41
+ assert.equal(url.pathname, "/connect/token");
42
+ assert.ok(headers.get("authorization").startsWith("Basic "));
43
+ const credentials = Buffer.from(headers.get("authorization").slice(6), "base64").toString().split(":").map(decodeURIComponent);
44
+ assert.deepEqual(credentials, ["fixture-client", "fixture-client-secret"]);
45
+ const body = new URLSearchParams(init.body);
46
+ assert.equal(body.has("client_secret"), false);
47
+ if (body.get("grant_type") === "refresh_token") assert.equal(body.get("refresh_token"), `fixture-refresh-${state.tokenCount}`);
48
+ if (state.tokenStatus !== 200) return Response.json({ error: "invalid_grant", error_description: "fixture-client-secret" }, { status: state.tokenStatus });
49
+ state.tokenCount += 1;
50
+ return Response.json({ access_token: `fixture-access-${state.tokenCount}`, refresh_token: `fixture-refresh-${state.tokenCount}`, token_type: "Bearer", expires_in: 60, scope: state.tokenScope });
51
+ }
52
+ assert.equal(url.origin, "https://api.xero.com");
53
+ assert.equal(init.redirect, "error");
54
+ assert.equal(headers.get("authorization"), `Bearer fixture-access-${state.tokenCount}`);
55
+ if (state.stall) return new Promise((resolve, reject) => { init.signal.throwIfAborted(); init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }); });
56
+ if (url.pathname === "/connections") {
57
+ assert.equal(headers.has("xero-tenant-id"), false);
58
+ return Response.json(state.connections, { status: state.status });
59
+ }
60
+ assert.equal(headers.get("xero-tenant-id"), tenantId);
61
+ if (state.binaryResponse !== undefined) return new Response(state.binaryResponse, { status: state.accountingStatus ?? state.status, headers: { "content-type": "application/pdf" } });
62
+ return Response.json(state.response, { status: state.accountingStatus ?? state.status });
63
+ }
64
+ };
65
+ const service = createConnectionService(options);
66
+ async function start() {
67
+ const { authorizationUrl } = await service.beginAuthorization(input);
68
+ const url = new URL(authorizationUrl);
69
+ const redirect = new URL(callback);
70
+ redirect.searchParams.set("code", "fixture-code"); redirect.searchParams.set("state", url.searchParams.get("state"));
71
+ return { url, callbackUrl: redirect.href };
72
+ }
73
+ const connect = async () => service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
74
+ return { service, options, state, directory, protection, requests, start, connect };
75
+ }
76
+
77
+ test("Xero exchanges code with Basic authentication, discovers tenants and persists an isolated encrypted grant", async (t) => {
78
+ const f = await fixture(t); const { url, callbackUrl } = await f.start();
79
+ assert.equal(url.origin + url.pathname, "https://login.xero.com/identity/connect/authorize");
80
+ assert.equal(url.searchParams.get("response_type"), "code"); assert.equal(url.searchParams.get("redirect_uri"), callback);
81
+ assert.equal(url.searchParams.get("scope"), defaults.join(" ")); assert.equal(url.searchParams.get("code_challenge_method"), "S256");
82
+ assert.equal(url.searchParams.has("client_secret"), false); assert.equal(url.searchParams.has("openid"), false);
83
+ const connected = await f.service.completeAuthorization({ ...input, callbackUrl });
84
+ assert.equal(connected.status, "connected"); assert.deepEqual(connected.grantedScopes, defaults);
85
+ const code = new URLSearchParams(f.requests[0].init.body);
86
+ assert.equal(code.get("grant_type"), "authorization_code"); assert.equal(code.get("redirect_uri"), callback); assert.ok(code.get("code_verifier"));
87
+ assert.equal(f.requests[1].url.href, "https://api.xero.com/connections");
88
+ for (const file of await readdir(f.directory)) {
89
+ const value = await readFile(path.join(f.directory, file), "utf8");
90
+ for (const secret of ["fixture-client-secret", "fixture-access-1", "fixture-refresh-1"]) assert.equal(value.includes(secret), false);
91
+ }
92
+ const restarted = createConnectionService({ ...f.options, store: createFileConnectionStore({ directory: f.directory, protection: f.protection }) });
93
+ assert.deepEqual(await restarted.status(input), { ...connected, callbackUrl: callback });
94
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "connections.list" }), [connection]);
95
+ for (const owner of [{ ...context, applicationId: "other-app" }, { ...context, subjectId: "other-user" }]) {
96
+ await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "connections.list" }), { code: "connector_reconnect_required" });
97
+ }
98
+ const count = f.requests.length; await restarted.disconnect(input);
99
+ assert.equal(f.requests.length, count); assert.equal((await f.service.status(input)).status, "disconnected");
100
+ });
101
+
102
+ test("Xero configuration supports captured permissions plus read variants and requires a Basic own registration", async (t) => {
103
+ const f = await fixture(t); const config = f.options.configuration;
104
+ assert.equal(xeroProvider.scopes.length, 24); assert.equal(new Set(xeroProvider.scopes.map((s) => s.value)).size, 24);
105
+ for (const accountMode of xeroProvider.accountModes) {
106
+ const copy = structuredClone(config); copy.integrations.xero.accountMode = accountMode;
107
+ copy.integrations.xero.scopes = xeroProvider.scopes.map((s) => s.value);
108
+ assert.deepEqual(validateIntegrationConfiguration(copy, { providers: [xeroProvider] }), copy);
109
+ }
110
+ for (const method of [undefined, "client_secret_post", "none", "unknown"]) {
111
+ const copy = structuredClone(config); copy.registrations.xero.tokenEndpointAuthMethod = method;
112
+ assert.throws(() => validateIntegrationConfiguration(copy, { providers: [xeroProvider] }));
113
+ }
114
+ for (const ref of ["raw-secret", "https://paste.invalid/secret", ""]) {
115
+ const copy = structuredClone(config); copy.registrations.xero.clientSecretRef = ref;
116
+ assert.throws(() => validateIntegrationConfiguration(copy, { providers: [xeroProvider] }));
117
+ }
118
+ const copy = structuredClone(config); copy.registrations.xero.source = "managed";
119
+ delete copy.registrations.xero.clientId; delete copy.registrations.xero.clientSecretRef; delete copy.registrations.xero.callbackUrlRef; delete copy.registrations.xero.tokenEndpointAuthMethod;
120
+ Object.assign(copy.registrations.xero, { serviceUrlRef: "env:GATEWAY", serviceCredentialRef: "env:APP", assignmentRef: "paid" });
121
+ assert.throws(() => createConnectionService({ ...f.options, configuration: copy }), { code: "integration_configuration_invalid" });
122
+ });
123
+
124
+ test("Xero checks current organisation permission before every accounting request and leaves discovery unscoped", async (t) => {
125
+ const f = await fixture(t); await f.connect();
126
+ f.state.connections = [connection, { ...connection, id: otherTenant, tenantId: otherTenant, tenantType: "PRACTICEMANAGER", tenantName: null },
127
+ { ...connection, id: "74305bf3-12e0-45e2-8dc8-e3ec73e3b1f9", tenantId: "74305bf3-12e0-45e2-8dc8-e3ec73e3b1f9", tenantType: "PRACTICE" }];
128
+ const operation = { ...input, operation: "contacts.list", input: { tenantId } };
129
+ assert.deepEqual(await f.service.invoke(operation), f.state.response);
130
+ assert.equal(f.requests.at(-2).url.pathname, "/connections");
131
+ assert.equal(f.requests.at(-1).headers.get("xero-tenant-id"), tenantId);
132
+ for (const denied of [otherTenant, "74305bf3-12e0-45e2-8dc8-e3ec73e3b1f9", "00000000-0000-0000-0000-000000000000"]) {
133
+ const count = f.requests.length;
134
+ await assert.rejects(f.service.invoke({ ...operation, input: { tenantId: denied } }), { code: "connector_permission_denied" });
135
+ assert.equal(f.requests.length, count + 1); assert.equal(f.requests.at(-1).url.pathname, "/connections");
136
+ }
137
+ f.state.connections = []; const count = f.requests.length;
138
+ await assert.rejects(f.service.invoke(operation), { code: "connector_permission_denied" });
139
+ assert.equal(f.requests.length, count + 1);
140
+ assert.deepEqual(await f.service.invoke({ ...input, operation: "connections.list" }), []);
141
+ });
142
+
143
+ test("Xero read operations use exact tenant headers, bounded lightweight pages and encoded search", async (t) => {
144
+ const f = await fixture(t); await f.connect();
145
+ const result = await f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId: tenantId.toUpperCase(), page: 2, pageSize: 1, includeArchived: true, searchTerm: "ACME + & Sons" } });
146
+ assert.deepEqual(result, f.state.response);
147
+ assert.deepEqual(Object.fromEntries(f.requests.at(-1).url.searchParams), { page: "2", pageSize: "1", includeArchived: "true", searchTerm: "ACME + & Sons", summaryOnly: "true" });
148
+ f.state.response = { Invoices: [{ InvoiceID: recordId, InvoiceNumber: "INV-001", Status: "AUTHORISED" }] };
149
+ assert.deepEqual(await f.service.invoke({ ...input, operation: "invoices.list", input: { tenantId } }), f.state.response);
150
+ assert.deepEqual(Object.fromEntries(f.requests.at(-1).url.searchParams), { page: "1", pageSize: "100", summaryOnly: "true" });
151
+ f.state.response = { Organisations: [{ OrganisationID: tenantId, Name: "Fixture organisation", BaseCurrency: "AUD" }] };
152
+ assert.deepEqual(await f.service.invoke({ ...input, operation: "organisation.read", input: { tenantId } }), f.state.response);
153
+ assert.equal(f.requests.at(-1).url.search, ""); assert.equal(f.requests.at(-1).url.pathname, "/api.xro/2.0/Organisation");
154
+ f.state.response = { Contacts: [] };
155
+ assert.deepEqual(await f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId, page: 3 } }), { Contacts: [] });
156
+ });
157
+
158
+ test("Xero rejects bad tenant IDs, paging and arbitrary headers before provider requests", async (t) => {
159
+ const f = await fixture(t); await f.connect(); const count = f.requests.length;
160
+ for (const values of [{}, { tenantId: "" }, { tenantId: tenantId + "\r\nxero: 1" }, { tenantId, page: 0 }, { tenantId, page: 1.1 },
161
+ { tenantId, pageSize: 201 }, { tenantId, pageSize: 0 }, { tenantId, page: 1_000_001 }, { tenantId, url: "https://evil.invalid" },
162
+ { tenantId, headers: { Authorization: "evil" } }, { tenantId, summaryOnly: false }, { tenantId, searchTerm: "x".repeat(256) }]) {
163
+ await assert.rejects(f.service.invoke({ ...input, operation: "contacts.list", input: values }), { code: "connector_input_invalid" });
164
+ }
165
+ await assert.rejects(f.service.invoke({ ...input, operation: "connections.list", input: { authEventId: "untrusted" } }), { code: "connector_input_invalid" });
166
+ assert.equal(f.requests.length, count);
167
+ });
168
+
169
+ test("Xero serializes refresh rotation, persists reduced grants and retains new tokens after API failure", async (t) => {
170
+ const f = await fixture(t); await f.connect(); f.state.time += 40_000;
171
+ await Promise.all([f.service.invoke({ ...input, operation: "connections.list" }), f.service.invoke({ ...input, operation: "connections.list" })]);
172
+ assert.equal(f.state.tokenCount, 2);
173
+ f.state.tokenScope = "offline_access accounting.contacts.read"; f.state.time += 40_000; f.state.status = 429;
174
+ await assert.rejects(f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId } }), { code: "connector_rate_limited" });
175
+ assert.equal(f.state.tokenCount, 3); f.state.status = 200;
176
+ const restarted = createConnectionService(f.options);
177
+ assert.deepEqual((await restarted.status(input)).grantedScopes, ["offline_access", "accounting.contacts.read"]);
178
+ await restarted.invoke({ ...input, operation: "contacts.list", input: { tenantId } });
179
+ assert.equal(f.state.tokenCount, 3);
180
+ const count = f.requests.length;
181
+ await assert.rejects(restarted.invoke({ ...input, operation: "invoices.list", input: { tenantId } }), { code: "connector_scope_missing" });
182
+ assert.equal(f.requests.length, count);
183
+ f.state.time += 40_000; f.state.tokenStatus = 400;
184
+ await assert.rejects(restarted.invoke({ ...input, operation: "connections.list" }), { code: "connector_reconnect_required" });
185
+ assert.equal((await restarted.status(input)).status, "reconnect-required");
186
+ });
187
+
188
+ test("Xero consent cancellation, denied scopes and registration changes cannot reuse attempts or grants", async (t) => {
189
+ const f = await fixture(t); const cancelled = await f.start();
190
+ await f.service.cancelAuthorization({ ...input, state: cancelled.url.searchParams.get("state") });
191
+ await assert.rejects(f.service.completeAuthorization({ ...input, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
192
+ const denied = await f.start(); const deniedUrl = new URL(denied.callbackUrl); deniedUrl.searchParams.delete("code"); deniedUrl.searchParams.set("error", "access_denied");
193
+ await assert.rejects(f.service.completeAuthorization({ ...input, callbackUrl: deniedUrl.href }), { code: "connector_consent_denied" });
194
+ assert.equal(f.requests.length, 0);
195
+ f.state.tokenScope = "offline_access"; await f.connect();
196
+ const count = f.requests.length;
197
+ await assert.rejects(f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId } }), { code: "connector_scope_missing" });
198
+ assert.equal(f.requests.length, count);
199
+ const pending = await f.start(); const changed = structuredClone(f.options.configuration); changed.registrations.xero.clientId = "other-client";
200
+ const service = createConnectionService({ ...f.options, configuration: changed });
201
+ assert.equal((await service.status(input)).status, "reconnect-required");
202
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: pending.callbackUrl }), { code: "connector_attempt_invalid" });
203
+ assert.equal(f.requests.length, count);
204
+ });
205
+
206
+ test("Xero accepts read-only or full accounting scopes and applies host authorization before references or HTTP", async (t) => {
207
+ const f = await fixture(t); const config = structuredClone(f.options.configuration);
208
+ config.integrations.xero.scopes = ["offline_access", "accounting.contacts", "accounting.invoices", "accounting.settings"];
209
+ f.state.tokenScope = config.integrations.xero.scopes.join(" ");
210
+ const service = createConnectionService({ ...f.options, configuration: config });
211
+ const started = await service.beginAuthorization(input);
212
+ await service.completeAuthorization({ ...input, callbackUrl: `${callback}?code=fixture&state=${new URL(started.authorizationUrl).searchParams.get("state")}` });
213
+ await service.invoke({ ...input, operation: "contacts.list", input: { tenantId } });
214
+ f.state.deny = true; const count = f.requests.length;
215
+ await assert.rejects(service.invoke({ ...input, operation: "contacts.list", input: { tenantId } }), /Denied by host/);
216
+ await assert.rejects(service.beginAuthorization(input), /Denied by host/); assert.equal(f.requests.length, count);
217
+ });
218
+
219
+ test("Xero rejects malformed connections, foreign organisation results and overlong pages", async (t) => {
220
+ const f = await fixture(t);
221
+ for (const connections of [{}, [{}], [{ ...connection, id: null }], [{ ...connection, tenantId: "bad" }], [{ ...connection, tenantName: {} }]]) {
222
+ f.state.connections = connections;
223
+ await assert.rejects(f.connect(), { code: "connector_response_invalid" });
224
+ assert.equal((await f.service.status(input)).status, "disconnected");
225
+ }
226
+ f.state.connections = [connection]; await f.connect();
227
+ for (const response of [{ Contacts: [{}] }, { Contacts: {} }, { ErrorNumber: 10, Message: "fixture-client-secret" }, { Contacts: [{ ContactID: recordId }, { ContactID: recordId }] }]) {
228
+ f.state.response = response;
229
+ await assert.rejects(f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId, pageSize: 1 } }), { code: "connector_response_invalid" });
230
+ }
231
+ f.state.response = { Organisations: [{ OrganisationID: otherTenant }] };
232
+ await assert.rejects(f.service.invoke({ ...input, operation: "organisation.read", input: { tenantId } }), { code: "connector_response_invalid" });
233
+ f.state.connections = [{ ...connection, tenantId: null }]; const count = f.requests.length;
234
+ await assert.rejects(f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId } }), { code: "connector_response_invalid" });
235
+ assert.equal(f.requests.length, count + 1);
236
+ });
237
+
238
+ test("Xero errors are redacted, never retried and require reconnect after authentication failure", async (t) => {
239
+ const f = await fixture(t); await f.connect();
240
+ for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"], [401, "connector_reconnect_required"]]) {
241
+ f.state.status = status; f.state.connections = { Message: "fixture-client-secret" }; const count = f.requests.length;
242
+ await assert.rejects(f.service.invoke({ ...input, operation: "connections.list" }), (error) => {
243
+ assert.equal(error.code, code); assert.equal(JSON.stringify(error).includes("fixture-client-secret"), false); return true;
244
+ });
245
+ assert.equal(f.requests.length, count + 1);
246
+ }
247
+ assert.equal((await f.service.status(input)).status, "reconnect-required");
248
+ });
249
+
250
+ test("Xero interruption and timeout stop before the accounting call and keep the connection usable", async (t) => {
251
+ const f = await fixture(t); await f.connect(); f.state.stall = true;
252
+ const service = createConnectionService({ ...f.options, providers: [{ ...xeroProvider, requestTimeoutMs: 35 }] });
253
+ const count = f.requests.length;
254
+ await assert.rejects(service.invoke({ ...input, operation: "contacts.list", input: { tenantId } }));
255
+ assert.equal(f.requests.length, count + 1); assert.equal(f.requests.at(-1).url.pathname, "/connections");
256
+ const controller = new AbortController();
257
+ const pending = f.service.invoke({ ...input, operation: "contacts.list", input: { tenantId }, signal: controller.signal });
258
+ const timer = setTimeout(() => controller.abort(), 35);
259
+ await assert.rejects(pending); clearTimeout(timer);
260
+ assert.equal(f.requests.length, count + 2); assert.equal((await f.service.status(input)).status, "connected");
261
+ });
262
+
263
+ test("Xero payment pages preserve amounts and invoice references and enforce current tenant access", async (t) => {
264
+ const f = await fixture(t);
265
+ const scopes = [...defaults, "accounting.payments.read"];
266
+ f.options.configuration.integrations.xero.scopes = scopes;
267
+ f.state.tokenScope = scopes.join(" ");
268
+ const service = createConnectionService(f.options);
269
+ const { authorizationUrl } = await service.beginAuthorization(input);
270
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
271
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
272
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
273
+ f.state.response = { Payments: [{ PaymentID: recordId, Amount: 12.34, Status: "AUTHORISED", CurrencyRate: 1.23,
274
+ Invoice: { InvoiceID: otherTenant, CurrencyCode: "AUD" }, Account: { AccountID: recordId } }] };
275
+ const args = { ...input, operation: "payments.list", input: { tenantId, page: 2, pageSize: 5 } };
276
+ assert.deepEqual(await service.invoke(args), f.state.response);
277
+ assert.equal(f.requests.at(-1).url.pathname, "/api.xro/2.0/Payments");
278
+ assert.equal(f.requests.at(-1).url.search, "?page=2&pageSize=5");
279
+ assert.equal(f.requests.at(-2).url.pathname, "/connections");
280
+ const before = f.requests.length;
281
+ await assert.rejects(service.invoke({ ...args, input: { tenantId, pageSize: 101 } }));
282
+ assert.equal(f.requests.length, before);
283
+ f.state.response = { Payments: Array(6).fill(f.state.response.Payments[0]) };
284
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
285
+ f.state.response = { Payments: [{ PaymentID: recordId, Amount: "unknown", Status: "AUTHORISED" }] };
286
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
287
+ f.state.response = { Payments: [] }; assert.deepEqual(await service.invoke(args), f.state.response);
288
+ f.state.connections = [];
289
+ const count = f.requests.length;
290
+ await assert.rejects(service.invoke(args), { code: "connector_permission_denied" });
291
+ assert.equal(f.requests.length, count + 1);
292
+ });
293
+
294
+ test("Xero financial reports retain nested rows and decimal text with explicit dates and report scopes", async (t) => {
295
+ const f = await fixture(t);
296
+ const scopes = [...defaults, "accounting.reports.profitandloss.read", "accounting.reports.balancesheet.read"];
297
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
298
+ const service = createConnectionService(f.options);
299
+ const { authorizationUrl } = await service.beginAuthorization(input);
300
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
301
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
302
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
303
+ const cases = [
304
+ ["reports.profitAndLoss", "ProfitAndLoss", { fromDate: "2026-01-01", toDate: "2026-08-31", periods: 2, timeframe: "MONTH", paymentsOnly: false }],
305
+ ["reports.balanceSheet", "BalanceSheet", { date: "2026-08-31", standardLayout: true }]
306
+ ];
307
+ for (const [operation, name, fields] of cases) {
308
+ f.state.response = { Reports: [{ ReportID: name, ReportTitles: [name], Rows: [
309
+ { RowType: "Header", Cells: [{ Value: "Account" }, { Value: "August" }] },
310
+ { RowType: "Section", Title: "Revenue", Rows: [{ RowType: "Row", Cells: [{ Value: "Sales" }, { Value: "9007199254740993.12", Attributes: [{ Id: "account", Value: recordId }] }] }] }
311
+ ] }] };
312
+ const args = { ...input, operation, input: { tenantId, ...fields } };
313
+ assert.deepEqual(await service.invoke(args), f.state.response);
314
+ const request = f.requests.at(-1);
315
+ assert.equal(request.url.pathname, `/api.xro/2.0/Reports/${name}`);
316
+ assert.deepEqual(Object.fromEntries(request.url.searchParams), Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, String(value)])));
317
+ const count = f.requests.length;
318
+ await assert.rejects(service.invoke({ ...args, input: { ...args.input, periods: 13 } }));
319
+ assert.equal(f.requests.length, count);
320
+ f.state.response = { Reports: [{ ReportID: name, Rows: [] }] };
321
+ assert.deepEqual(await service.invoke(args), f.state.response);
322
+ f.state.response = { Reports: [{ ReportID: name, Rows: [{ RowType: "Row", Cells: [{ Value: 42 }] }] }] };
323
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
324
+ }
325
+ const count = f.requests.length;
326
+ for (const fields of [{ fromDate: "2026-02-30", toDate: "2026-03-31" }, { fromDate: "2026-10-01", toDate: "2026-09-01" }, {}]) {
327
+ await assert.rejects(service.invoke({ ...input, operation: "reports.profitAndLoss", input: { tenantId, ...fields } }));
328
+ }
329
+ assert.equal(f.requests.length, count);
330
+ });
331
+
332
+ test("Xero individual contacts and invoices preserve detail and reject foreign records", async (t) => {
333
+ const f = await fixture(t); await f.connect();
334
+ for (const [operation, resource, idName, detail] of [
335
+ ["contacts.get", "Contacts", "ContactID", { Addresses: [{ AddressType: "STREET", City: "Perth" }], ContactPersons: [{ FirstName: "Sam" }], Balances: { AccountsReceivable: { Outstanding: 12.34 } } }],
336
+ ["invoices.get", "Invoices", "InvoiceID", { Type: "ACCREC", CurrencyCode: "AUD", AmountDue: 12.34, LineItems: [{ Description: "Groom", Quantity: 1, UnitAmount: 12.34, AccountCode: "200" }] }]
337
+ ]) {
338
+ const args = { ...input, operation, input: { tenantId, id: recordId.toUpperCase() } };
339
+ f.state.response = { [resource]: [{ [idName]: recordId, ...detail }] };
340
+ assert.deepEqual(await f.service.invoke(args), f.state.response);
341
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}/${recordId}`);
342
+ assert.equal(f.requests.at(-1).url.search, "");
343
+ const count = f.requests.length;
344
+ await assert.rejects(f.service.invoke({ ...args, input: { tenantId, id: "../other" } }));
345
+ assert.equal(f.requests.length, count);
346
+ f.state.response = { [resource]: [{ [idName]: otherTenant, ...detail }] };
347
+ await assert.rejects(f.service.invoke(args), { code: "connector_response_invalid" });
348
+ f.state.response = { [resource]: [] };
349
+ await assert.rejects(f.service.invoke(args), { code: "connector_response_invalid" });
350
+ f.state.response = { [resource]: [{ [idName]: 42 }] };
351
+ await assert.rejects(f.service.invoke(args));
352
+ }
353
+ });
354
+
355
+ test("Xero contact writes use stable idempotency and never forward a write to tenant discovery", async (t) => {
356
+ const f = await fixture(t);
357
+ const scopes = [...defaults, "accounting.contacts"];
358
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
359
+ const service = createConnectionService(f.options);
360
+ const { authorizationUrl } = await service.beginAuthorization(input);
361
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code"); url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
362
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
363
+ f.state.response = { Contacts: [{ ContactID: recordId, Name: "New customer" }] };
364
+ for (const update of [false, true]) {
365
+ const args = { ...input, operation: update ? "contacts.update" : "contacts.create", input: { tenantId, idempotencyKey: "contact-request-1", Name: "New customer", ...(update ? { id: recordId } : {}) } };
366
+ assert.deepEqual(await service.invoke(args), f.state.response);
367
+ const [lookup, write] = f.requests.slice(-2);
368
+ assert.equal(lookup.url.pathname, "/connections"); assert.equal(lookup.init.method, "GET");
369
+ assert.equal(lookup.init.body, undefined); assert.equal(lookup.headers.has("idempotency-key"), false);
370
+ assert.equal(write.init.method, update ? "POST" : "PUT");
371
+ assert.equal(write.headers.get("idempotency-key"), "contact-request-1");
372
+ assert.deepEqual(JSON.parse(write.init.body), { Contacts: [{ Name: "New customer" }] });
373
+ f.state.response.Contacts[0].HasValidationErrors = true;
374
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
375
+ delete f.state.response.Contacts[0].HasValidationErrors;
376
+ }
377
+ const before = f.requests.length;
378
+ await assert.rejects(service.invoke({ ...input, operation: "contacts.create", input: { tenantId, Name: "No request key" } }));
379
+ await assert.rejects(service.invoke({ ...input, operation: "contacts.update", input: { tenantId, id: recordId, idempotencyKey: "empty-change" } }));
380
+ assert.equal(f.requests.length, before);
381
+ });
382
+
383
+ test("Xero records one deliberate invoice payment with an explicit account and no automatic retry", async (t) => {
384
+ const f = await fixture(t);
385
+ const scopes = [...defaults, "accounting.payments"];
386
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
387
+ const service = createConnectionService(f.options);
388
+ const { authorizationUrl } = await service.beginAuthorization(input);
389
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code"); url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
390
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
391
+ f.state.response = { Accounts: [{ AccountID: recordId, Type: "BANK", CurrencyCode: "AUD", EnablePaymentsToAccount: true }] };
392
+ assert.deepEqual(await service.invoke({ ...input, operation: "accounts.list", input: { tenantId } }), f.state.response);
393
+ assert.equal(f.requests.at(-1).url.search, "");
394
+ const values = { tenantId, idempotencyKey: "invoice-payment-1", invoiceId: recordId, accountId: otherTenant, Date: "2026-09-13", Amount: 12.34, CurrencyRate: 1.2345, Reference: "Receipt 123" };
395
+ const args = { ...input, operation: "payments.create", input: values };
396
+ f.state.response = { Payments: [{ PaymentID: recordId, Amount: 12.34, Status: "AUTHORISED" }] };
397
+ assert.deepEqual(await service.invoke(args), f.state.response);
398
+ const [lookup, write] = f.requests.slice(-2);
399
+ assert.equal(lookup.init.method, "GET"); assert.equal(lookup.init.body, undefined);
400
+ assert.equal(lookup.headers.has("idempotency-key"), false);
401
+ assert.equal(write.init.method, "PUT"); assert.equal(write.headers.get("idempotency-key"), values.idempotencyKey);
402
+ assert.deepEqual(JSON.parse(write.init.body), { Payments: [{ Date: values.Date, Amount: 12.34, CurrencyRate: 1.2345, Reference: values.Reference, Invoice: { InvoiceID: recordId }, Account: { AccountID: otherTenant } }] });
403
+ const count = f.requests.length;
404
+ for (const patch of [{ Amount: 0 }, { Amount: -1 }, { Amount: Infinity }, { Date: "2026-02-30" }, { accountId: "bad" }, { IsReconciled: true }]) {
405
+ await assert.rejects(service.invoke({ ...args, input: { ...values, ...patch } }));
406
+ }
407
+ assert.equal(f.requests.length, count);
408
+ f.state.response.Payments[0].ValidationErrors = [{ Message: "Amount exceeds balance" }];
409
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
410
+ f.state.response = { Payments: [{ PaymentID: recordId, Amount: 12.34 }] };
411
+ f.state.accountingStatus = 500; const before = f.requests.length;
412
+ await assert.rejects(service.invoke(args), { code: "connector_provider_failed" });
413
+ assert.equal(f.requests.length, before + 2);
414
+ });
415
+
416
+ test("Xero invoice writes retain explicit currency, tax basis and line precision with reviewed status transitions", async (t) => {
417
+ const f = await fixture(t);
418
+ const scopes = [...defaults, "accounting.invoices"];
419
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
420
+ const service = createConnectionService(f.options);
421
+ const { authorizationUrl } = await service.beginAuthorization(input);
422
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code"); url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
423
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
424
+ const fields = { Type: "ACCREC", contactId: otherTenant, Date: "2026-09-13", DueDate: "2026-09-30", CurrencyCode: "AUD", LineAmountTypes: "Exclusive",
425
+ LineItems: [{ Description: "Grooming", Quantity: 2, UnitAmount: 12.3456, AccountCode: "200", TaxType: "OUTPUT", DiscountRate: 10 }] };
426
+ const args = { ...input, operation: "invoices.create", input: { tenantId, idempotencyKey: "invoice-1", ...fields } };
427
+ f.state.response = { Invoices: [{ InvoiceID: recordId, Status: "DRAFT", CurrencyCode: "AUD", LineItems: fields.LineItems }] };
428
+ assert.deepEqual(await service.invoke(args), f.state.response);
429
+ const write = f.requests.at(-1);
430
+ assert.equal(write.init.method, "PUT"); assert.equal(write.url.search, "?unitdp=4");
431
+ const { contactId, ...expected } = fields;
432
+ assert.deepEqual(JSON.parse(write.init.body), { Invoices: [{ ...expected, Status: "DRAFT", Contact: { ContactID: contactId } }] });
433
+ const update = { ...input, operation: "invoices.update", input: { tenantId, idempotencyKey: "invoice-authorise-1", id: recordId, Status: "AUTHORISED" } };
434
+ f.state.response.Invoices[0].Status = "AUTHORISED";
435
+ assert.deepEqual(await service.invoke(update), f.state.response);
436
+ assert.equal(f.requests.at(-1).init.method, "POST");
437
+ assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { Invoices: [{ Status: "AUTHORISED" }] });
438
+ const count = f.requests.length;
439
+ for (const patch of [{ Status: "PAID" }, { CurrencyCode: "dollars" }, { LineItems: [] }, { Type: "ACCPAY" }, { Date: "2026-02-30" }]) {
440
+ await assert.rejects(service.invoke({ ...args, input: { ...args.input, ...patch } }));
441
+ }
442
+ assert.equal(f.requests.length, count);
443
+ f.state.response.Invoices[0].ValidationErrors = [{ Message: "Invalid account code" }];
444
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
445
+ f.state.accountingStatus = 500; const before = f.requests.length;
446
+ await assert.rejects(service.invoke(args), { code: "connector_provider_failed" });
447
+ assert.equal(f.requests.length, before + 2);
448
+ });
449
+
450
+ test("Xero settings discovery preserves native tax and tracking data without summary filtering", async t => {
451
+ const f = await fixture(t); await f.connect();
452
+ for (const [operation, resource, fields, row] of [
453
+ ["currencies.list", "Currencies", {}, { Code: "AUD", Description: "Australian Dollar" }],
454
+ ["taxRates.list", "TaxRates", {}, { TaxType: "OUTPUT", Name: "GST", EffectiveRate: 10, TaxComponents: [{ Name: "GST", Rate: 10 }] }],
455
+ ["trackingCategories.list", "TrackingCategories", { includeArchived: true }, { TrackingCategoryID: recordId, Name: "Location", Options: [{ TrackingOptionID: otherTenant, Name: "Perth" }] }]
456
+ ]) {
457
+ const args = { ...input, operation, input: { tenantId, ...fields } };
458
+ f.state.response = { [resource]: [row] };
459
+ assert.deepEqual(await f.service.invoke(args), f.state.response);
460
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}`);
461
+ assert.deepEqual(Object.fromEntries(f.requests.at(-1).url.searchParams), Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, String(v)])));
462
+ f.state.response = { [resource]: [{}] };
463
+ await assert.rejects(f.service.invoke(args), { code: "connector_response_invalid" });
464
+ f.state.response = { [resource]: [] };
465
+ assert.deepEqual(await f.service.invoke(args), f.state.response);
466
+ }
467
+ });
468
+
469
+ test("Xero aged, executive, bank and trial reports use individual scopes and explicit reporting dates", async t => {
470
+ const f = await fixture(t);
471
+ const cases = [
472
+ ["reports.agedReceivables", "AgedReceivablesByContact", "aged", { contactId: recordId, date: "2026-08-31" }],
473
+ ["reports.agedPayables", "AgedPayablesByContact", "aged", { contactId: recordId, date: "2026-08-31", fromDate: "2026-01-01", toDate: "2026-08-31" }],
474
+ ["reports.executiveSummary", "ExecutiveSummary", "executivesummary", { date: "2026-08-31" }],
475
+ ["reports.bankSummary", "BankSummary", "banksummary", { fromDate: "2026-08-01", toDate: "2026-08-31" }],
476
+ ["reports.trialBalance", "TrialBalance", "trialbalance", { date: "2026-08-31", paymentsOnly: true }]
477
+ ];
478
+ const scopes = [...defaults, ...new Set(cases.map(([, , scope]) => `accounting.reports.${scope}.read`))];
479
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
480
+ const service = createConnectionService(f.options);
481
+ const { authorizationUrl } = await service.beginAuthorization(input);
482
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
483
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
484
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
485
+ for (const [operation, resource, , fields] of cases) {
486
+ f.state.response = { Reports: [{ ReportID: resource, Rows: [{ RowType: "Row", Cells: [{ Value: "123.45" }] }] }] };
487
+ const args = { ...input, operation, input: { tenantId, ...fields } };
488
+ assert.deepEqual(await service.invoke(args), f.state.response);
489
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/Reports/${resource}`);
490
+ assert.deepEqual(Object.fromEntries(f.requests.at(-1).url.searchParams), Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, String(v)])));
491
+ const count = f.requests.length;
492
+ await assert.rejects(service.invoke({ ...args, input: { ...args.input, date: "2026-02-30" } }));
493
+ assert.equal(f.requests.length, count);
494
+ }
495
+ const limited = await fixture(t); await limited.connect();
496
+ const count = limited.requests.length;
497
+ await assert.rejects(limited.service.invoke({ ...input, operation: "reports.trialBalance", input: { tenantId, date: "2026-08-31" } }));
498
+ assert.equal(limited.requests.length, count);
499
+ });
500
+
501
+ test("Xero budgets preserve account balances and enforce date ranges, record identity and report periods", async t => {
502
+ const f = await fixture(t);
503
+ const scopes = [...defaults, "accounting.budgets.read", "accounting.reports.budgetsummary.read"];
504
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
505
+ const service = createConnectionService(f.options);
506
+ const { authorizationUrl } = await service.beginAuthorization(input);
507
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
508
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
509
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
510
+ const budget = { BudgetID: recordId, Type: "OVERALL", BudgetLines: [{ AccountID: otherTenant, AccountCode: "200",
511
+ BudgetBalances: [{ Period: "2026-08", Amount: 123.45, Notes: "Grooming" }] }] };
512
+ for (const operation of ["budgets.list", "budgets.get"]) {
513
+ f.state.response = { Budgets: [budget] };
514
+ const fields = { tenantId, DateFrom: "2026-01-01", DateTo: "2026-12-31", ...(operation === "budgets.get" ? { id: recordId.toUpperCase() } : {}) };
515
+ const args = { ...input, operation, input: fields };
516
+ assert.deepEqual(await service.invoke(args), f.state.response);
517
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/Budgets${operation === "budgets.get" ? `/${recordId}` : ""}`);
518
+ assert.equal(f.requests.at(-1).url.search, "?DateFrom=2026-01-01&DateTo=2026-12-31");
519
+ const count = f.requests.length;
520
+ await assert.rejects(service.invoke({ ...args, input: { ...fields, DateTo: "2025-12-31" } }));
521
+ assert.equal(f.requests.length, count);
522
+ if (operation === "budgets.get") {
523
+ f.state.response = { Budgets: [{ ...budget, BudgetID: otherTenant }] };
524
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
525
+ } else {
526
+ f.state.response = { Budgets: [] };
527
+ assert.deepEqual(await service.invoke(args), f.state.response);
528
+ }
529
+ }
530
+ const args = { ...input, operation: "reports.budgetSummary", input: { tenantId, date: "2026-08-31", periods: 4, timeframe: 3 } };
531
+ f.state.response = { Reports: [{ ReportID: "BudgetSummary", Rows: [{ RowType: "Row", Cells: [{ Value: "123.45" }] }] }] };
532
+ assert.deepEqual(await service.invoke(args), f.state.response);
533
+ assert.equal(f.requests.at(-1).url.search, "?date=2026-08-31&periods=4&timeframe=3");
534
+ const count = f.requests.length;
535
+ for (const fields of [{ timeframe: "QUARTER" }, { timeframe: 2 }, { periods: 13 }, { date: "2026-02-30" }]) {
536
+ await assert.rejects(service.invoke({ ...args, input: { ...args.input, ...fields } }));
537
+ }
538
+ assert.equal(f.requests.length, count);
539
+ });
540
+
541
+ test("Xero bank and manual journal reads preserve native detail and enforce fixed page and record boundaries", async t => {
542
+ const f = await fixture(t);
543
+ const scopes = [...defaults, "accounting.banktransactions.read", "accounting.manualjournals.read"];
544
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
545
+ const service = createConnectionService(f.options);
546
+ const { authorizationUrl } = await service.beginAuthorization(input);
547
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
548
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
549
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
550
+ for (const [prefix, resource, idName, detail] of [
551
+ ["bankTransactions", "BankTransactions", "BankTransactionID", { Type: "SPEND", CurrencyCode: "AUD", Total: 12.34, LineItems: [{ AccountCode: "400", UnitAmount: 12.34 }], BankAccount: { AccountID: otherTenant } }],
552
+ ["manualJournals", "ManualJournals", "ManualJournalID", { Narration: "Adjustment", Status: "DRAFT", JournalLines: [{ AccountCode: "400", LineAmount: 12.34 }, { AccountCode: "200", LineAmount: -12.34 }] }]
553
+ ]) {
554
+ const row = { [idName]: recordId, ...detail };
555
+ const list = { ...input, operation: `${prefix}.list`, input: { tenantId, page: 2 } };
556
+ f.state.response = { [resource]: [row] };
557
+ assert.deepEqual(await service.invoke(list), f.state.response);
558
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}`);
559
+ assert.equal(f.requests.at(-1).url.search, "?page=2");
560
+ const count = f.requests.length;
561
+ await assert.rejects(service.invoke({ ...list, input: { tenantId, pageSize: 200 } }));
562
+ assert.equal(f.requests.length, count);
563
+ f.state.response = { [resource]: Array(101).fill(row) };
564
+ await assert.rejects(service.invoke(list), { code: "connector_response_invalid" });
565
+ const get = { ...input, operation: `${prefix}.get`, input: { tenantId, id: recordId } };
566
+ f.state.response = { [resource]: [row] };
567
+ assert.deepEqual(await service.invoke(get), f.state.response);
568
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}/${recordId}`);
569
+ assert.equal(f.requests.at(-1).url.search, "");
570
+ f.state.response = { [resource]: [{ ...row, [idName]: otherTenant }] };
571
+ await assert.rejects(service.invoke(get), { code: "connector_response_invalid" });
572
+ f.state.response = { [resource]: [] };
573
+ assert.deepEqual(await service.invoke(list), f.state.response);
574
+ }
575
+ });
576
+
577
+ test("Xero manual journal writes default to draft and retain deliberate posting, signed lines and idempotency", async t => {
578
+ const f = await fixture(t);
579
+ const scopes = [...defaults, "accounting.manualjournals"];
580
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
581
+ const service = createConnectionService(f.options);
582
+ const { authorizationUrl } = await service.beginAuthorization(input);
583
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
584
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
585
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
586
+ const fields = { tenantId, idempotencyKey: "journal-1", Narration: "Reclassification", Date: "2026-08-31", LineAmountTypes: "NoTax",
587
+ JournalLines: [{ AccountCode: "400", LineAmount: 12.34, Tracking: [{ Name: "Location", Option: "Perth" }] }, { AccountCode: "200", LineAmount: -12.34 }] };
588
+ const args = { ...input, operation: "manualJournals.create", input: fields };
589
+ f.state.response = { ManualJournals: [{ ManualJournalID: recordId, Status: "DRAFT" }] };
590
+ assert.deepEqual(await service.invoke(args), f.state.response);
591
+ assert.equal(f.requests.at(-1).init.method, "PUT");
592
+ assert.equal(f.requests.at(-1).headers.get("idempotency-key"), "journal-1");
593
+ const { tenantId: unusedTenant, idempotencyKey: unusedKey, ...journal } = fields;
594
+ assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { ManualJournals: [{ ...journal, Status: "DRAFT" }] });
595
+ assert.equal(f.requests.at(-2).init.method, "GET");
596
+ assert.equal(f.requests.at(-2).init.body, undefined);
597
+ const update = { ...input, operation: "manualJournals.update", input: { tenantId, idempotencyKey: "journal-post-1", id: recordId, Status: "POSTED" } };
598
+ await service.invoke(update);
599
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/ManualJournals/${recordId}`);
600
+ assert.equal(f.requests.at(-1).init.method, "POST");
601
+ assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { ManualJournals: [{ Status: "POSTED", ManualJournalID: recordId }] });
602
+ const count = f.requests.length;
603
+ for (const patch of [{ Status: "AUTHORISED" }, { JournalLines: [] }, { Date: "2026-02-30" }, { JournalLines: [{ AccountCode: "400", LineAmount: Infinity }, fields.JournalLines[1]] }]) {
604
+ await assert.rejects(service.invoke({ ...args, input: { ...fields, ...patch } }));
605
+ }
606
+ assert.equal(f.requests.length, count);
607
+ f.state.response = { ManualJournals: [{ ManualJournalID: recordId, ValidationErrors: [{ Message: "Journal is not balanced" }] }] };
608
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
609
+ f.state.accountingStatus = 500;
610
+ const beforeFailure = f.requests.length;
611
+ await assert.rejects(service.invoke(args));
612
+ assert.equal(f.requests.length, beforeFailure + 2);
613
+ });
614
+
615
+ test("Xero spend and receive writes use explicit bank/contact references and never reconcile or retry automatically", async t => {
616
+ const f = await fixture(t);
617
+ const scopes = [...defaults, "accounting.banktransactions"];
618
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
619
+ const service = createConnectionService(f.options);
620
+ const { authorizationUrl } = await service.beginAuthorization(input);
621
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
622
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
623
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
624
+ const fields = { tenantId, idempotencyKey: "spend-1", Type: "SPEND", contactId: recordId, bankAccountId: otherTenant,
625
+ Date: "2026-08-31", LineAmountTypes: "Exclusive", Reference: "Supplies", CurrencyRate: 1.2345,
626
+ LineItems: [{ Description: "Supplies", Quantity: 2, UnitAmount: 12.3456, AccountCode: "400", TaxType: "INPUT" }] };
627
+ f.state.response = { BankTransactions: [{ BankTransactionID: recordId, Status: "AUTHORISED", CurrencyCode: "AUD" }] };
628
+ const args = { ...input, operation: "bankTransactions.create", input: fields };
629
+ assert.deepEqual(await service.invoke(args), f.state.response);
630
+ assert.equal(f.requests.at(-1).init.method, "PUT");
631
+ assert.equal(f.requests.at(-1).url.search, "?unitdp=4");
632
+ assert.equal(f.requests.at(-1).headers.get("idempotency-key"), "spend-1");
633
+ const { tenantId: unusedTenant, idempotencyKey: unusedKey, contactId, bankAccountId, ...transaction } = fields;
634
+ assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { BankTransactions: [{ ...transaction, Contact: { ContactID: contactId }, BankAccount: { AccountID: bankAccountId } }] });
635
+ await service.invoke({ ...args, input: { ...fields, Type: "RECEIVE", idempotencyKey: "receive-1" } });
636
+ assert.equal(JSON.parse(f.requests.at(-1).init.body).BankTransactions[0].Type, "RECEIVE");
637
+ const update = { ...input, operation: "bankTransactions.update", input: { tenantId, id: recordId, idempotencyKey: "delete-1", Status: "DELETED" } };
638
+ await service.invoke(update);
639
+ assert.equal(f.requests.at(-1).init.method, "POST");
640
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/BankTransactions/${recordId}`);
641
+ assert.deepEqual(JSON.parse(f.requests.at(-1).init.body), { BankTransactions: [{ Status: "DELETED", BankTransactionID: recordId }] });
642
+ const count = f.requests.length;
643
+ for (const patch of [{ IsReconciled: true }, { Type: "SPEND-TRANSFER" }, { CurrencyCode: "USD" }, { LineItems: [] }, { bankAccountId: "bad" }]) {
644
+ await assert.rejects(service.invoke({ ...args, input: { ...fields, ...patch } }));
645
+ }
646
+ assert.equal(f.requests.length, count);
647
+ f.state.response = { BankTransactions: [{ BankTransactionID: recordId, ValidationErrors: [{ Message: "Bank account invalid" }] }] };
648
+ await assert.rejects(service.invoke(args), { code: "connector_response_invalid" });
649
+ f.state.accountingStatus = 500;
650
+ const before = f.requests.length;
651
+ await assert.rejects(service.invoke(args)); assert.equal(f.requests.length, before + 2);
652
+ });
653
+
654
+ test("Xero attachments use parent-bound file paths, binary bodies and checked organisation access", async t => {
655
+ const f = await fixture(t);
656
+ const scopes = [...defaults, "accounting.attachments"];
657
+ f.options.configuration.integrations.xero.scopes = scopes; f.state.tokenScope = scopes.join(" ");
658
+ const service = createConnectionService(f.options);
659
+ const { authorizationUrl } = await service.beginAuthorization(input);
660
+ const url = new URL(callback); url.searchParams.set("code", "fixture-code");
661
+ url.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state"));
662
+ await service.completeAuthorization({ ...input, callbackUrl: url.href });
663
+ const file = { AttachmentID: otherTenant, FileName: "receipt #1.pdf", ContentLength: 5 };
664
+ for (const resource of ["Contacts", "Invoices", "BankTransactions", "ManualJournals"]) {
665
+ const fields = { tenantId, resource, id: recordId };
666
+ f.state.response = { Attachments: [file] };
667
+ assert.deepEqual(await service.invoke({ ...input, operation: "attachments.list", input: fields }), f.state.response);
668
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}/${recordId}/Attachments`);
669
+ for (const [operation, method] of [["attachments.upload", "PUT"], ["attachments.replace", "POST"]]) {
670
+ const args = { ...input, operation, input: { ...fields, idempotencyKey: "file-1", filename: file.FileName, contentBase64: "aGVsbG8=" } };
671
+ assert.deepEqual(await service.invoke(args), f.state.response);
672
+ const sent = f.requests.at(-1);
673
+ assert.equal(sent.init.method, method);
674
+ assert.equal(sent.url.pathname, `/api.xro/2.0/${resource}/${recordId}/Attachments/receipt%20%231.pdf`);
675
+ assert.deepEqual(sent.init.body, Buffer.from("hello"));
676
+ assert.equal(sent.headers.get("content-type"), "application/octet-stream");
677
+ assert.equal(sent.init.credentials, "omit");
678
+ assert.equal(f.requests.at(-2).init.body, undefined);
679
+ assert.equal(f.requests.at(-2).headers.has("idempotency-key"), false);
680
+ const count = f.requests.length;
681
+ for (const patch of [{ filename: "../secret" }, { filename: ".." }, { contentBase64: "not base64" }, { resource: "Unknown" }]) {
682
+ await assert.rejects(service.invoke({ ...args, input: { ...args.input, ...patch } }));
683
+ }
684
+ assert.equal(f.requests.length, count);
685
+ }
686
+ f.state.binaryResponse = Buffer.from("hello");
687
+ const download = { ...input, operation: "attachments.download", input: { ...fields, attachmentId: otherTenant } };
688
+ assert.deepEqual(await service.invoke(download), { contentBase64: "aGVsbG8=", size: 5, contentType: "application/pdf" });
689
+ assert.equal(f.requests.at(-1).url.pathname, `/api.xro/2.0/${resource}/${recordId}/Attachments/${otherTenant}`);
690
+ delete f.state.binaryResponse;
691
+ }
692
+ const download = { ...input, operation: "attachments.download", input: { tenantId, resource: "Invoices", id: recordId, attachmentId: otherTenant } };
693
+ f.state.binaryResponse = Buffer.alloc(3 * 1024 * 1024 + 1);
694
+ await assert.rejects(service.invoke(download), { code: "connector_response_too_large" });
695
+ f.state.binaryResponse = Buffer.alloc(0);
696
+ await assert.rejects(service.invoke(download), { code: "connector_response_invalid" });
697
+ f.state.accountingStatus = 401;
698
+ await assert.rejects(service.invoke(download));
699
+ assert.equal((await service.status(input)).status, "reconnect-required");
700
+ });