@indigoai-us/hq-cli 5.77.14 → 5.79.0

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 (309) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. package/vitest.e2e.config.ts +0 -7
@@ -1,1474 +0,0 @@
1
- /**
2
- * Unit tests for `hq members invite|list|revoke|set-role` (members.ts).
3
- *
4
- * Coverage:
5
- * - detectTarget — pure validation for email vs personUid vs invalid
6
- * - getCallerPersonUid — happy path + missing-person-entity branch
7
- * - inviteMember — email + personUid targets, --paths gating, HTTP errors
8
- * - listPendingInvites — happy path + 403
9
- * - revokeInvite — happy path + 404
10
- * - set-role CLI surface — forwards membershipKey/newRole and surfaces 403
11
- */
12
-
13
- import { Command } from "commander";
14
- import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
15
-
16
- vi.mock("../utils/cognito-session.js", async (importOriginal) => {
17
- const original =
18
- await importOriginal<typeof import("../utils/cognito-session.js")>();
19
- return {
20
- ...original,
21
- ensureCognitoToken: vi.fn(async () => "test-token"),
22
- };
23
- });
24
-
25
- vi.mock("../utils/vault-api.js", async (importOriginal) => {
26
- const original = await importOriginal<typeof import("../utils/vault-api.js")>();
27
- return {
28
- ...original,
29
- getCompanyUid: vi.fn(async () => "cmp_acme"),
30
- };
31
- });
32
-
33
- import { ensureCognitoToken } from "../utils/cognito-session.js";
34
- import { getCompanyUid } from "../utils/vault-api.js";
35
- import {
36
- InviteHttpError,
37
- changeMemberRole,
38
- detectTarget,
39
- formatInviteHttpError,
40
- getCallerPersonUid,
41
- inviteMember,
42
- listActiveMembers,
43
- listPendingInvites,
44
- registerMembersCommand,
45
- resendInvite,
46
- resolveRoleChangeTarget,
47
- resolveRevokeTargetToMembershipKey,
48
- revokeInvite,
49
- } from "./members.js";
50
-
51
- function jsonResponse(status: number, body: unknown): Response {
52
- return new Response(JSON.stringify(body), {
53
- status,
54
- headers: { "Content-Type": "application/json" },
55
- });
56
- }
57
-
58
- let fetchSpy: MockInstance<typeof fetch>;
59
- const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
60
- const mockGetCompanyUid = vi.mocked(getCompanyUid);
61
-
62
- beforeEach(() => {
63
- vi.clearAllMocks();
64
- fetchSpy = vi.spyOn(globalThis, "fetch");
65
- mockEnsureCognitoToken.mockResolvedValue("test-token");
66
- mockGetCompanyUid.mockResolvedValue("cmp_acme");
67
- });
68
-
69
- afterEach(() => {
70
- vi.restoreAllMocks();
71
- });
72
-
73
- function buildMembersProgram(): Command {
74
- const program = new Command();
75
- program.name("hq").exitOverride();
76
- registerMembersCommand(program);
77
- return program;
78
- }
79
-
80
- // ---------------------------------------------------------------------------
81
- // detectTarget
82
- // ---------------------------------------------------------------------------
83
-
84
- describe("detectTarget", () => {
85
- it("recognizes plain emails and lowercases them", () => {
86
- expect(detectTarget("Alice@Example.com")).toEqual({
87
- type: "email",
88
- value: "alice@example.com",
89
- });
90
- });
91
-
92
- it("recognizes person UIDs", () => {
93
- expect(detectTarget("prs_bob123")).toEqual({
94
- type: "person",
95
- value: "prs_bob123",
96
- });
97
- });
98
-
99
- it("recognizes fleet agent uids", () => {
100
- expect(detectTarget("agt_01HXYZABCDEFGHJKMNPQRSTVWX")).toEqual({
101
- type: "agent",
102
- value: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
103
- isAgent: true,
104
- });
105
- });
106
-
107
- it("recognizes fleet agent machine emails", () => {
108
- expect(
109
- detectTarget("agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai"),
110
- ).toEqual({
111
- type: "email",
112
- value: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
113
- isAgent: true,
114
- });
115
- });
116
-
117
- it("returns null for invalid targets", () => {
118
- expect(detectTarget("not-a-target")).toBeNull();
119
- expect(detectTarget("cmp_company")).toBeNull();
120
- expect(detectTarget("")).toBeNull();
121
- });
122
- });
123
-
124
- // ---------------------------------------------------------------------------
125
- // getCallerPersonUid
126
- // ---------------------------------------------------------------------------
127
-
128
- describe("getCallerPersonUid", () => {
129
- it("returns the personUid from the first membership", async () => {
130
- fetchSpy.mockResolvedValueOnce(
131
- jsonResponse(200, {
132
- memberships: [
133
- { membershipKey: "k1", personUid: "prs_admin", companyUid: "cmp_a", role: "owner", status: "active" },
134
- ],
135
- }),
136
- );
137
-
138
- const uid = await getCallerPersonUid("test-token");
139
- expect(uid).toBe("prs_admin");
140
- });
141
-
142
- it("throws if the caller has no person entity yet", async () => {
143
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { memberships: [] }));
144
- await expect(getCallerPersonUid("test-token")).rejects.toThrow(/no person entity/);
145
- });
146
-
147
- it("throws on auth failure", async () => {
148
- fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized" }));
149
- await expect(getCallerPersonUid("test-token")).rejects.toThrow(/run `hq login`/);
150
- });
151
- });
152
-
153
- // ---------------------------------------------------------------------------
154
- // inviteMember
155
- // ---------------------------------------------------------------------------
156
-
157
- describe("inviteMember", () => {
158
- it("creates an invite for an email target and returns a magic link", async () => {
159
- fetchSpy.mockResolvedValueOnce(
160
- jsonResponse(200, {
161
- membership: { role: "member", status: "pending" },
162
- inviteToken: "tok_abc",
163
- }),
164
- );
165
-
166
- const result = await inviteMember({
167
- target: "alice@example.com",
168
- role: "member",
169
- companyUid: "cmp_acme",
170
- callerUid: "prs_admin",
171
- token: "test-token",
172
- });
173
-
174
- expect(result.magicLink).toBe("hq://accept/tok_abc");
175
- expect(result.membership.role).toBe("member");
176
-
177
- const call = fetchSpy.mock.calls[0];
178
- const body = JSON.parse((call[1]?.body as string) ?? "{}");
179
- expect(body).toEqual({
180
- companyUid: "cmp_acme",
181
- role: "member",
182
- invitedBy: "prs_admin",
183
- inviteeEmail: "alice@example.com",
184
- });
185
- });
186
-
187
- it("creates an invite for a personUid target", async () => {
188
- fetchSpy.mockResolvedValueOnce(
189
- jsonResponse(200, {
190
- membership: { role: "admin", status: "pending" },
191
- inviteToken: "tok_456",
192
- }),
193
- );
194
-
195
- await inviteMember({
196
- target: "prs_bob",
197
- role: "admin",
198
- companyUid: "cmp_acme",
199
- callerUid: "prs_admin",
200
- token: "test-token",
201
- });
202
-
203
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
204
- expect(body.personUid).toBe("prs_bob");
205
- expect(body.inviteeEmail).toBeUndefined();
206
- });
207
-
208
- it("forwards allowedPrefixes when --paths is set with --role guest", async () => {
209
- fetchSpy.mockResolvedValueOnce(
210
- jsonResponse(200, {
211
- membership: { role: "guest", status: "pending" },
212
- inviteToken: "tok_guest",
213
- }),
214
- );
215
-
216
- await inviteMember({
217
- target: "alice@example.com",
218
- role: "guest",
219
- paths: "docs/, shared/",
220
- companyUid: "cmp_acme",
221
- callerUid: "prs_admin",
222
- token: "test-token",
223
- });
224
-
225
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
226
- expect(body.allowedPrefixes).toEqual(["docs/", "shared/"]);
227
- });
228
-
229
- it("rejects --paths with a non-guest role", async () => {
230
- await expect(
231
- inviteMember({
232
- target: "alice@example.com",
233
- role: "member",
234
- paths: "docs/",
235
- companyUid: "cmp_acme",
236
- callerUid: "prs_admin",
237
- token: "test-token",
238
- }),
239
- ).rejects.toThrow(/--paths is only valid with --role guest/);
240
- expect(fetchSpy).not.toHaveBeenCalled();
241
- });
242
-
243
- it("rejects an invalid target", async () => {
244
- await expect(
245
- inviteMember({
246
- target: "not-a-target",
247
- role: "member",
248
- companyUid: "cmp_acme",
249
- callerUid: "prs_admin",
250
- token: "test-token",
251
- }),
252
- ).rejects.toThrow(/Invalid target/);
253
- expect(fetchSpy).not.toHaveBeenCalled();
254
- });
255
-
256
- it("invites a fleet agent by uid as member via /membership/invite", async () => {
257
- fetchSpy.mockResolvedValueOnce(
258
- jsonResponse(201, {
259
- membership: {
260
- membershipKey: "agt_01HXYZ#cmp_acme",
261
- role: "member",
262
- status: "active",
263
- },
264
- agentUid: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
265
- hostCompanyUid: "cmp_host",
266
- activationBilled: false,
267
- }),
268
- );
269
-
270
- const result = await inviteMember({
271
- target: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
272
- role: "member",
273
- companyUid: "cmp_acme",
274
- callerUid: "prs_admin",
275
- token: "test-token",
276
- });
277
-
278
- expect(result.membership.status).toBe("active");
279
- expect(result.membership.role).toBe("member");
280
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
281
- expect(body.personUid).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX");
282
- expect(body.role).toBe("member");
283
- });
284
-
285
- it("rejects owner/guest roles for agent invites", async () => {
286
- await expect(
287
- inviteMember({
288
- target: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
289
- role: "owner",
290
- companyUid: "cmp_acme",
291
- callerUid: "prs_admin",
292
- token: "test-token",
293
- }),
294
- ).rejects.toThrow(/member or admin/);
295
- expect(fetchSpy).not.toHaveBeenCalled();
296
- });
297
-
298
- it("rejects an unknown role", async () => {
299
- await expect(
300
- inviteMember({
301
- target: "alice@example.com",
302
- role: "superuser",
303
- companyUid: "cmp_acme",
304
- callerUid: "prs_admin",
305
- token: "test-token",
306
- }),
307
- ).rejects.toThrow(/Invalid role/);
308
- expect(fetchSpy).not.toHaveBeenCalled();
309
- });
310
-
311
- it("wraps non-2xx responses in InviteHttpError", async () => {
312
- fetchSpy.mockResolvedValueOnce(jsonResponse(409, { error: "duplicate" }));
313
-
314
- await expect(
315
- inviteMember({
316
- target: "alice@example.com",
317
- role: "member",
318
- companyUid: "cmp_acme",
319
- callerUid: "prs_admin",
320
- token: "test-token",
321
- }),
322
- ).rejects.toBeInstanceOf(InviteHttpError);
323
- });
324
-
325
- // HQ-11: inviting an already-active member returns 409 with the machine code
326
- // MEMBERSHIP_ALREADY_EXISTS. The code must ride on the InviteHttpError so the
327
- // `invite` command can treat it as a terminal "already a member" (no retry).
328
- it("surfaces the server `code` (MEMBERSHIP_ALREADY_EXISTS) on the InviteHttpError", async () => {
329
- fetchSpy.mockResolvedValueOnce(
330
- jsonResponse(409, {
331
- error:
332
- "Membership already exists for person email:alice@example.com in company cmp_acme",
333
- code: "MEMBERSHIP_ALREADY_EXISTS",
334
- }),
335
- );
336
-
337
- const err = await inviteMember({
338
- target: "alice@example.com",
339
- role: "member",
340
- companyUid: "cmp_acme",
341
- callerUid: "prs_admin",
342
- token: "test-token",
343
- }).catch((e: unknown) => e);
344
-
345
- expect(err).toBeInstanceOf(InviteHttpError);
346
- expect((err as InviteHttpError).status).toBe(409);
347
- expect((err as InviteHttpError).code).toBe("MEMBERSHIP_ALREADY_EXISTS");
348
- });
349
-
350
- it("resolves the token when it is nested on the membership row", async () => {
351
- fetchSpy.mockResolvedValueOnce(
352
- jsonResponse(200, {
353
- membership: {
354
- role: "admin",
355
- status: "pending",
356
- inviteToken: "tok_nested",
357
- },
358
- }),
359
- );
360
-
361
- const result = await inviteMember({
362
- target: "alice@example.com",
363
- role: "admin",
364
- companyUid: "cmp_acme",
365
- callerUid: "prs_admin",
366
- token: "test-token",
367
- });
368
-
369
- expect(result.inviteToken).toBe("tok_nested");
370
- expect(result.magicLink).toBe("hq://accept/tok_nested");
371
- });
372
-
373
- it("schemaVersion 2: no inviteToken in response → success with undefined magicLink", async () => {
374
- // 2026-05-21 fix: the server moved to email-keyed authoritative
375
- // membership rows (schemaVersion 2) — the invite IS created, but
376
- // there is no token to redeem. The CLI must NOT throw here; instead
377
- // the action handler prints "sign in with the invited email"
378
- // instructions. Previously this case threw "did not include an
379
- // invite token" which produced false-failure UX on a working invite.
380
- fetchSpy.mockResolvedValueOnce(
381
- jsonResponse(201, {
382
- membership: {
383
- membershipKey: "email:alice@example.com#cmp_acme",
384
- role: "member",
385
- status: "pending",
386
- inviteeEmail: "alice@example.com",
387
- schemaVersion: 2,
388
- },
389
- }),
390
- );
391
-
392
- const result = await inviteMember({
393
- target: "alice@example.com",
394
- role: "member",
395
- companyUid: "cmp_acme",
396
- callerUid: "prs_admin",
397
- token: "test-token",
398
- });
399
-
400
- expect(result.inviteToken).toBeUndefined();
401
- expect(result.magicLink).toBeUndefined();
402
- expect(result.membership.role).toBe("member");
403
- expect(result.membership.status).toBe("pending");
404
- expect(result.membership.membershipKey).toBe(
405
- "email:alice@example.com#cmp_acme",
406
- );
407
- expect(result.membership.inviteeEmail).toBe("alice@example.com");
408
- });
409
-
410
- it("throws when the response has no membership row at all (server bug)", async () => {
411
- // Belt-and-suspenders: a 2xx response with NO membership row is a
412
- // server-side regression — surface it loudly so it doesn't silently
413
- // succeed-but-do-nothing.
414
- fetchSpy.mockResolvedValueOnce(jsonResponse(201, {}));
415
-
416
- await expect(
417
- inviteMember({
418
- target: "alice@example.com",
419
- role: "admin",
420
- companyUid: "cmp_acme",
421
- callerUid: "prs_admin",
422
- token: "test-token",
423
- }),
424
- ).rejects.toThrow(/no membership row/);
425
- });
426
-
427
- // ---- sendEmail flag (hq-pro PR #140 contract) ---------------------------
428
-
429
- it("forwards `sendEmail: true` in the request body when the option is set", async () => {
430
- fetchSpy.mockResolvedValueOnce(
431
- jsonResponse(201, {
432
- membership: {
433
- role: "member",
434
- status: "pending",
435
- inviteeEmail: "alice@example.com",
436
- schemaVersion: 2,
437
- },
438
- resent: false,
439
- emailSent: true,
440
- emailSkipped: false,
441
- }),
442
- );
443
-
444
- const result = await inviteMember({
445
- target: "alice@example.com",
446
- role: "member",
447
- sendEmail: true,
448
- companyUid: "cmp_acme",
449
- callerUid: "prs_admin",
450
- token: "test-token",
451
- });
452
-
453
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
454
- expect(body.sendEmail).toBe(true);
455
- expect(result.emailSent).toBe(true);
456
- expect(result.emailSkipped).toBe(false);
457
- expect(result.emailError).toBeUndefined();
458
- });
459
-
460
- it("omits sendEmail from the body when the option is unset (backward-compat with pre-resend hq-pro)", async () => {
461
- fetchSpy.mockResolvedValueOnce(
462
- jsonResponse(201, {
463
- membership: {
464
- role: "member",
465
- status: "pending",
466
- inviteeEmail: "alice@example.com",
467
- schemaVersion: 2,
468
- },
469
- }),
470
- );
471
-
472
- const result = await inviteMember({
473
- target: "alice@example.com",
474
- role: "member",
475
- companyUid: "cmp_acme",
476
- callerUid: "prs_admin",
477
- token: "test-token",
478
- });
479
-
480
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
481
- expect("sendEmail" in body).toBe(false);
482
- // Pre-resend hq-pro doesn't send the email fields at all — the CLI
483
- // result fields stay undefined so the caller can detect "server has no
484
- // opinion" vs "server explicitly skipped".
485
- expect(result.emailSent).toBeUndefined();
486
- expect(result.emailSkipped).toBeUndefined();
487
- });
488
-
489
- it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
490
- fetchSpy.mockResolvedValueOnce(
491
- jsonResponse(201, {
492
- membership: {
493
- role: "member",
494
- status: "pending",
495
- inviteeEmail: "alice@example.com",
496
- schemaVersion: 2,
497
- },
498
- resent: false,
499
- emailSent: false,
500
- emailSkipped: true,
501
- }),
502
- );
503
-
504
- const result = await inviteMember({
505
- target: "alice@example.com",
506
- role: "member",
507
- sendEmail: true,
508
- companyUid: "cmp_acme",
509
- callerUid: "prs_admin",
510
- token: "test-token",
511
- });
512
-
513
- expect(result.emailSent).toBe(false);
514
- expect(result.emailSkipped).toBe(true);
515
- });
516
-
517
- it("surfaces emailError when the server-side Resend send failed", async () => {
518
- fetchSpy.mockResolvedValueOnce(
519
- jsonResponse(201, {
520
- membership: {
521
- role: "member",
522
- status: "pending",
523
- inviteeEmail: "alice@example.com",
524
- schemaVersion: 2,
525
- },
526
- resent: false,
527
- emailSent: false,
528
- emailSkipped: false,
529
- emailError: "Resend 429: rate limited",
530
- }),
531
- );
532
-
533
- const result = await inviteMember({
534
- target: "alice@example.com",
535
- role: "member",
536
- sendEmail: true,
537
- companyUid: "cmp_acme",
538
- callerUid: "prs_admin",
539
- token: "test-token",
540
- });
541
-
542
- expect(result.emailSent).toBe(false);
543
- expect(result.emailError).toMatch(/Resend 429/);
544
- });
545
-
546
- // ---- groupIds (hq-pro PR #140 contract) ---------------------------------
547
-
548
- it("forwards groupIds in the body when set on an email-keyed invite", async () => {
549
- fetchSpy.mockResolvedValueOnce(
550
- jsonResponse(201, {
551
- membership: {
552
- role: "member",
553
- status: "pending",
554
- inviteeEmail: "alice@example.com",
555
- schemaVersion: 2,
556
- },
557
- }),
558
- );
559
-
560
- await inviteMember({
561
- target: "alice@example.com",
562
- role: "member",
563
- groupIds: ["grp-1", "grp-2"],
564
- companyUid: "cmp_acme",
565
- callerUid: "prs_admin",
566
- token: "test-token",
567
- });
568
-
569
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
570
- expect(body.groupIds).toEqual(["grp-1", "grp-2"]);
571
- });
572
-
573
- it("rejects --groups combined with a personUid target before hitting the server", async () => {
574
- await expect(
575
- inviteMember({
576
- target: "prs_bob",
577
- role: "member",
578
- groupIds: ["grp-1"],
579
- companyUid: "cmp_acme",
580
- callerUid: "prs_admin",
581
- token: "test-token",
582
- }),
583
- ).rejects.toThrow(/email-keyed invites/);
584
- expect(fetchSpy).not.toHaveBeenCalled();
585
- });
586
-
587
- it("omits groupIds from the body when the array is empty", async () => {
588
- fetchSpy.mockResolvedValueOnce(
589
- jsonResponse(201, {
590
- membership: {
591
- role: "member",
592
- status: "pending",
593
- inviteeEmail: "alice@example.com",
594
- schemaVersion: 2,
595
- },
596
- }),
597
- );
598
-
599
- await inviteMember({
600
- target: "alice@example.com",
601
- role: "member",
602
- groupIds: [],
603
- companyUid: "cmp_acme",
604
- callerUid: "prs_admin",
605
- token: "test-token",
606
- });
607
-
608
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
609
- expect("groupIds" in body).toBe(false);
610
- });
611
- });
612
-
613
- // ---------------------------------------------------------------------------
614
- // resendInvite (hq-pro PR #140 resend: true short-circuit)
615
- // ---------------------------------------------------------------------------
616
-
617
- describe("resendInvite", () => {
618
- it("posts { resend: true, inviteeEmail, companyUid, role: 'member' } and returns the resent result", async () => {
619
- fetchSpy.mockResolvedValueOnce(
620
- jsonResponse(200, {
621
- resent: true,
622
- emailSent: true,
623
- emailSkipped: false,
624
- }),
625
- );
626
-
627
- const result = await resendInvite({
628
- inviteeEmail: "Alice@Example.com",
629
- companyUid: "cmp_acme",
630
- callerUid: "prs_admin",
631
- token: "test-token",
632
- });
633
-
634
- expect(result).toEqual({
635
- resent: true,
636
- emailSent: true,
637
- emailSkipped: false,
638
- });
639
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
640
- expect(body).toEqual({
641
- companyUid: "cmp_acme",
642
- // Server still requires `role` even on the resend short-circuit; the
643
- // CLI passes the safe default. Server ignores it on this path.
644
- role: "member",
645
- // Email normalized to lowercase before send.
646
- inviteeEmail: "alice@example.com",
647
- invitedBy: "prs_admin",
648
- resend: true,
649
- });
650
- });
651
-
652
- it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
653
- fetchSpy.mockResolvedValueOnce(
654
- jsonResponse(200, {
655
- resent: true,
656
- emailSent: false,
657
- emailSkipped: true,
658
- }),
659
- );
660
-
661
- const result = await resendInvite({
662
- inviteeEmail: "alice@example.com",
663
- companyUid: "cmp_acme",
664
- callerUid: "prs_admin",
665
- token: "test-token",
666
- });
667
-
668
- expect(result.emailSent).toBe(false);
669
- expect(result.emailSkipped).toBe(true);
670
- });
671
-
672
- it("throws on a non-email target — the resend path is email-keyed-row only", async () => {
673
- await expect(
674
- resendInvite({
675
- inviteeEmail: "prs_bob",
676
- companyUid: "cmp_acme",
677
- callerUid: "prs_admin",
678
- token: "test-token",
679
- }),
680
- ).rejects.toThrow(/email target/);
681
- expect(fetchSpy).not.toHaveBeenCalled();
682
- });
683
-
684
- it("wraps a 404 INVITE_NOT_PENDING in InviteHttpError so the action handler can format it", async () => {
685
- fetchSpy.mockResolvedValueOnce(
686
- jsonResponse(404, {
687
- error: "no pending row",
688
- code: "INVITE_NOT_PENDING",
689
- }),
690
- );
691
-
692
- await expect(
693
- resendInvite({
694
- inviteeEmail: "alice@example.com",
695
- companyUid: "cmp_acme",
696
- callerUid: "prs_admin",
697
- token: "test-token",
698
- }),
699
- ).rejects.toBeInstanceOf(InviteHttpError);
700
- });
701
-
702
- it("rejects a 2xx response that lacks `resent: true` — guard against a pre-resend hq-pro silently dropping the flag", async () => {
703
- fetchSpy.mockResolvedValueOnce(
704
- jsonResponse(200, { membership: { role: "member", status: "pending" } }),
705
- );
706
-
707
- await expect(
708
- resendInvite({
709
- inviteeEmail: "alice@example.com",
710
- companyUid: "cmp_acme",
711
- callerUid: "prs_admin",
712
- token: "test-token",
713
- }),
714
- ).rejects.toThrow(/doesn't support `resend: true` yet/);
715
- });
716
- });
717
-
718
- // ---------------------------------------------------------------------------
719
- // listPendingInvites
720
- // ---------------------------------------------------------------------------
721
-
722
- describe("listPendingInvites", () => {
723
- it("returns the parsed invites array", async () => {
724
- fetchSpy.mockResolvedValueOnce(
725
- jsonResponse(200, {
726
- invites: [
727
- {
728
- membershipKey: "k1",
729
- inviteeEmail: "alice@example.com",
730
- companyUid: "cmp_acme",
731
- role: "member",
732
- status: "pending",
733
- invitedBy: "prs_admin",
734
- invitedAt: "2026-05-04T12:00:00Z",
735
- },
736
- ],
737
- }),
738
- );
739
-
740
- const invites = await listPendingInvites("test-token", "cmp_acme");
741
- expect(invites).toHaveLength(1);
742
- expect(invites[0].inviteeEmail).toBe("alice@example.com");
743
- });
744
-
745
- it("throws InviteHttpError on 403", async () => {
746
- fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
747
- await expect(listPendingInvites("test-token", "cmp_acme")).rejects.toBeInstanceOf(
748
- InviteHttpError,
749
- );
750
- });
751
-
752
- it("returns [] for an empty invites array", async () => {
753
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: [] }));
754
- await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
755
- });
756
-
757
- it("returns [] when invites is null", async () => {
758
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { invites: null }));
759
- await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
760
- });
761
-
762
- it("returns [] when the invites key is missing", async () => {
763
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
764
- await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
765
- });
766
-
767
- it("schemaVersion 2: reads `pending` key (the canonical server response)", async () => {
768
- // 2026-05-21 fix: live server returns `{ pending: [...] }`, not
769
- // `{ invites: [...] }`. The CLI must read `pending` as the primary
770
- // and fall back to `invites` for older stages.
771
- fetchSpy.mockResolvedValueOnce(
772
- jsonResponse(200, {
773
- pending: [
774
- {
775
- membershipKey: "email:alice@example.com#cmp_acme",
776
- inviteeEmail: "alice@example.com",
777
- companyUid: "cmp_acme",
778
- role: "member",
779
- status: "pending",
780
- invitedBy: "prs_admin",
781
- invitedAt: "2026-05-21T12:00:00Z",
782
- schemaVersion: 2,
783
- },
784
- ],
785
- }),
786
- );
787
-
788
- const invites = await listPendingInvites("test-token", "cmp_acme");
789
- expect(invites).toHaveLength(1);
790
- expect(invites[0].membershipKey).toBe("email:alice@example.com#cmp_acme");
791
- expect(invites[0].inviteeEmail).toBe("alice@example.com");
792
- });
793
-
794
- it("prefers `pending` over legacy `invites` when both present (server transition)", async () => {
795
- // Defensive: an in-flight server deploy could briefly return BOTH
796
- // fields. CLI takes the canonical `pending` key.
797
- fetchSpy.mockResolvedValueOnce(
798
- jsonResponse(200, {
799
- pending: [{ membershipKey: "k_pending" } as never],
800
- invites: [{ membershipKey: "k_legacy" } as never],
801
- }),
802
- );
803
- const invites = await listPendingInvites("test-token", "cmp_acme");
804
- expect(invites).toHaveLength(1);
805
- expect(invites[0].membershipKey).toBe("k_pending");
806
- });
807
- });
808
-
809
- // ---------------------------------------------------------------------------
810
- // listActiveMembers
811
- // ---------------------------------------------------------------------------
812
-
813
- describe("listActiveMembers", () => {
814
- it("GETs /membership/company/{uid} and returns the members array", async () => {
815
- fetchSpy.mockResolvedValueOnce(
816
- jsonResponse(200, {
817
- members: [
818
- {
819
- membershipKey: "k1",
820
- personUid: "prs_alice",
821
- companyUid: "cmp_acme",
822
- role: "owner",
823
- status: "active",
824
- personEmail: "alice@example.com",
825
- personName: "Alice",
826
- },
827
- ],
828
- }),
829
- );
830
-
831
- const members = await listActiveMembers("test-token", "cmp_acme");
832
-
833
- const call = fetchSpy.mock.calls[0];
834
- expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
835
- expect(members).toHaveLength(1);
836
- expect(members[0].personEmail).toBe("alice@example.com");
837
- expect(members[0].role).toBe("owner");
838
- });
839
-
840
- it("returns [] when the members key is missing", async () => {
841
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
842
- await expect(
843
- listActiveMembers("test-token", "cmp_acme"),
844
- ).resolves.toEqual([]);
845
- });
846
-
847
- it("returns [] when members is null", async () => {
848
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: null }));
849
- await expect(
850
- listActiveMembers("test-token", "cmp_acme"),
851
- ).resolves.toEqual([]);
852
- });
853
-
854
- it("throws InviteHttpError on non-ok", async () => {
855
- fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
856
- await expect(
857
- listActiveMembers("test-token", "cmp_acme"),
858
- ).rejects.toBeInstanceOf(InviteHttpError);
859
- });
860
- });
861
-
862
- // ---------------------------------------------------------------------------
863
- // registerMembersCommand list
864
- // ---------------------------------------------------------------------------
865
-
866
- describe("registerMembersCommand list", () => {
867
- it("defaults to ACTIVE members: renders EMAIL/ROLE/NAME + the share hint", async () => {
868
- fetchSpy.mockResolvedValueOnce(
869
- jsonResponse(200, {
870
- members: [
871
- {
872
- membershipKey: "k1",
873
- personUid: "prs_alice",
874
- companyUid: "cmp_acme",
875
- role: "owner",
876
- status: "active",
877
- personEmail: "alice@example.com",
878
- personName: "Alice",
879
- },
880
- {
881
- membershipKey: "k2",
882
- personUid: "prs_bob",
883
- companyUid: "cmp_acme",
884
- role: "member",
885
- status: "active",
886
- personSlug: "bob",
887
- },
888
- ],
889
- }),
890
- );
891
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
892
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
893
-
894
- await buildMembersProgram().parseAsync(
895
- ["members", "--company", "acme", "list"],
896
- { from: "user" },
897
- );
898
-
899
- const call = fetchSpy.mock.calls[0];
900
- expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
901
- const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
902
- expect(output).toContain("EMAIL");
903
- expect(output).toContain("ROLE");
904
- expect(output).toContain("NAME");
905
- expect(output).toContain("alice@example.com");
906
- expect(output).toContain("owner");
907
- expect(output).toContain("Alice");
908
- // unresolved email falls back to personUid; name falls back to slug
909
- expect(output).toContain("prs_bob");
910
- expect(output).toContain("bob");
911
- expect(output).toContain(
912
- "hq secrets share <path> --with <email>",
913
- );
914
- expect(errSpy).not.toHaveBeenCalled();
915
- });
916
-
917
- it("default active: empty roster prints the no-active-members message", async () => {
918
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
919
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
920
-
921
- await buildMembersProgram().parseAsync(
922
- ["members", "--company", "acme", "list"],
923
- { from: "user" },
924
- );
925
-
926
- expect(logSpy).toHaveBeenCalledWith(
927
- expect.stringContaining("No active members found for this company."),
928
- );
929
- });
930
-
931
- it("--pending preserves the OLD pending-invites table verbatim", async () => {
932
- fetchSpy.mockResolvedValueOnce(
933
- jsonResponse(200, {
934
- pending: [
935
- {
936
- membershipKey: "email:alice@example.com#cmp_acme",
937
- inviteeEmail: "alice@example.com",
938
- companyUid: "cmp_acme",
939
- role: "member",
940
- status: "pending",
941
- invitedBy: "prs_admin",
942
- invitedAt: "2026-05-21T12:00:00Z",
943
- },
944
- ],
945
- }),
946
- );
947
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
948
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
949
-
950
- await buildMembersProgram().parseAsync(
951
- ["members", "--company", "acme", "list", "--pending"],
952
- { from: "user" },
953
- );
954
-
955
- const call = fetchSpy.mock.calls[0];
956
- expect(String(call[0])).toMatch(
957
- /\/membership\/company\/cmp_acme\/pending$/,
958
- );
959
- const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
960
- expect(output).toContain("TARGET");
961
- expect(output).toContain("INVITED_BY");
962
- expect(output).toContain("MEMBERSHIP_KEY");
963
- expect(output).toContain("alice@example.com");
964
- // the active-only share hint must NOT appear in the pending view
965
- expect(output).not.toContain("hq secrets share");
966
- expect(errSpy).not.toHaveBeenCalled();
967
- });
968
-
969
- it("--pending: empty invites prints the no-pending-invites message", async () => {
970
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { pending: [] }));
971
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
972
-
973
- await buildMembersProgram().parseAsync(
974
- ["members", "--company", "acme", "list", "--pending"],
975
- { from: "user" },
976
- );
977
-
978
- expect(logSpy).toHaveBeenCalledWith(
979
- expect.stringContaining("No pending invites for this company."),
980
- );
981
- });
982
- });
983
-
984
- // ---------------------------------------------------------------------------
985
- // revokeInvite
986
- // ---------------------------------------------------------------------------
987
-
988
- describe("revokeInvite", () => {
989
- it("posts membershipKey + companyUid", async () => {
990
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
991
-
992
- await revokeInvite("test-token", "k1", "cmp_acme");
993
-
994
- const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
995
- expect(body).toEqual({ membershipKey: "k1", companyUid: "cmp_acme" });
996
- });
997
-
998
- it("throws InviteHttpError on 404", async () => {
999
- fetchSpy.mockResolvedValueOnce(jsonResponse(404, { error: "not found" }));
1000
- await expect(revokeInvite("test-token", "k1", "cmp_acme")).rejects.toBeInstanceOf(
1001
- InviteHttpError,
1002
- );
1003
- });
1004
- });
1005
-
1006
- // ---------------------------------------------------------------------------
1007
- // registerMembersCommand set-role
1008
- // ---------------------------------------------------------------------------
1009
-
1010
- describe("registerMembersCommand set-role", () => {
1011
- it("forwards membershipKey + newRole to /membership/role", async () => {
1012
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1013
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1014
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1015
-
1016
- await buildMembersProgram().parseAsync(
1017
- ["members", "--company", "acme", "set-role", "agt_ops#cmp_acme", "admin"],
1018
- { from: "user" },
1019
- );
1020
-
1021
- expect(mockEnsureCognitoToken).toHaveBeenCalledTimes(1);
1022
- expect(mockGetCompanyUid).toHaveBeenCalledWith("test-token", "acme");
1023
- expect(fetchSpy).toHaveBeenCalledTimes(1);
1024
-
1025
- const call = fetchSpy.mock.calls[0];
1026
- expect(String(call[0])).toMatch(/\/membership\/role$/);
1027
- expect(call[1]?.method).toBe("POST");
1028
- const body = JSON.parse((call[1]?.body as string) ?? "{}");
1029
- expect(body).toEqual({
1030
- companyUid: "cmp_acme",
1031
- membershipKey: "agt_ops#cmp_acme",
1032
- newRole: "admin",
1033
- });
1034
- expect(logSpy).toHaveBeenCalledWith(
1035
- expect.stringContaining("Updated role for 'agt_ops#cmp_acme' to admin"),
1036
- );
1037
- expect(errSpy).not.toHaveBeenCalled();
1038
- });
1039
-
1040
- it("surfaces a backend 403 verbatim and exits non-zero", async () => {
1041
- fetchSpy.mockResolvedValueOnce(
1042
- jsonResponse(403, { error: "Only owners can change member roles." }),
1043
- );
1044
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1045
- vi.spyOn(console, "log").mockImplementation(() => undefined);
1046
- vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1047
- throw new Error(`__EXIT__:${code ?? 0}`);
1048
- }) as never);
1049
-
1050
- await expect(
1051
- buildMembersProgram().parseAsync(
1052
- ["members", "--company", "acme", "set-role", "agt_ops#cmp_acme", "admin"],
1053
- { from: "user" },
1054
- ),
1055
- ).rejects.toThrow("__EXIT__:1");
1056
-
1057
- expect(errSpy).toHaveBeenCalledWith(
1058
- expect.stringContaining("Only owners can change member roles."),
1059
- );
1060
- });
1061
- });
1062
-
1063
- // ---------------------------------------------------------------------------
1064
- // changeMemberRole + the unified `promote` command (alias `set-role`)
1065
- // ---------------------------------------------------------------------------
1066
-
1067
- describe("changeMemberRole", () => {
1068
- it("rejects an invalid role WITHOUT making a network call", async () => {
1069
- await expect(
1070
- changeMemberRole(
1071
- "test-token",
1072
- "cmp_acme",
1073
- "prs_x#cmp_acme",
1074
- "superuser" as never,
1075
- ),
1076
- ).rejects.toThrow("must be one of owner, admin, member, guest");
1077
- expect(fetchSpy).not.toHaveBeenCalled();
1078
- });
1079
-
1080
- it("accepts the full role set and POSTs to /membership/role", async () => {
1081
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1082
- await changeMemberRole("test-token", "cmp_acme", "prs_x#cmp_acme", "guest");
1083
- const call = fetchSpy.mock.calls[0];
1084
- expect(String(call[0])).toMatch(/\/membership\/role$/);
1085
- const body = JSON.parse((call[1]?.body as string) ?? "{}");
1086
- expect(body).toEqual({
1087
- companyUid: "cmp_acme",
1088
- membershipKey: "prs_x#cmp_acme",
1089
- newRole: "guest",
1090
- });
1091
- });
1092
- });
1093
-
1094
- describe("registerMembersCommand promote", () => {
1095
- it("resolves an email target and promotes to a full-set role the old set-role couldn't (admin)", async () => {
1096
- fetchSpy
1097
- .mockResolvedValueOnce(
1098
- jsonResponse(200, {
1099
- members: [
1100
- {
1101
- membershipKey: "prs_alice#cmp_acme",
1102
- personUid: "prs_alice",
1103
- companyUid: "cmp_acme",
1104
- role: "member",
1105
- status: "active",
1106
- personEmail: "Alice@Example.com",
1107
- },
1108
- ],
1109
- }),
1110
- )
1111
- .mockResolvedValueOnce(jsonResponse(200, {}));
1112
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1113
-
1114
- await buildMembersProgram().parseAsync(
1115
- ["members", "--company", "acme", "promote", "alice@example.com", "admin"],
1116
- { from: "user" },
1117
- );
1118
-
1119
- expect(String(fetchSpy.mock.calls[0][0])).toMatch(
1120
- /\/membership\/company\/cmp_acme$/,
1121
- );
1122
- const call = fetchSpy.mock.calls[1];
1123
- expect(String(call[0])).toMatch(/\/membership\/role$/);
1124
- const body = JSON.parse((call[1]?.body as string) ?? "{}");
1125
- expect(body).toEqual({
1126
- companyUid: "cmp_acme",
1127
- membershipKey: "prs_alice#cmp_acme",
1128
- newRole: "admin",
1129
- });
1130
- expect(body.membershipKey).not.toContain("email:");
1131
- expect(logSpy).toHaveBeenCalledWith(
1132
- expect.stringContaining("Updated role for 'alice@example.com' to admin"),
1133
- );
1134
- // Clarifies that admins already have full context access automatically
1135
- // (no separate grant) and that sync mode governs local visibility.
1136
- expect(logSpy).toHaveBeenCalledWith(
1137
- expect.stringContaining(
1138
- "Admins automatically have full read and write access",
1139
- ),
1140
- );
1141
- expect(logSpy).toHaveBeenCalledWith(
1142
- expect.stringContaining("governed by their sync mode"),
1143
- );
1144
- });
1145
-
1146
- it("fails clearly when no active member matches an email and never posts a role change", async () => {
1147
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
1148
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1149
- vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1150
- throw new Error(`__EXIT__:${code ?? 0}`);
1151
- }) as never);
1152
-
1153
- await expect(
1154
- buildMembersProgram().parseAsync(
1155
- [
1156
- "members",
1157
- "--company",
1158
- "acme",
1159
- "promote",
1160
- "missing@example.com",
1161
- "admin",
1162
- ],
1163
- { from: "user" },
1164
- ),
1165
- ).rejects.toThrow("__EXIT__:1");
1166
-
1167
- const errorOutput = errSpy.mock.calls
1168
- .flatMap((call) => call.map(String))
1169
- .join(" ");
1170
- expect(errorOutput).toContain(
1171
- "No active member has email 'missing@example.com'",
1172
- );
1173
- expect(errorOutput).toContain("hq members list");
1174
- expect(fetchSpy).toHaveBeenCalledTimes(1);
1175
- expect(
1176
- fetchSpy.mock.calls.some(
1177
- ([url, init]) =>
1178
- String(url).endsWith("/membership/role") && init?.method === "POST",
1179
- ),
1180
- ).toBe(false);
1181
- });
1182
-
1183
- it("forwards a personUid target and the guest role (beyond set-role's admin|member cap)", async () => {
1184
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
1185
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1186
-
1187
- await buildMembersProgram().parseAsync(
1188
- ["members", "--company", "acme", "promote", "prs_bob", "guest"],
1189
- { from: "user" },
1190
- );
1191
-
1192
- const body = JSON.parse(
1193
- (fetchSpy.mock.calls[0]?.[1]?.body as string) ?? "{}",
1194
- );
1195
- expect(body).toEqual({
1196
- companyUid: "cmp_acme",
1197
- membershipKey: "prs_bob#cmp_acme",
1198
- newRole: "guest",
1199
- });
1200
- // The full-access clarification applies ONLY to the role-bypass roles
1201
- // (owner/admin) — a guest/member promotion must not claim full access.
1202
- expect(logSpy).not.toHaveBeenCalledWith(
1203
- expect.stringContaining("full read and write access"),
1204
- );
1205
- });
1206
-
1207
- it("rejects an invalid role at the CLI and exits non-zero without a network call", async () => {
1208
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1209
- vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1210
- throw new Error(`__EXIT__:${code ?? 0}`);
1211
- }) as never);
1212
-
1213
- await expect(
1214
- buildMembersProgram().parseAsync(
1215
- ["members", "--company", "acme", "promote", "alice@example.com", "wizard"],
1216
- { from: "user" },
1217
- ),
1218
- ).rejects.toThrow("__EXIT__:1");
1219
-
1220
- expect(errSpy).toHaveBeenCalledWith(
1221
- expect.stringContaining("must be one of owner, admin, member, guest"),
1222
- );
1223
- expect(fetchSpy).not.toHaveBeenCalled();
1224
- });
1225
-
1226
- it("surfaces a backend 403 verbatim (server-enforced authorization)", async () => {
1227
- fetchSpy.mockResolvedValueOnce(
1228
- jsonResponse(403, { error: "Only an owner may promote a member to owner" }),
1229
- );
1230
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1231
- vi.spyOn(console, "log").mockImplementation(() => undefined);
1232
- vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
1233
- throw new Error(`__EXIT__:${code ?? 0}`);
1234
- }) as never);
1235
-
1236
- await expect(
1237
- buildMembersProgram().parseAsync(
1238
- ["members", "--company", "acme", "promote", "prs_bob", "owner"],
1239
- { from: "user" },
1240
- ),
1241
- ).rejects.toThrow("__EXIT__:1");
1242
-
1243
- expect(errSpy).toHaveBeenCalledWith(
1244
- expect.stringContaining("Only an owner may promote a member to owner"),
1245
- );
1246
- });
1247
- });
1248
-
1249
- describe("resolveRoleChangeTarget", () => {
1250
- it("passes through a full membership key without fetching members", async () => {
1251
- await expect(
1252
- resolveRoleChangeTarget(
1253
- "test-token",
1254
- "cmp_acme",
1255
- "prs_alice#cmp_acme",
1256
- ),
1257
- ).resolves.toBe("prs_alice#cmp_acme");
1258
- expect(fetchSpy).not.toHaveBeenCalled();
1259
- });
1260
-
1261
- it("wraps bare person and agent uids without fetching members", async () => {
1262
- await expect(
1263
- resolveRoleChangeTarget("test-token", "cmp_acme", "prs_alice"),
1264
- ).resolves.toBe("prs_alice#cmp_acme");
1265
- await expect(
1266
- resolveRoleChangeTarget("test-token", "cmp_acme", "agt_ops"),
1267
- ).resolves.toBe("agt_ops#cmp_acme");
1268
- expect(fetchSpy).not.toHaveBeenCalled();
1269
- });
1270
-
1271
- it("maps agent machine emails to agt_…#companyUid without fetching members", async () => {
1272
- await expect(
1273
- resolveRoleChangeTarget(
1274
- "test-token",
1275
- "cmp_acme",
1276
- "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
1277
- ),
1278
- ).resolves.toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_acme");
1279
- expect(fetchSpy).not.toHaveBeenCalled();
1280
- });
1281
- });
1282
-
1283
- // ---------------------------------------------------------------------------
1284
- // registerMembersCommand invite — MEMBERSHIP_ALREADY_EXISTS messaging
1285
- // ---------------------------------------------------------------------------
1286
-
1287
- describe("registerMembersCommand invite 409 messaging", () => {
1288
- function mockCallerIdentity(): void {
1289
- fetchSpy.mockResolvedValueOnce(
1290
- jsonResponse(200, {
1291
- memberships: [
1292
- {
1293
- membershipKey: "prs_admin#cmp_acme",
1294
- personUid: "prs_admin",
1295
- companyUid: "cmp_acme",
1296
- role: "owner",
1297
- status: "active",
1298
- },
1299
- ],
1300
- }),
1301
- );
1302
- }
1303
-
1304
- it("guides --resend when the conflict is a pending invite", async () => {
1305
- mockCallerIdentity();
1306
- fetchSpy
1307
- .mockResolvedValueOnce(
1308
- jsonResponse(409, {
1309
- error: "Membership already exists",
1310
- code: "MEMBERSHIP_ALREADY_EXISTS",
1311
- }),
1312
- )
1313
- .mockResolvedValueOnce(
1314
- jsonResponse(200, {
1315
- pending: [
1316
- {
1317
- membershipKey: "email:alice@example.com#cmp_acme",
1318
- inviteeEmail: "alice@example.com",
1319
- companyUid: "cmp_acme",
1320
- role: "member",
1321
- status: "pending",
1322
- invitedBy: "prs_admin",
1323
- invitedAt: "2026-05-21T12:00:00Z",
1324
- },
1325
- ],
1326
- }),
1327
- );
1328
-
1329
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1330
- const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1331
-
1332
- await buildMembersProgram().parseAsync(
1333
- ["members", "--company", "acme", "invite", "Alice@Example.com"],
1334
- { from: "user" },
1335
- );
1336
-
1337
- const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
1338
- expect(output).toContain("already has a pending invite");
1339
- expect(output).toContain(
1340
- "hq members invite alice@example.com --company acme --resend",
1341
- );
1342
- expect(output).not.toContain("already a member of this company");
1343
- expect(errSpy).not.toHaveBeenCalled();
1344
- });
1345
-
1346
- it("keeps the already-member note when the conflict is an active member", async () => {
1347
- mockCallerIdentity();
1348
- fetchSpy
1349
- .mockResolvedValueOnce(
1350
- jsonResponse(409, {
1351
- error: "Membership already exists",
1352
- code: "MEMBERSHIP_ALREADY_EXISTS",
1353
- }),
1354
- )
1355
- .mockResolvedValueOnce(jsonResponse(200, { pending: [] }))
1356
- .mockResolvedValueOnce(
1357
- jsonResponse(200, {
1358
- members: [
1359
- {
1360
- membershipKey: "prs_alice#cmp_acme",
1361
- personUid: "prs_alice",
1362
- companyUid: "cmp_acme",
1363
- role: "member",
1364
- status: "active",
1365
- personEmail: "alice@example.com",
1366
- },
1367
- ],
1368
- }),
1369
- );
1370
-
1371
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
1372
-
1373
- await buildMembersProgram().parseAsync(
1374
- ["members", "--company", "acme", "invite", "alice@example.com"],
1375
- { from: "user" },
1376
- );
1377
-
1378
- const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
1379
- expect(output).toContain("already a member of this company");
1380
- expect(output).not.toContain("pending invite");
1381
- });
1382
- });
1383
-
1384
- // ---------------------------------------------------------------------------
1385
- // resolveRevokeTargetToMembershipKey
1386
- // ---------------------------------------------------------------------------
1387
-
1388
- describe("resolveRevokeTargetToMembershipKey", () => {
1389
- it("wraps fleet agent uids as agt_…#companyUid", () => {
1390
- expect(
1391
- resolveRevokeTargetToMembershipKey(
1392
- "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
1393
- "cmp_abc",
1394
- ),
1395
- ).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
1396
- });
1397
-
1398
- it("maps agent machine emails to agt_…#companyUid (active guest memberships)", () => {
1399
- expect(
1400
- resolveRevokeTargetToMembershipKey(
1401
- "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
1402
- "cmp_abc",
1403
- ),
1404
- ).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
1405
- });
1406
-
1407
- // Regression: `hq members revoke alice@example.com` used to send the raw
1408
- // email straight to /membership/revoke, which the server rejects with 404
1409
- // "Invite not found" because it keys on `email:<email>#<companyUid>`. Live
1410
- // smoke 2026-05-21 reproduced this against indigo.
1411
- it("wraps a bare email into the email-keyed membership shape", () => {
1412
- expect(
1413
- resolveRevokeTargetToMembershipKey("alice@example.com", "cmp_abc"),
1414
- ).toBe("email:alice@example.com#cmp_abc");
1415
- });
1416
-
1417
- it("lowercases the email when wrapping", () => {
1418
- expect(
1419
- resolveRevokeTargetToMembershipKey("Alice@Example.COM", "cmp_abc"),
1420
- ).toBe("email:alice@example.com#cmp_abc");
1421
- });
1422
-
1423
- it("wraps a bare personUid into the personUid-keyed membership shape", () => {
1424
- expect(resolveRevokeTargetToMembershipKey("prs_bob123", "cmp_abc")).toBe(
1425
- "prs_bob123#cmp_abc",
1426
- );
1427
- });
1428
-
1429
- it("passes through a full email-keyed membership key unchanged", () => {
1430
- expect(
1431
- resolveRevokeTargetToMembershipKey(
1432
- "email:alice@example.com#cmp_abc",
1433
- "cmp_other",
1434
- ),
1435
- ).toBe("email:alice@example.com#cmp_abc");
1436
- });
1437
-
1438
- it("passes through a full personUid-keyed membership key unchanged", () => {
1439
- expect(
1440
- resolveRevokeTargetToMembershipKey("prs_bob#cmp_abc", "cmp_other"),
1441
- ).toBe("prs_bob#cmp_abc");
1442
- });
1443
-
1444
- it("passes through unrecognized strings (legacy inviteToken, garbage)", () => {
1445
- // A schemaVersion-1 inviteToken is opaque base64url; we can't tell it
1446
- // apart from garbage. Send it to the server and let the server 404 if
1447
- // it doesn't resolve.
1448
- expect(
1449
- resolveRevokeTargetToMembershipKey("some-opaque-token", "cmp_abc"),
1450
- ).toBe("some-opaque-token");
1451
- });
1452
- });
1453
-
1454
- // ---------------------------------------------------------------------------
1455
- // formatInviteHttpError
1456
- // ---------------------------------------------------------------------------
1457
-
1458
- describe("formatInviteHttpError", () => {
1459
- it("maps 401 to a login hint", () => {
1460
- expect(formatInviteHttpError(401, "ignored")).toMatch(/run `hq login`/);
1461
- });
1462
- it("maps 403 to admin/owner hint", () => {
1463
- expect(formatInviteHttpError(403, "ignored")).toMatch(/admins and owners/);
1464
- });
1465
- it("maps 409 to duplicate-invite hint", () => {
1466
- expect(formatInviteHttpError(409, "ignored")).toMatch(/already has a membership/);
1467
- });
1468
- it("prefixes 5xx with 'Server error:'", () => {
1469
- expect(formatInviteHttpError(500, "boom")).toBe("Server error: boom");
1470
- });
1471
- it("falls through to the message for unmapped statuses", () => {
1472
- expect(formatInviteHttpError(400, "bad input")).toBe("bad input");
1473
- });
1474
- });