@indigoai-us/hq-cli 5.77.13 → 5.78.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 (307) hide show
  1. package/CHANGELOG.md +34 -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/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. 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
- });