@indigoai-us/hq-cli 5.30.0 → 5.32.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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Unit tests for `hq group-grants grant|revoke|outbound|inbound`
3
+ * (group-grants.ts).
4
+ *
5
+ * Mirrors members.test.ts: mock `globalThis.fetch`, exercise the exported
6
+ * helper functions, assert the request body the client sends, and assert HTTP
7
+ * errors (esp. 403 cross-tenant) are wrapped + surfaced actionably.
8
+ *
9
+ * Covers the two story e2e behaviors:
10
+ * 1. Operator WITH invite rights on B grants (G, B, member) → client POSTs
11
+ * /group-grants with {groupId:G, sourceCompanyUid, targetCompanyUid:B,
12
+ * role:'member'} and returns the grant.
13
+ * 2. Operator with NO role on independent C grants (G, C) → backend 403 →
14
+ * helper throws GrantHttpError(403, FORBIDDEN) and formatGrantHttpError
15
+ * yields an actionable cross-tenant permission message.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
19
+
20
+ import {
21
+ GrantHttpError,
22
+ formatGrantHttpError,
23
+ grantGroup,
24
+ listInboundGrants,
25
+ listOutboundGrants,
26
+ revokeGroupGrant,
27
+ } from "./group-grants.js";
28
+
29
+ function jsonResponse(status: number, body: unknown): Response {
30
+ return new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: { "Content-Type": "application/json" },
33
+ });
34
+ }
35
+
36
+ let fetchSpy: MockInstance<typeof fetch>;
37
+
38
+ beforeEach(() => {
39
+ fetchSpy = vi.spyOn(globalThis, "fetch");
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // grantGroup — story e2e #1 (authorized) + validation
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("grantGroup", () => {
51
+ it("POSTs /group-grants with the right body and returns the grant (operator with invite rights on B)", async () => {
52
+ fetchSpy.mockResolvedValueOnce(
53
+ jsonResponse(200, {
54
+ grant: {
55
+ groupId: "grp_eng",
56
+ sourceCompanyUid: "cmp_a",
57
+ targetCompanyUid: "cmp_b",
58
+ role: "member",
59
+ },
60
+ }),
61
+ );
62
+
63
+ const grant = await grantGroup({
64
+ groupId: "grp_eng",
65
+ sourceCompanyUid: "cmp_a",
66
+ targetCompanyUid: "cmp_b",
67
+ role: "member",
68
+ token: "test-token",
69
+ });
70
+
71
+ expect(grant.role).toBe("member");
72
+ expect(grant.targetCompanyUid).toBe("cmp_b");
73
+
74
+ const call = fetchSpy.mock.calls[0];
75
+ expect(String(call[0])).toContain("/group-grants");
76
+ expect(call[1]?.method).toBe("POST");
77
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
78
+ expect(body).toEqual({
79
+ groupId: "grp_eng",
80
+ sourceCompanyUid: "cmp_a",
81
+ targetCompanyUid: "cmp_b",
82
+ role: "member",
83
+ });
84
+ });
85
+
86
+ it("synthesizes a grant when the server omits the grant body", async () => {
87
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
88
+
89
+ const grant = await grantGroup({
90
+ groupId: "grp_eng",
91
+ sourceCompanyUid: "cmp_a",
92
+ targetCompanyUid: "cmp_b",
93
+ role: "admin",
94
+ token: "test-token",
95
+ });
96
+
97
+ expect(grant).toEqual({
98
+ groupId: "grp_eng",
99
+ sourceCompanyUid: "cmp_a",
100
+ targetCompanyUid: "cmp_b",
101
+ role: "admin",
102
+ });
103
+ });
104
+
105
+ it("wraps a 403 in GrantHttpError carrying the FORBIDDEN code (operator with no role on independent C)", async () => {
106
+ fetchSpy.mockResolvedValueOnce(
107
+ jsonResponse(403, { error: "forbidden", code: "FORBIDDEN" }),
108
+ );
109
+
110
+ const err = await grantGroup({
111
+ groupId: "grp_eng",
112
+ sourceCompanyUid: "cmp_a",
113
+ targetCompanyUid: "cmp_c",
114
+ role: "member",
115
+ token: "test-token",
116
+ }).catch((e) => e);
117
+
118
+ expect(err).toBeInstanceOf(GrantHttpError);
119
+ expect((err as GrantHttpError).status).toBe(403);
120
+ expect((err as GrantHttpError).code).toBe("FORBIDDEN");
121
+ });
122
+
123
+ it("rejects an invalid group id before calling the API", async () => {
124
+ await expect(
125
+ grantGroup({
126
+ groupId: "eng",
127
+ sourceCompanyUid: "cmp_a",
128
+ targetCompanyUid: "cmp_b",
129
+ role: "member",
130
+ token: "test-token",
131
+ }),
132
+ ).rejects.toThrow(/Invalid group id/);
133
+ expect(fetchSpy).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("rejects an unknown role before calling the API", async () => {
137
+ await expect(
138
+ grantGroup({
139
+ groupId: "grp_eng",
140
+ sourceCompanyUid: "cmp_a",
141
+ targetCompanyUid: "cmp_b",
142
+ role: "superuser",
143
+ token: "test-token",
144
+ }),
145
+ ).rejects.toThrow(/Invalid role/);
146
+ expect(fetchSpy).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("rejects a same-company grant before calling the API", async () => {
150
+ await expect(
151
+ grantGroup({
152
+ groupId: "grp_eng",
153
+ sourceCompanyUid: "cmp_a",
154
+ targetCompanyUid: "cmp_a",
155
+ role: "member",
156
+ token: "test-token",
157
+ }),
158
+ ).rejects.toThrow(/cross company boundaries/);
159
+ expect(fetchSpy).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // formatGrantHttpError — actionable cross-tenant messaging (story AC #3)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ describe("formatGrantHttpError", () => {
168
+ it("renders an actionable cross-tenant 403 message naming the target company", () => {
169
+ const msg = formatGrantHttpError(403, "forbidden", {
170
+ targetCompany: "acme-c",
171
+ code: "FORBIDDEN",
172
+ });
173
+ expect(msg).toMatch(/Permission denied/);
174
+ expect(msg).toMatch(/owner or admin of the target company 'acme-c'/);
175
+ });
176
+
177
+ it("maps 401 to a login hint", () => {
178
+ expect(formatGrantHttpError(401, "x")).toMatch(/hq login/);
179
+ });
180
+
181
+ it("passes through a generic 400 message", () => {
182
+ expect(formatGrantHttpError(400, "bad role")).toBe("bad role");
183
+ });
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // revokeGroupGrant
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe("revokeGroupGrant", () => {
191
+ it("POSTs /group-grants/revoke with the right body", async () => {
192
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { revoked: true }));
193
+
194
+ await revokeGroupGrant({
195
+ groupId: "grp_eng",
196
+ sourceCompanyUid: "cmp_a",
197
+ targetCompanyUid: "cmp_b",
198
+ token: "test-token",
199
+ });
200
+
201
+ const call = fetchSpy.mock.calls[0];
202
+ expect(String(call[0])).toContain("/group-grants/revoke");
203
+ expect(call[1]?.method).toBe("POST");
204
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
205
+ expect(body).toEqual({
206
+ groupId: "grp_eng",
207
+ sourceCompanyUid: "cmp_a",
208
+ targetCompanyUid: "cmp_b",
209
+ });
210
+ });
211
+
212
+ it("wraps a 403 in GrantHttpError", async () => {
213
+ fetchSpy.mockResolvedValueOnce(
214
+ jsonResponse(403, { error: "forbidden", code: "FORBIDDEN" }),
215
+ );
216
+
217
+ await expect(
218
+ revokeGroupGrant({
219
+ groupId: "grp_eng",
220
+ sourceCompanyUid: "cmp_a",
221
+ targetCompanyUid: "cmp_c",
222
+ token: "test-token",
223
+ }),
224
+ ).rejects.toBeInstanceOf(GrantHttpError);
225
+ });
226
+
227
+ it("rejects an invalid group id before calling the API", async () => {
228
+ await expect(
229
+ revokeGroupGrant({
230
+ groupId: "eng",
231
+ sourceCompanyUid: "cmp_a",
232
+ targetCompanyUid: "cmp_b",
233
+ token: "test-token",
234
+ }),
235
+ ).rejects.toThrow(/Invalid group id/);
236
+ expect(fetchSpy).not.toHaveBeenCalled();
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // listOutboundGrants / listInboundGrants
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe("listOutboundGrants", () => {
245
+ it("GETs /group-grants/outbound with sourceCompanyUid and optional groupId", async () => {
246
+ fetchSpy.mockResolvedValueOnce(
247
+ jsonResponse(200, {
248
+ grants: [
249
+ {
250
+ groupId: "grp_eng",
251
+ sourceCompanyUid: "cmp_a",
252
+ targetCompanyUid: "cmp_b",
253
+ role: "member",
254
+ },
255
+ ],
256
+ }),
257
+ );
258
+
259
+ const list = await listOutboundGrants("test-token", "cmp_a", "grp_eng");
260
+ expect(list).toHaveLength(1);
261
+
262
+ const url = new URL(String(fetchSpy.mock.calls[0][0]));
263
+ expect(url.pathname).toContain("/group-grants/outbound");
264
+ expect(url.searchParams.get("sourceCompanyUid")).toBe("cmp_a");
265
+ expect(url.searchParams.get("groupId")).toBe("grp_eng");
266
+ });
267
+
268
+ it("returns [] when the server returns no grants", async () => {
269
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
270
+ expect(await listOutboundGrants("test-token", "cmp_a")).toEqual([]);
271
+ });
272
+ });
273
+
274
+ describe("listInboundGrants", () => {
275
+ it("GETs /group-grants/inbound with companyUid", async () => {
276
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { grants: [] }));
277
+
278
+ await listInboundGrants("test-token", "cmp_b");
279
+
280
+ const url = new URL(String(fetchSpy.mock.calls[0][0]));
281
+ expect(url.pathname).toContain("/group-grants/inbound");
282
+ expect(url.searchParams.get("companyUid")).toBe("cmp_b");
283
+ });
284
+
285
+ it("wraps a 403 in GrantHttpError", async () => {
286
+ fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
287
+ await expect(
288
+ listInboundGrants("test-token", "cmp_b"),
289
+ ).rejects.toBeInstanceOf(GrantHttpError);
290
+ });
291
+ });