@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.
- package/CHANGELOG.md +34 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +51 -2
- package/dist/commands/pkg-install.js +43 -0
- package/dist/commands/run.js +8 -0
- package/dist/commands/secrets.js +125 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2247
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -71
- package/src/commands/pkg-install.ts +0 -215
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -184
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2037
- package/src/commands/secrets.ts +0 -2240
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- 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
|
-
});
|