@indigoai-us/hq-cloud 6.14.50 → 6.15.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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,1380 +0,0 @@
1
- /**
2
- * VaultClient unit tests (VLT-7 US-001).
3
- *
4
- * Uses mocked fetch to assert retry behavior, error mapping, and auth header injection.
5
- */
6
-
7
- import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
8
- import {
9
- VaultClient,
10
- VaultAuthError,
11
- VaultPermissionDeniedError,
12
- VaultNotFoundError,
13
- VaultConflictError,
14
- VaultClientError,
15
- pickCanonicalPersonEntity,
16
- type EntityInfo,
17
- type ExplicitGrant,
18
- type MembershipSyncConfig,
19
- type TelemetryEventsBatch,
20
- } from "./vault-client.js";
21
-
22
- // ---------------------------------------------------------------------------
23
- // Helpers
24
- // ---------------------------------------------------------------------------
25
-
26
- function jsonResponse(status: number, body: unknown): Response {
27
- return new Response(JSON.stringify(body), {
28
- status,
29
- headers: { "Content-Type": "application/json" },
30
- });
31
- }
32
-
33
- function textResponse(status: number, body: string): Response {
34
- return new Response(body, { status });
35
- }
36
-
37
- const TEST_CONFIG = {
38
- apiUrl: "https://vault.test.example.com",
39
- authToken: "test-jwt-token-123",
40
- };
41
-
42
- let client: VaultClient;
43
- let fetchSpy: MockInstance<typeof fetch>;
44
-
45
- beforeEach(() => {
46
- client = new VaultClient(TEST_CONFIG);
47
- fetchSpy = vi.spyOn(globalThis, "fetch");
48
- });
49
-
50
- afterEach(() => {
51
- vi.restoreAllMocks();
52
- });
53
-
54
- // ---------------------------------------------------------------------------
55
- // Auth header injection
56
- // ---------------------------------------------------------------------------
57
-
58
- describe("auth header injection", () => {
59
- it("sends Bearer token on every request", async () => {
60
- fetchSpy.mockResolvedValueOnce(
61
- jsonResponse(200, { members: [] }),
62
- );
63
-
64
- await client.listMembersOfCompany("cmp_abc");
65
-
66
- expect(fetchSpy).toHaveBeenCalledTimes(1);
67
- const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
68
- const headers = init.headers as Record<string, string>;
69
- expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
70
- });
71
-
72
- it("sets Content-Type on POST requests", async () => {
73
- fetchSpy.mockResolvedValueOnce(
74
- jsonResponse(200, { membership: {}, inviteToken: "tok" }),
75
- );
76
-
77
- await client.createInvite({
78
- companyUid: "cmp_abc",
79
- role: "member",
80
- invitedBy: "psn_xyz",
81
- });
82
-
83
- const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
84
- const headers = init.headers as Record<string, string>;
85
- expect(headers["Content-Type"]).toBe("application/json");
86
- });
87
- });
88
-
89
- // ---------------------------------------------------------------------------
90
- // Error mapping
91
- // ---------------------------------------------------------------------------
92
-
93
- describe("error mapping", () => {
94
- it("maps 401 to VaultAuthError", async () => {
95
- fetchSpy.mockImplementation(() =>
96
- Promise.resolve(jsonResponse(401, { message: "Token expired" })),
97
- );
98
-
99
- await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
100
- });
101
-
102
- it("maps 403 to VaultPermissionDeniedError", async () => {
103
- fetchSpy.mockResolvedValueOnce(
104
- jsonResponse(403, { message: "Admin required" }),
105
- );
106
-
107
- await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultPermissionDeniedError);
108
- });
109
-
110
- it("maps 404 to VaultNotFoundError", async () => {
111
- fetchSpy.mockResolvedValueOnce(
112
- jsonResponse(404, { message: "Not found" }),
113
- );
114
-
115
- await expect(client.entity.get("cmp_missing")).rejects.toThrow(VaultNotFoundError);
116
- });
117
-
118
- it("maps 409 to VaultConflictError", async () => {
119
- fetchSpy.mockResolvedValueOnce(
120
- jsonResponse(409, { message: "Already accepted" }),
121
- );
122
-
123
- await expect(client.acceptInvite("tok", "psn_abc")).rejects.toThrow(VaultConflictError);
124
- });
125
-
126
- it("preserves error message from response body", async () => {
127
- fetchSpy.mockResolvedValueOnce(
128
- jsonResponse(403, { message: "Only admins can invite" }),
129
- );
130
-
131
- try {
132
- await client.createInvite({
133
- companyUid: "cmp_abc",
134
- role: "member",
135
- invitedBy: "psn_xyz",
136
- });
137
- expect.fail("Should have thrown");
138
- } catch (err) {
139
- expect(err).toBeInstanceOf(VaultPermissionDeniedError);
140
- expect((err as VaultPermissionDeniedError).message).toBe("Only admins can invite");
141
- }
142
- });
143
-
144
- it("handles non-JSON error bodies gracefully", async () => {
145
- fetchSpy.mockResolvedValueOnce(
146
- textResponse(404, "Not Found"),
147
- );
148
-
149
- try {
150
- await client.entity.findBySlug("company", "test");
151
- expect.fail("Should have thrown");
152
- } catch (err) {
153
- expect(err).toBeInstanceOf(VaultNotFoundError);
154
- expect((err as VaultNotFoundError).message).toBe("Not Found");
155
- }
156
- });
157
- });
158
-
159
- // ---------------------------------------------------------------------------
160
- // Retry behavior
161
- // ---------------------------------------------------------------------------
162
-
163
- describe("retry behavior", () => {
164
- it("retries on 429 and succeeds on second attempt", async () => {
165
- fetchSpy
166
- .mockResolvedValueOnce(jsonResponse(429, { message: "Rate limited" }))
167
- .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
168
-
169
- const result = await client.listMembersOfCompany("cmp_abc");
170
- expect(result).toHaveLength(1);
171
- expect(fetchSpy).toHaveBeenCalledTimes(2);
172
- });
173
-
174
- it("retries on 500 and succeeds on third attempt", async () => {
175
- fetchSpy
176
- .mockResolvedValueOnce(jsonResponse(500, { message: "Internal error" }))
177
- .mockResolvedValueOnce(jsonResponse(502, { message: "Bad gateway" }))
178
- .mockResolvedValueOnce(jsonResponse(200, { membership: { role: "admin" } }));
179
-
180
- const result = await client.updateRole({
181
- membershipKey: "psn_1#cmp_abc",
182
- newRole: "admin",
183
- updaterUid: "psn_owner",
184
- companyUid: "cmp_abc",
185
- });
186
- expect(result.role).toBe("admin");
187
- expect(fetchSpy).toHaveBeenCalledTimes(3);
188
- });
189
-
190
- it("throws after exhausting all retries on persistent 500", async () => {
191
- fetchSpy.mockImplementation(() =>
192
- Promise.resolve(jsonResponse(500, { message: "Down" })),
193
- );
194
-
195
- await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultClientError);
196
- // 1 initial + 3 retries = 4
197
- expect(fetchSpy).toHaveBeenCalledTimes(4);
198
- });
199
-
200
- // Regression: the deploy access-gate 401 of 2026-07-19. A token that expired
201
- // mid-request is recoverable — callers passing a getter can mint a fresh one —
202
- // but the client threw instead of retrying, so a transient auth lapse surfaced
203
- // as a hard failure and the caller degraded a members-only gate to a password.
204
- it("retries a 401 once when the token getter yields a fresh token", async () => {
205
- const tokens = ["stale-token", "fresh-token"];
206
- const getToken = vi.fn(async () => tokens.shift() ?? "fresh-token");
207
- const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
208
-
209
- fetchSpy
210
- .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
211
- .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
212
-
213
- const result = await c.listMembersOfCompany("cmp_abc");
214
- expect(result).toHaveLength(1);
215
- expect(fetchSpy).toHaveBeenCalledTimes(2);
216
-
217
- const authHeader = (call: number) =>
218
- (fetchSpy.mock.calls[call]![1] as RequestInit).headers as Record<string, string>;
219
- expect(authHeader(0).Authorization).toBe("Bearer stale-token");
220
- expect(authHeader(1).Authorization).toBe("Bearer fresh-token");
221
- });
222
-
223
- it("does not retry a 401 when the getter returns the same token", async () => {
224
- const getToken = vi.fn(async () => "unchanging-token");
225
- const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
226
-
227
- fetchSpy.mockResolvedValue(jsonResponse(401, { message: "Nope" }));
228
-
229
- await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
230
- expect(fetchSpy).toHaveBeenCalledTimes(1);
231
- });
232
-
233
- // Review finding: the auth retry used to share the transient budget, so a
234
- // 401 on the LAST attempt set the flag, hit `continue`, and fell out of the
235
- // loop having never actually retried — the recovery silently did nothing in
236
- // exactly the case it was written for.
237
- it("still retries when the 401 lands on the final attempt", async () => {
238
- const tokens = ["stale", "stale", "stale", "stale", "fresh"];
239
- const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
240
- const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
241
-
242
- // Burn the whole transient budget first (attempts 0,1,2), so the 401 lands
243
- // on attempt 3 — the last one the shared-budget loop would ever run.
244
- fetchSpy
245
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
246
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
247
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
248
- .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
249
- .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
250
-
251
- const result = await c.listMembersOfCompany("cmp_abc");
252
- expect(result).toHaveLength(1);
253
- // 5th call only happens if the auth retry got its own budget.
254
- expect(fetchSpy).toHaveBeenCalledTimes(5);
255
- });
256
-
257
- // Review finding: a 401 consumed a transient-retry slot, so a request that
258
- // hit an auth lapse had fewer retries left for genuine 5xx afterwards.
259
- it("does not spend transient-retry budget on the auth retry", async () => {
260
- const tokens = ["stale", "fresh"];
261
- const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
262
- const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
263
-
264
- // 401 (free retry) then three 500s — the full transient budget must still
265
- // be available, so the 4th call succeeds.
266
- fetchSpy
267
- .mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
268
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
269
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
270
- .mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
271
- .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
272
-
273
- const result = await c.listMembersOfCompany("cmp_abc");
274
- expect(result).toHaveLength(1);
275
- expect(fetchSpy).toHaveBeenCalledTimes(5);
276
- });
277
-
278
- it("retries a 401 at most once even if the token keeps changing", async () => {
279
- let n = 0;
280
- const getToken = vi.fn(async () => `token-${n++}`);
281
- const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
282
-
283
- // mockImplementation (not mockResolvedValue): each attempt needs its own
284
- // Response, since the body can only be read once.
285
- fetchSpy.mockImplementation(() =>
286
- Promise.resolve(jsonResponse(401, { message: "Still no" })),
287
- );
288
-
289
- await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
290
- // 1 initial + exactly 1 auth retry — never a retry storm against the authorizer
291
- expect(fetchSpy).toHaveBeenCalledTimes(2);
292
- });
293
-
294
- it("does not retry on 403 (non-transient)", async () => {
295
- fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
296
-
297
- await expect(client.createInvite({
298
- companyUid: "cmp_abc",
299
- role: "member",
300
- invitedBy: "psn_xyz",
301
- })).rejects.toThrow(VaultPermissionDeniedError);
302
- expect(fetchSpy).toHaveBeenCalledTimes(1);
303
- });
304
-
305
- it("retries on network errors (fetch throws)", async () => {
306
- fetchSpy
307
- .mockRejectedValueOnce(new Error("ECONNRESET"))
308
- .mockResolvedValueOnce(jsonResponse(200, { members: [] }));
309
-
310
- const result = await client.listMembersOfCompany("cmp_abc");
311
- expect(result).toEqual([]);
312
- expect(fetchSpy).toHaveBeenCalledTimes(2);
313
- });
314
- });
315
-
316
- // ---------------------------------------------------------------------------
317
- // API surface
318
- // ---------------------------------------------------------------------------
319
-
320
- describe("API surface", () => {
321
- it("createInvite sends correct body and URL", async () => {
322
- fetchSpy.mockResolvedValueOnce(
323
- jsonResponse(200, {
324
- membership: { membershipKey: "psn_1#cmp_abc", role: "member", status: "pending" },
325
- inviteToken: "tok_secure_random",
326
- }),
327
- );
328
-
329
- const result = await client.createInvite({
330
- companyUid: "cmp_abc",
331
- role: "member",
332
- invitedBy: "psn_owner",
333
- inviteeEmail: "alice@example.com",
334
- });
335
-
336
- expect(result.inviteToken).toBe("tok_secure_random");
337
-
338
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
339
- expect(url).toBe("https://vault.test.example.com/membership/invite");
340
- expect(JSON.parse(init.body as string)).toEqual({
341
- companyUid: "cmp_abc",
342
- role: "member",
343
- invitedBy: "psn_owner",
344
- inviteeEmail: "alice@example.com",
345
- });
346
- });
347
-
348
- it("acceptInvite sends token and personUid", async () => {
349
- fetchSpy.mockResolvedValueOnce(
350
- jsonResponse(200, {
351
- membership: { status: "active", role: "member" },
352
- }),
353
- );
354
-
355
- const result = await client.acceptInvite("tok_abc", "psn_invitee");
356
- expect(result.membership.status).toBe("active");
357
-
358
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
359
- expect(url).toBe("https://vault.test.example.com/membership/accept");
360
- expect(JSON.parse(init.body as string)).toEqual({
361
- token: "tok_abc",
362
- personUid: "psn_invitee",
363
- });
364
- });
365
-
366
- it("postTelemetryEvents sends action events without personUid", async () => {
367
- fetchSpy.mockResolvedValueOnce(
368
- jsonResponse(200, { contractVersion: 1, ok: true, written: 1, skipped: [] }),
369
- );
370
- const batch: TelemetryEventsBatch = {
371
- events: [
372
- {
373
- eventName: "sync_runner_completed",
374
- app: "hq-cloud",
375
- source: "sync-runner",
376
- occurredAt: "2026-07-07T12:00:00.000Z",
377
- companyUid: "cmp_abc",
378
- sessionId: "sync_123",
379
- schemaVersion: 1,
380
- properties: {
381
- result: "completed",
382
- fileCount: 2,
383
- },
384
- },
385
- ],
386
- };
387
-
388
- const result = await client.postTelemetryEvents(batch);
389
-
390
- expect(result).toEqual({ ok: true, written: 1, skipped: [] });
391
- expect(fetchSpy).toHaveBeenCalledTimes(1);
392
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
393
- expect(url).toBe("https://vault.test.example.com/v1/telemetry/events");
394
- expect((init.method as string).toUpperCase()).toBe("POST");
395
- const body = JSON.parse(init.body as string) as Record<string, unknown>;
396
- expect(body).toEqual(batch);
397
- expect(JSON.stringify(body)).not.toContain("personUid");
398
- });
399
-
400
- it("postTelemetryEvents normalizes the compatible pre-v1 accepted response", async () => {
401
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { accepted: 2, deduped: 1 }));
402
- const batch: TelemetryEventsBatch = { events: [] };
403
-
404
- await expect(client.postTelemetryEvents(batch)).resolves.toEqual({
405
- ok: true,
406
- written: 2,
407
- skipped: [],
408
- });
409
- });
410
-
411
- it("updateRole sends correct payload", async () => {
412
- fetchSpy.mockResolvedValueOnce(
413
- jsonResponse(200, {
414
- membership: { role: "guest", allowedPrefixes: ["docs/"] },
415
- }),
416
- );
417
-
418
- const result = await client.updateRole({
419
- membershipKey: "psn_1#cmp_abc",
420
- newRole: "guest",
421
- allowedPrefixes: ["docs/"],
422
- updaterUid: "psn_admin",
423
- companyUid: "cmp_abc",
424
- });
425
-
426
- expect(result.role).toBe("guest");
427
- expect(result.allowedPrefixes).toEqual(["docs/"]);
428
-
429
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
430
- expect(url).toBe("https://vault.test.example.com/membership/role");
431
- expect(JSON.parse(init.body as string)).toEqual({
432
- membershipKey: "psn_1#cmp_abc",
433
- newRole: "guest",
434
- allowedPrefixes: ["docs/"],
435
- updaterUid: "psn_admin",
436
- companyUid: "cmp_abc",
437
- });
438
- });
439
-
440
- it("entity.get calls correct URL", async () => {
441
- fetchSpy.mockResolvedValueOnce(
442
- jsonResponse(200, {
443
- entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
444
- }),
445
- );
446
-
447
- const entity = await client.entity.get("cmp_abc");
448
- expect(entity.slug).toBe("acme");
449
-
450
- const [url] = fetchSpy.mock.calls[0] as [string];
451
- expect(url).toBe("https://vault.test.example.com/entity/cmp_abc");
452
- });
453
-
454
- it("entity.findBySlug calls correct URL", async () => {
455
- fetchSpy.mockResolvedValueOnce(
456
- jsonResponse(200, {
457
- entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
458
- }),
459
- );
460
-
461
- const entity = await client.entity.findBySlug("company", "acme");
462
- expect(entity.uid).toBe("cmp_abc");
463
-
464
- const [url] = fetchSpy.mock.calls[0] as [string];
465
- expect(url).toBe("https://vault.test.example.com/entity/by-slug/company/acme");
466
- });
467
-
468
- it("revokeMembership calls POST /membership/revoke with companyUid", async () => {
469
- fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
470
-
471
- await client.revokeMembership("psn_1#cmp_abc", "cmp_abc");
472
-
473
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
474
- expect(url).toBe("https://vault.test.example.com/membership/revoke");
475
- expect(init.method).toBe("POST");
476
- expect(JSON.parse(init.body as string)).toEqual({
477
- membershipKey: "psn_1#cmp_abc",
478
- companyUid: "cmp_abc",
479
- });
480
- });
481
-
482
- it("listPendingInvites calls correct URL", async () => {
483
- fetchSpy.mockResolvedValueOnce(
484
- jsonResponse(200, { invites: [{ status: "pending" }] }),
485
- );
486
-
487
- const invites = await client.listPendingInvites("cmp_abc");
488
- expect(invites).toHaveLength(1);
489
-
490
- const [url] = fetchSpy.mock.calls[0] as [string];
491
- expect(url).toBe("https://vault.test.example.com/membership/company/cmp_abc/pending");
492
- });
493
-
494
- it("listMyMemberships hits GET /membership/me and unwraps memberships[]", async () => {
495
- fetchSpy.mockResolvedValueOnce(
496
- jsonResponse(200, {
497
- memberships: [
498
- { membershipKey: "psn_1#cmp_a", role: "owner", status: "active" },
499
- { membershipKey: "psn_1#cmp_b", role: "member", status: "active" },
500
- ],
501
- }),
502
- );
503
-
504
- const memberships = await client.listMyMemberships();
505
- expect(memberships).toHaveLength(2);
506
- expect(memberships[0].membershipKey).toBe("psn_1#cmp_a");
507
-
508
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
509
- expect(url).toBe("https://vault.test.example.com/membership/me");
510
- expect(init.method).toBe("GET");
511
- // No body on GET.
512
- expect(init.body).toBeUndefined();
513
- });
514
-
515
- it("listMyMemberships returns [] for callers with no person entity (bootstrap case)", async () => {
516
- // Server returns 200 + { memberships: [] } rather than 404 when the
517
- // caller is signed in but hasn't been provisioned yet. The SDK must
518
- // surface an empty array, NOT throw — hq-sync-runner relies on this
519
- // to emit `setup-needed` without catching HTTP errors.
520
- fetchSpy.mockResolvedValueOnce(
521
- jsonResponse(200, { memberships: [] }),
522
- );
523
-
524
- const memberships = await client.listMyMemberships();
525
- expect(memberships).toEqual([]);
526
- });
527
-
528
- it("listMyPendingInvitesByEmail hits GET /membership/pending-by-email", async () => {
529
- fetchSpy.mockResolvedValueOnce(
530
- jsonResponse(200, {
531
- invites: [
532
- {
533
- membershipKey: "email:stefan@getindigo.ai#cmp_abc",
534
- companyUid: "cmp_abc",
535
- role: "owner",
536
- invitedBy: "sub-admin",
537
- invitedAt: "2026-04-20T00:00:00Z",
538
- },
539
- ],
540
- }),
541
- );
542
-
543
- const invites = await client.listMyPendingInvitesByEmail();
544
- expect(invites).toHaveLength(1);
545
- expect(invites[0].companyUid).toBe("cmp_abc");
546
-
547
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
548
- expect(url).toBe(
549
- "https://vault.test.example.com/membership/pending-by-email",
550
- );
551
- expect(init.method).toBe("GET");
552
- });
553
-
554
- it("listMyPendingInvitesByEmail returns [] when server omits the key", async () => {
555
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
556
- const invites = await client.listMyPendingInvitesByEmail();
557
- expect(invites).toEqual([]);
558
- });
559
-
560
- it("claimPendingInvitesByEmail POSTs personUid to /membership/claim-by-email", async () => {
561
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
562
-
563
- await client.claimPendingInvitesByEmail("ent_person_stefan");
564
-
565
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
566
- expect(url).toBe(
567
- "https://vault.test.example.com/membership/claim-by-email",
568
- );
569
- expect(init.method).toBe("POST");
570
- expect(JSON.parse(init.body as string)).toEqual({
571
- personUid: "ent_person_stefan",
572
- });
573
- });
574
- });
575
-
576
- describe("response validation (F26)", () => {
577
- it("F26: validates a valid files/list response and returns typed data", async () => {
578
- fetchSpy.mockResolvedValueOnce(
579
- jsonResponse(200, {
580
- objects: [
581
- {
582
- key: "shared/docs/a.txt",
583
- size: 123,
584
- lastModified: "2026-05-20T12:00:00.000Z",
585
- etag: "abc123",
586
- permission: "read",
587
- },
588
- ],
589
- cursor: null,
590
- truncated: false,
591
- }),
592
- );
593
-
594
- const page = await client.listFiles("cmp_abc", "shared/docs/");
595
-
596
- expect(page.objects[0]).toEqual({
597
- key: "shared/docs/a.txt",
598
- size: 123,
599
- lastModified: "2026-05-20T12:00:00.000Z",
600
- etag: "abc123",
601
- permission: "read",
602
- });
603
- expect(page.cursor).toBeNull();
604
- expect(page.truncated).toBe(false);
605
- });
606
-
607
- it("F26: defaults omitted files/list paging fields", async () => {
608
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
609
-
610
- const page = await client.listFiles("cmp_abc", "shared/docs/");
611
-
612
- expect(page).toEqual({
613
- objects: [],
614
- cursor: null,
615
- truncated: false,
616
- });
617
- });
618
-
619
- it("F26: defaults omitted telemetry opt-in fields to disabled", async () => {
620
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
621
-
622
- const optIn = await client.getTelemetryOptIn();
623
-
624
- expect(optIn).toEqual({ enabled: false, updatedAt: null });
625
- });
626
-
627
- it("F26: maps a malformed files/list response to VaultClientError", async () => {
628
- fetchSpy.mockResolvedValueOnce(
629
- jsonResponse(200, {
630
- objects: [
631
- {
632
- key: "shared/docs/a.txt",
633
- size: 123,
634
- lastModified: "2026-05-20T12:00:00.000Z",
635
- permission: "read",
636
- },
637
- ],
638
- cursor: null,
639
- truncated: false,
640
- }),
641
- );
642
-
643
- await expect(client.listFiles("cmp_abc")).rejects.toMatchObject({
644
- name: "VaultClientError",
645
- statusCode: 502,
646
- });
647
- });
648
- });
649
-
650
- describe("VaultClient identity bootstrap", () => {
651
- let client: VaultClient;
652
- let fetchSpy: MockInstance<typeof fetch>;
653
-
654
- beforeEach(() => {
655
- fetchSpy = vi.spyOn(globalThis, "fetch");
656
- fetchSpy.mockResolvedValue(jsonResponse(200, {}));
657
- client = new VaultClient({
658
- apiUrl: "https://vault.test.example.com",
659
- authToken: "test-token",
660
- region: "us-east-1",
661
- });
662
- });
663
-
664
- afterEach(() => {
665
- fetchSpy.mockRestore();
666
- });
667
-
668
- it("entity.listByType GETs /entity/by-type/{type}", async () => {
669
- fetchSpy.mockResolvedValueOnce(
670
- jsonResponse(200, {
671
- entities: [
672
- {
673
- uid: "ent_person_stefan",
674
- slug: "stefan-johnson",
675
- type: "person",
676
- status: "active",
677
- },
678
- ],
679
- }),
680
- );
681
-
682
- const entities = await client.entity.listByType("person");
683
- expect(entities).toHaveLength(1);
684
- const [url] = fetchSpy.mock.calls[0] as [string];
685
- expect(url).toBe(
686
- "https://vault.test.example.com/entity/by-type/person",
687
- );
688
- });
689
-
690
- it("entity.listByType returns [] when server omits the key", async () => {
691
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
692
- const entities = await client.entity.listByType("person");
693
- expect(entities).toEqual([]);
694
- });
695
-
696
- it("ensureMyPersonEntity short-circuits when a person entity already exists", async () => {
697
- fetchSpy.mockResolvedValueOnce(
698
- jsonResponse(200, {
699
- entities: [
700
- {
701
- uid: "ent_person_existing",
702
- slug: "already-there",
703
- type: "person",
704
- status: "active",
705
- },
706
- ],
707
- }),
708
- );
709
-
710
- const person = await client.ensureMyPersonEntity({
711
- ownerSub: "sub-abc",
712
- displayName: "Stefan Johnson",
713
- });
714
-
715
- expect(person.uid).toBe("ent_person_existing");
716
- // Only one HTTP call — list. No POST /entity.
717
- expect(fetchSpy).toHaveBeenCalledTimes(1);
718
- });
719
-
720
- it("ensureMyPersonEntity POSTs /entity with a slug derived from displayName when none exist", async () => {
721
- fetchSpy
722
- .mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
723
- .mockResolvedValueOnce(
724
- jsonResponse(200, {
725
- entity: {
726
- uid: "ent_person_new",
727
- slug: "stefan-johnson",
728
- type: "person",
729
- status: "active",
730
- },
731
- }),
732
- );
733
-
734
- const person = await client.ensureMyPersonEntity({
735
- ownerSub: "sub-abc",
736
- displayName: "Stefan Johnson",
737
- });
738
-
739
- expect(person.uid).toBe("ent_person_new");
740
- expect(fetchSpy).toHaveBeenCalledTimes(2);
741
- const [url, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
742
- expect(url).toBe("https://vault.test.example.com/entity");
743
- expect(init.method).toBe("POST");
744
- expect(JSON.parse(init.body as string)).toEqual({
745
- type: "person",
746
- name: "Stefan Johnson",
747
- slug: "stefan-johnson",
748
- });
749
- });
750
-
751
- it("ensureMyPersonEntity falls back to user-<sub-suffix> when displayName slugifies to empty", async () => {
752
- fetchSpy
753
- .mockResolvedValueOnce(jsonResponse(200, { entities: [] }))
754
- .mockResolvedValueOnce(
755
- jsonResponse(200, {
756
- entity: {
757
- uid: "ent_person_new",
758
- slug: "user-12345678",
759
- type: "person",
760
- status: "active",
761
- },
762
- }),
763
- );
764
-
765
- await client.ensureMyPersonEntity({
766
- ownerSub: "sub-abcdef12345678",
767
- displayName: "!!!",
768
- });
769
-
770
- const [, init] = fetchSpy.mock.calls[1] as [string, RequestInit];
771
- const body = JSON.parse(init.body as string);
772
- expect(body.slug).toBe("user-12345678");
773
- });
774
-
775
- it("listByType_roundtrips_createdAt", async () => {
776
- fetchSpy.mockResolvedValueOnce(
777
- jsonResponse(200, {
778
- entities: [
779
- {
780
- uid: "prs_x",
781
- slug: "alice",
782
- type: "person",
783
- status: "active",
784
- createdAt: "2026-01-01T00:00:00Z",
785
- },
786
- ],
787
- }),
788
- );
789
-
790
- const entities = await client.entity.listByType("person");
791
- expect(entities).toHaveLength(1);
792
- expect(entities[0].createdAt).toBe("2026-01-01T00:00:00Z");
793
- });
794
-
795
- it("ensureMyPersonEntity_picks_oldest_when_multiple", async () => {
796
- fetchSpy.mockResolvedValueOnce(
797
- jsonResponse(200, {
798
- entities: [
799
- { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-03-01T00:00:00Z" },
800
- { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" },
801
- { uid: "prs_c", slug: "c", type: "person", status: "active", createdAt: "2026-06-01T00:00:00Z" },
802
- ],
803
- }),
804
- );
805
-
806
- const person = await client.ensureMyPersonEntity({
807
- ownerSub: "sub-multi",
808
- displayName: "Multi User",
809
- });
810
-
811
- expect(person.uid).toBe("prs_a");
812
- expect(fetchSpy).toHaveBeenCalledTimes(1);
813
- });
814
-
815
- it("ensureMyPersonEntity_handles_missing_createdAt_deterministically", async () => {
816
- fetchSpy.mockResolvedValueOnce(
817
- jsonResponse(200, {
818
- entities: [
819
- { uid: "prs_z", slug: "z", type: "person", status: "active" },
820
- { uid: "prs_a", slug: "a", type: "person", status: "active" },
821
- ],
822
- }),
823
- );
824
-
825
- const person = await client.ensureMyPersonEntity({
826
- ownerSub: "sub-nodates",
827
- displayName: "No Dates User",
828
- });
829
-
830
- // Both missing createdAt → "" tie, uid tiebreak selects prs_a
831
- expect(person.uid).toBe("prs_a");
832
- expect(fetchSpy).toHaveBeenCalledTimes(1);
833
- });
834
-
835
- it("pickCanonicalPersonEntity_picks_oldest_tiebreak_uid", () => {
836
- const list: EntityInfo[] = [
837
- { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
838
- { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
839
- ];
840
- // Older createdAt wins
841
- expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
842
-
843
- // Same createdAt: uid tiebreak (lexicographic ascending)
844
- const tieList: EntityInfo[] = [
845
- { uid: "prs_z", slug: "z", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
846
- { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
847
- ];
848
- expect(pickCanonicalPersonEntity(tieList)?.uid).toBe("prs_a");
849
- });
850
-
851
- it("pickCanonicalPersonEntity_handles_missing_createdAt_deterministically", () => {
852
- // undefined createdAt coalesces to "" which sorts before any ISO date string.
853
- // So the entity with undefined createdAt wins on the createdAt comparison.
854
- // When two entities both lack createdAt, uid tiebreak applies.
855
- const list: EntityInfo[] = [
856
- { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
857
- { uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo, // no createdAt
858
- ];
859
- // "" < "2026-..." so prs_a (undefined→"") wins
860
- expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
861
-
862
- // Both undefined: uid tiebreak
863
- const bothUndefined: EntityInfo[] = [
864
- { uid: "prs_z", slug: "z", type: "person", status: "active" } as EntityInfo,
865
- { uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo,
866
- ];
867
- expect(pickCanonicalPersonEntity(bothUndefined)?.uid).toBe("prs_a");
868
- });
869
-
870
- it("pickCanonicalPersonEntity_filters_out_non_person_types", () => {
871
- const mixed: EntityInfo[] = [
872
- { uid: "cmp_x", slug: "x", type: "company", status: "active", createdAt: "2025-01-01T00:00:00Z" } as EntityInfo,
873
- { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
874
- ];
875
- const result = pickCanonicalPersonEntity(mixed);
876
- expect(result?.uid).toBe("prs_b");
877
- expect(result?.type).toBe("person");
878
- });
879
-
880
- it("sts.vend roundtrips POST /sts/vend with companyUid", async () => {
881
- fetchSpy.mockResolvedValueOnce(
882
- jsonResponse(200, {
883
- credentials: {
884
- accessKeyId: "AKIAIOSFODNN7EXAMPLE",
885
- secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
886
- sessionToken: "FwoGZXIvYXdzEBY...",
887
- },
888
- expiresAt: "2026-01-01T01:00:00.000Z",
889
- }),
890
- );
891
-
892
- const result = await client.sts.vend({ companyUid: "cmp_x" });
893
-
894
- expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
895
- expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
896
- expect(typeof result.expiresAt).toBe("string");
897
-
898
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
899
- expect(url).toBe("https://vault.test.example.com/sts/vend");
900
- expect((init.method as string).toUpperCase()).toBe("POST");
901
- expect(JSON.parse(init.body as string)).toEqual({ companyUid: "cmp_x" });
902
- });
903
-
904
- it("vendSelf_roundtrip", async () => {
905
- fetchSpy.mockResolvedValueOnce(
906
- jsonResponse(200, {
907
- credentials: {
908
- accessKeyId: "AKIAIOSFODNN7EXAMPLE",
909
- secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
910
- sessionToken: "FwoGZXIvYXdzEBY...",
911
- },
912
- expiresAt: "2026-01-01T01:00:00.000Z",
913
- }),
914
- );
915
-
916
- const result = await client.sts.vendSelf({ personUid: "prs_x" });
917
-
918
- expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
919
- expect(result.credentials.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
920
- expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
921
- expect(typeof result.expiresAt).toBe("string");
922
-
923
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
924
- expect(url).toBe("https://vault.test.example.com/sts/vend-self");
925
- expect((init.method as string).toUpperCase()).toBe("POST");
926
- expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
927
- });
928
- });
929
-
930
- // ---------------------------------------------------------------------------
931
- // Browse-vs-sync SDK methods (US-004)
932
- //
933
- // listMyExplicitGrants, getMembershipSyncConfig, setMembershipSyncConfig
934
- // wrap the US-002/US-003 hq-pro endpoints. Tests assert URL shape, payload
935
- // shape, error mapping (401/404/network), and the empty-grants fallback
936
- // that lets call sites treat "no grants" as a normal state.
937
- // ---------------------------------------------------------------------------
938
-
939
- describe("listMyExplicitGrants (US-004)", () => {
940
- it("GETs /v1/files/grants with the company query param and returns grants[]", async () => {
941
- const grant: ExplicitGrant = {
942
- companyUid: "cmp_abc",
943
- path: "shared/docs",
944
- permission: "read",
945
- source: "person",
946
- };
947
- fetchSpy.mockResolvedValueOnce(
948
- jsonResponse(200, {
949
- grants: [grant],
950
- computedAt: "2026-05-20T12:00:00.000Z",
951
- }),
952
- );
953
-
954
- const grants = await client.listMyExplicitGrants("cmp_abc");
955
-
956
- expect(grants).toEqual([grant]);
957
-
958
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
959
- expect(url).toBe(
960
- "https://vault.test.example.com/v1/files/grants?company=cmp_abc",
961
- );
962
- expect(init.method).toBe("GET");
963
- expect(init.body).toBeUndefined();
964
- });
965
-
966
- it("parses creator-sourced grants", async () => {
967
- const grant: ExplicitGrant = {
968
- companyUid: "cmp_abc",
969
- path: "personal/psn_creator",
970
- permission: "admin",
971
- source: "creator",
972
- };
973
- fetchSpy.mockResolvedValueOnce(
974
- jsonResponse(200, {
975
- grants: [grant],
976
- computedAt: "2026-05-20T12:00:00.000Z",
977
- }),
978
- );
979
-
980
- const grants = await client.listMyExplicitGrants("cmp_abc");
981
-
982
- expect(grants[0]?.source).toBe("creator");
983
- expect(grants).toEqual([grant]);
984
- });
985
-
986
- it("URL-encodes the companyUid query parameter", async () => {
987
- fetchSpy.mockResolvedValueOnce(
988
- jsonResponse(200, { grants: [], computedAt: "2026-05-20T12:00:00.000Z" }),
989
- );
990
-
991
- await client.listMyExplicitGrants("cmp/with spaces&weird=chars");
992
-
993
- const [url] = fetchSpy.mock.calls[0] as [string];
994
- expect(url).toBe(
995
- "https://vault.test.example.com/v1/files/grants?company=cmp%2Fwith%20spaces%26weird%3Dchars",
996
- );
997
- });
998
-
999
- it("returns [] when the server omits the grants key (empty graph)", async () => {
1000
- fetchSpy.mockResolvedValueOnce(
1001
- jsonResponse(200, { computedAt: "2026-05-20T12:00:00.000Z" }),
1002
- );
1003
-
1004
- const grants = await client.listMyExplicitGrants("cmp_abc");
1005
- expect(grants).toEqual([]);
1006
- });
1007
-
1008
- it("maps 401 to VaultAuthError", async () => {
1009
- fetchSpy.mockImplementation(() =>
1010
- Promise.resolve(
1011
- jsonResponse(401, { error: "Missing or invalid authorization token" }),
1012
- ),
1013
- );
1014
-
1015
- await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
1016
- VaultAuthError,
1017
- );
1018
- });
1019
-
1020
- it("maps 404 to VaultNotFoundError", async () => {
1021
- fetchSpy.mockResolvedValueOnce(
1022
- jsonResponse(404, { error: "Unknown route" }),
1023
- );
1024
-
1025
- await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
1026
- VaultNotFoundError,
1027
- );
1028
- });
1029
-
1030
- it("surfaces transport errors after exhausting retries", async () => {
1031
- fetchSpy.mockRejectedValue(new Error("ECONNREFUSED"));
1032
-
1033
- await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
1034
- /ECONNREFUSED/,
1035
- );
1036
- // 1 initial + 3 retries = 4 attempts on a persistent network error.
1037
- expect(fetchSpy).toHaveBeenCalledTimes(4);
1038
- });
1039
- });
1040
-
1041
- describe("getMembershipSyncConfig (US-004)", () => {
1042
- it("GETs /v1/memberships/{id}/sync-config and returns the row verbatim", async () => {
1043
- const row: MembershipSyncConfig = {
1044
- membershipId: "psn_1#cmp_abc",
1045
- syncMode: "custom",
1046
- customPaths: ["shared/docs", "personal/psn_1"],
1047
- isDefault: false,
1048
- updatedAt: "2026-05-20T12:00:00.000Z",
1049
- updatedBy: "psn_admin",
1050
- };
1051
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, row));
1052
-
1053
- const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
1054
-
1055
- expect(result).toEqual(row);
1056
-
1057
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1058
- expect(url).toBe(
1059
- // `#` in the membershipKey MUST be URL-encoded — otherwise the
1060
- // server sees a fragment, not a path segment.
1061
- "https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
1062
- );
1063
- expect(init.method).toBe("GET");
1064
- expect(init.body).toBeUndefined();
1065
- });
1066
-
1067
- it("returns the default row (isDefault: true) without updatedAt/updatedBy", async () => {
1068
- fetchSpy.mockResolvedValueOnce(
1069
- jsonResponse(200, {
1070
- membershipId: "psn_1#cmp_abc",
1071
- syncMode: "all",
1072
- isDefault: true,
1073
- }),
1074
- );
1075
-
1076
- const result = await client.getMembershipSyncConfig("psn_1#cmp_abc");
1077
- expect(result.isDefault).toBe(true);
1078
- expect(result.updatedAt).toBeUndefined();
1079
- expect(result.updatedBy).toBeUndefined();
1080
- });
1081
-
1082
- it("maps 401 to VaultAuthError", async () => {
1083
- fetchSpy.mockImplementation(() =>
1084
- Promise.resolve(jsonResponse(401, { error: "Token expired" })),
1085
- );
1086
-
1087
- await expect(
1088
- client.getMembershipSyncConfig("psn_1#cmp_abc"),
1089
- ).rejects.toThrow(VaultAuthError);
1090
- });
1091
-
1092
- it("maps 404 to VaultNotFoundError (membership tombstoned or missing)", async () => {
1093
- fetchSpy.mockResolvedValueOnce(
1094
- jsonResponse(404, {
1095
- error: "Membership not found or not active: psn_1#cmp_abc",
1096
- }),
1097
- );
1098
-
1099
- await expect(
1100
- client.getMembershipSyncConfig("psn_1#cmp_abc"),
1101
- ).rejects.toThrow(VaultNotFoundError);
1102
- });
1103
-
1104
- it("surfaces network errors after retry exhaustion", async () => {
1105
- fetchSpy.mockRejectedValue(new Error("socket hang up"));
1106
-
1107
- await expect(
1108
- client.getMembershipSyncConfig("psn_1#cmp_abc"),
1109
- ).rejects.toThrow(/socket hang up/);
1110
- expect(fetchSpy).toHaveBeenCalledTimes(4);
1111
- });
1112
- });
1113
-
1114
- describe("setMembershipSyncConfig (US-004)", () => {
1115
- it("PUTs the partial body to /v1/memberships/{id}/sync-config", async () => {
1116
- const persisted: MembershipSyncConfig = {
1117
- membershipId: "psn_1#cmp_abc",
1118
- syncMode: "custom",
1119
- customPaths: ["shared/docs"],
1120
- isDefault: false,
1121
- updatedAt: "2026-05-20T12:00:00.000Z",
1122
- updatedBy: "psn_1",
1123
- };
1124
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, persisted));
1125
-
1126
- const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
1127
- syncMode: "custom",
1128
- customPaths: ["shared/docs"],
1129
- });
1130
-
1131
- expect(result).toEqual(persisted);
1132
-
1133
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1134
- expect(url).toBe(
1135
- "https://vault.test.example.com/v1/memberships/psn_1%23cmp_abc/sync-config",
1136
- );
1137
- expect(init.method).toBe("PUT");
1138
- const headers = init.headers as Record<string, string>;
1139
- expect(headers["Content-Type"]).toBe("application/json");
1140
- expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
1141
- expect(JSON.parse(init.body as string)).toEqual({
1142
- syncMode: "custom",
1143
- customPaths: ["shared/docs"],
1144
- });
1145
- });
1146
-
1147
- it("supports the shared mode without customPaths", async () => {
1148
- fetchSpy.mockResolvedValueOnce(
1149
- jsonResponse(200, {
1150
- membershipId: "psn_1#cmp_abc",
1151
- syncMode: "shared",
1152
- isDefault: false,
1153
- updatedAt: "2026-05-20T12:00:00.000Z",
1154
- updatedBy: "psn_1",
1155
- }),
1156
- );
1157
-
1158
- const result = await client.setMembershipSyncConfig("psn_1#cmp_abc", {
1159
- syncMode: "shared",
1160
- });
1161
-
1162
- expect(result.syncMode).toBe("shared");
1163
- expect(result.customPaths).toBeUndefined();
1164
-
1165
- const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1166
- expect(JSON.parse(init.body as string)).toEqual({ syncMode: "shared" });
1167
- });
1168
-
1169
- it("maps 401 to VaultAuthError", async () => {
1170
- fetchSpy.mockImplementation(() =>
1171
- Promise.resolve(jsonResponse(401, { error: "Token expired" })),
1172
- );
1173
-
1174
- await expect(
1175
- client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
1176
- ).rejects.toThrow(VaultAuthError);
1177
- });
1178
-
1179
- it("maps 404 to VaultNotFoundError when the membership is gone", async () => {
1180
- fetchSpy.mockResolvedValueOnce(
1181
- jsonResponse(404, { error: "Membership not found" }),
1182
- );
1183
-
1184
- await expect(
1185
- client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
1186
- ).rejects.toThrow(VaultNotFoundError);
1187
- });
1188
-
1189
- it("maps 409 to VaultConflictError", async () => {
1190
- fetchSpy.mockResolvedValueOnce(
1191
- jsonResponse(409, { error: "Concurrent write conflict" }),
1192
- );
1193
-
1194
- await expect(
1195
- client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
1196
- ).rejects.toThrow(VaultConflictError);
1197
- });
1198
-
1199
- it("surfaces network errors after retry exhaustion", async () => {
1200
- fetchSpy.mockRejectedValue(new Error("ETIMEDOUT"));
1201
-
1202
- await expect(
1203
- client.setMembershipSyncConfig("psn_1#cmp_abc", { syncMode: "all" }),
1204
- ).rejects.toThrow(/ETIMEDOUT/);
1205
- const syncConfigCalls = fetchSpy.mock.calls.filter(([url]) =>
1206
- String(url).includes("/v1/memberships/psn_1%23cmp_abc/sync-config"),
1207
- );
1208
- expect(syncConfigCalls).toHaveLength(4);
1209
- });
1210
- });
1211
-
1212
- // ---------------------------------------------------------------------------
1213
- // Refreshable authToken getter
1214
- //
1215
- // Regression for the personal-sync 401: a captured `authToken` string can
1216
- // outlive Cognito's 60-min access token TTL during long multi-company
1217
- // fanouts, causing mid-flight `refreshEntityContext` calls to 401. The
1218
- // getter form lets every request resolve the latest token from disk.
1219
- // ---------------------------------------------------------------------------
1220
-
1221
- describe("authToken getter (refreshable token)", () => {
1222
- // Response bodies can only be read once, so each mock call needs a fresh
1223
- // Response. Using mockImplementation (not mockResolvedValue) ensures a new
1224
- // Response instance per request — same pattern as the retry tests above.
1225
- function alwaysOk(body: unknown): void {
1226
- fetchSpy.mockImplementation(() =>
1227
- Promise.resolve(jsonResponse(200, body)),
1228
- );
1229
- }
1230
-
1231
- it("calls the getter on every request", async () => {
1232
- const getter = vi.fn(async () => "first-token");
1233
- const c = new VaultClient({
1234
- apiUrl: "https://vault.test.example.com",
1235
- authToken: getter,
1236
- });
1237
-
1238
- alwaysOk({ members: [] });
1239
-
1240
- await c.listMembersOfCompany("cmp_abc");
1241
- await c.listMembersOfCompany("cmp_abc");
1242
- await c.listMembersOfCompany("cmp_abc");
1243
-
1244
- expect(getter).toHaveBeenCalledTimes(3);
1245
- });
1246
-
1247
- it("picks up a rotated token without recreating the client", async () => {
1248
- let current = "stale-token";
1249
- const c = new VaultClient({
1250
- apiUrl: "https://vault.test.example.com",
1251
- authToken: async () => current,
1252
- });
1253
-
1254
- alwaysOk({ members: [] });
1255
-
1256
- await c.listMembersOfCompany("cmp_abc");
1257
- const [, firstInit] = fetchSpy.mock.calls[0] as [string, RequestInit];
1258
- expect((firstInit.headers as Record<string, string>).Authorization).toBe(
1259
- "Bearer stale-token",
1260
- );
1261
-
1262
- // Simulate the menubar refreshing ~/.hq/cognito-tokens.json mid-flight.
1263
- current = "fresh-token";
1264
-
1265
- await c.listMembersOfCompany("cmp_abc");
1266
- const [, secondInit] = fetchSpy.mock.calls[1] as [string, RequestInit];
1267
- expect((secondInit.headers as Record<string, string>).Authorization).toBe(
1268
- "Bearer fresh-token",
1269
- );
1270
- });
1271
-
1272
- it("supports a sync getter (returns plain string, not a promise)", async () => {
1273
- const c = new VaultClient({
1274
- apiUrl: "https://vault.test.example.com",
1275
- authToken: () => "sync-token",
1276
- });
1277
-
1278
- alwaysOk({ members: [] });
1279
- await c.listMembersOfCompany("cmp_abc");
1280
-
1281
- const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1282
- expect((init.headers as Record<string, string>).Authorization).toBe(
1283
- "Bearer sync-token",
1284
- );
1285
- });
1286
-
1287
- it("static-string authToken still works (back-compat)", async () => {
1288
- const c = new VaultClient({
1289
- apiUrl: "https://vault.test.example.com",
1290
- authToken: "static-token",
1291
- });
1292
-
1293
- alwaysOk({ members: [] });
1294
- await c.listMembersOfCompany("cmp_abc");
1295
- await c.listMembersOfCompany("cmp_abc");
1296
-
1297
- const calls = fetchSpy.mock.calls as [string, RequestInit][];
1298
- for (const [, init] of calls) {
1299
- expect((init.headers as Record<string, string>).Authorization).toBe(
1300
- "Bearer static-token",
1301
- );
1302
- }
1303
- });
1304
- });
1305
-
1306
- // ---------------------------------------------------------------------------
1307
- // Raw vend (POST /vend, purpose-aware after US-009)
1308
- // ---------------------------------------------------------------------------
1309
-
1310
- describe("vend (POST /vend, purpose-aware)", () => {
1311
- it("POSTs to /vend with paths/operations/purpose and returns credentials + policySize", async () => {
1312
- fetchSpy.mockResolvedValueOnce(
1313
- jsonResponse(200, {
1314
- credentials: {
1315
- accessKeyId: "AKIA-BROWSE",
1316
- secretAccessKey: "secret",
1317
- sessionToken: "token",
1318
- expiration: "2026-05-20T13:00:00.000Z",
1319
- },
1320
- paths: ["shared/docs/"],
1321
- operations: "read-only",
1322
- purpose: "browse",
1323
- policySize: 412,
1324
- requestId: "req_xyz",
1325
- }),
1326
- );
1327
-
1328
- const out = await client.vend({
1329
- paths: ["shared/docs/"],
1330
- operations: "read-only",
1331
- purpose: "browse",
1332
- });
1333
-
1334
- expect(out.purpose).toBe("browse");
1335
- expect(out.policySize).toBe(412);
1336
- expect(out.credentials.accessKeyId).toBe("AKIA-BROWSE");
1337
- expect(out.credentials.sessionToken).toBe("token");
1338
-
1339
- const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1340
- expect(url).toBe("https://vault.test.example.com/vend");
1341
- expect((init.method as string).toUpperCase()).toBe("POST");
1342
- expect(JSON.parse(init.body as string)).toEqual({
1343
- paths: ["shared/docs/"],
1344
- operations: "read-only",
1345
- purpose: "browse",
1346
- });
1347
- });
1348
-
1349
- it("forwards purpose='sync' verbatim (no client-side defaulting)", async () => {
1350
- fetchSpy.mockResolvedValueOnce(
1351
- jsonResponse(200, {
1352
- credentials: {
1353
- accessKeyId: "k",
1354
- secretAccessKey: "s",
1355
- sessionToken: "t",
1356
- expiration: "2026-05-20T13:00:00.000Z",
1357
- },
1358
- paths: ["shared/"],
1359
- operations: "read-only",
1360
- purpose: "sync",
1361
- policySize: 200,
1362
- }),
1363
- );
1364
-
1365
- await client.vend({
1366
- paths: ["shared/"],
1367
- operations: "read-only",
1368
- purpose: "sync",
1369
- duration: 1800,
1370
- });
1371
-
1372
- const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
1373
- expect(JSON.parse(init.body as string)).toEqual({
1374
- paths: ["shared/"],
1375
- operations: "read-only",
1376
- purpose: "sync",
1377
- duration: 1800,
1378
- });
1379
- });
1380
- });