@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -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 +165 -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/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. 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
- });