@indigoai-us/hq-cli 5.31.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.
- package/dist/commands/group-grants.d.ts +76 -0
- package/dist/commands/group-grants.js +296 -0
- package/dist/commands/pack-install.d.ts +46 -1
- package/dist/commands/pack-install.js +96 -17
- package/dist/commands/packs.d.ts +22 -0
- package/dist/commands/packs.js +406 -0
- package/dist/commands/pkg-list.js +31 -5
- package/dist/index.js +12 -2
- package/dist/utils/pack-contributions.d.ts +87 -0
- package/dist/utils/pack-contributions.js +239 -0
- package/package.json +1 -1
- package/src/commands/group-grants.test.ts +291 -0
- package/src/commands/group-grants.ts +452 -0
- package/src/commands/pack-install.ts +129 -21
- package/src/commands/packs.ts +524 -0
- package/src/commands/pkg-list.ts +34 -3
- package/src/index.ts +12 -0
- package/src/utils/pack-contributions.test.ts +208 -0
- package/src/utils/pack-contributions.ts +303 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-pack contribution helpers -- the single source of truth (in TS) for
|
|
3
|
+
* the `contributes.* -> host-path` symlink mapping that `hq install` wires via
|
|
4
|
+
* `core/scripts/scan-packages.sh`.
|
|
5
|
+
*
|
|
6
|
+
* `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
|
|
7
|
+
* tracked by filesystem presence -- there is no registry file). The list /
|
|
8
|
+
* update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
|
|
9
|
+
* SAME mapping so it can report link health and cleanly un-wire a pack without
|
|
10
|
+
* leaving dangling symlinks. That mapping is duplicated today in two places:
|
|
11
|
+
*
|
|
12
|
+
* - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
|
|
13
|
+
* - pack-install.ts validateManifest's `subpaths` record (payload validation)
|
|
14
|
+
*
|
|
15
|
+
* This module re-encodes it once for TS callers. A parity test
|
|
16
|
+
* (`pack-contributions.test.ts`) asserts it matches scan-packages.sh's `case`
|
|
17
|
+
* arms so the three copies cannot drift.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f5492359-fdf3-5e85-9a81-699c4cd68f6a")}catch(e){}}();
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import * as yaml from 'js-yaml';
|
|
24
|
+
/**
|
|
25
|
+
* Map one contributes entry to its source/host paths. MUST stay in lockstep
|
|
26
|
+
* with scan-packages.sh:wire_one_package and pack-install.ts:validateManifest.
|
|
27
|
+
*/
|
|
28
|
+
function linkFor(hqRoot, packDir, key, item) {
|
|
29
|
+
let srcRel;
|
|
30
|
+
let dstRel;
|
|
31
|
+
switch (key) {
|
|
32
|
+
case 'workers':
|
|
33
|
+
srcRel = path.join('workers', item);
|
|
34
|
+
dstRel = path.join('core', 'workers', 'public', item);
|
|
35
|
+
break;
|
|
36
|
+
case 'knowledge':
|
|
37
|
+
srcRel = path.join('knowledge', item);
|
|
38
|
+
dstRel = path.join('core', 'knowledge', 'public', item);
|
|
39
|
+
break;
|
|
40
|
+
case 'skills':
|
|
41
|
+
srcRel = path.join('skills', item);
|
|
42
|
+
dstRel = path.join('.claude', 'skills', item);
|
|
43
|
+
break;
|
|
44
|
+
case 'commands':
|
|
45
|
+
srcRel = path.join('commands', `${item}.md`);
|
|
46
|
+
dstRel = path.join('.claude', 'commands', `${item}.md`);
|
|
47
|
+
break;
|
|
48
|
+
case 'hooks':
|
|
49
|
+
srcRel = path.join('hooks', `${item}.sh`);
|
|
50
|
+
dstRel = path.join('.claude', 'hooks', `${item}.sh`);
|
|
51
|
+
break;
|
|
52
|
+
case 'policies':
|
|
53
|
+
srcRel = path.join('policies', `${item}.md`);
|
|
54
|
+
dstRel = path.join('core', 'policies', `${item}.md`);
|
|
55
|
+
break;
|
|
56
|
+
case 'scripts':
|
|
57
|
+
srcRel = path.join('scripts', item);
|
|
58
|
+
dstRel = path.join('core', 'scripts', item);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
key,
|
|
63
|
+
item,
|
|
64
|
+
src: path.join(packDir, srcRel),
|
|
65
|
+
dst: path.join(hqRoot, dstRel),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Every symlink a pack's `contributes` block declares. Empty subfields and
|
|
70
|
+
* non-array values are ignored, mirroring scan-packages.sh.
|
|
71
|
+
*/
|
|
72
|
+
export function contributionLinks(hqRoot, packDir, contributes) {
|
|
73
|
+
const links = [];
|
|
74
|
+
for (const [key, items] of Object.entries(contributes)) {
|
|
75
|
+
if (!Array.isArray(items))
|
|
76
|
+
continue;
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
if (typeof item !== 'string' || item.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
links.push(linkFor(hqRoot, packDir, key, item));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return links;
|
|
84
|
+
}
|
|
85
|
+
/** Classify a host path against the link that should own it. */
|
|
86
|
+
export function linkStatus(link) {
|
|
87
|
+
let st;
|
|
88
|
+
try {
|
|
89
|
+
st = fs.lstatSync(link.dst);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return 'missing';
|
|
93
|
+
}
|
|
94
|
+
if (!st.isSymbolicLink()) {
|
|
95
|
+
return 'foreign'; // a real file/dir occupies the slot -- not ours
|
|
96
|
+
}
|
|
97
|
+
let target;
|
|
98
|
+
try {
|
|
99
|
+
target = fs.readlinkSync(link.dst);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return 'foreign';
|
|
103
|
+
}
|
|
104
|
+
// scan-packages.sh writes the symlink target as the absolute `src` path, so a
|
|
105
|
+
// direct compare is correct. Resolve both to be robust to trailing slashes.
|
|
106
|
+
const resolvedTarget = path.resolve(path.dirname(link.dst), target);
|
|
107
|
+
if (path.resolve(link.src) !== resolvedTarget) {
|
|
108
|
+
return 'foreign'; // points at another pack / somewhere else
|
|
109
|
+
}
|
|
110
|
+
return fs.existsSync(link.src) ? 'live' : 'broken';
|
|
111
|
+
}
|
|
112
|
+
/** Absolute path to `<hqRoot>/core/packages`. */
|
|
113
|
+
export function packagesDir(hqRoot) {
|
|
114
|
+
return path.join(hqRoot, 'core', 'packages');
|
|
115
|
+
}
|
|
116
|
+
/** Read and shallowly validate a pack's package.yaml. */
|
|
117
|
+
export function readPackManifest(packDir) {
|
|
118
|
+
const manifestPath = path.join(packDir, 'package.yaml');
|
|
119
|
+
if (!fs.existsSync(manifestPath)) {
|
|
120
|
+
return { manifest: null, error: 'package.yaml missing' };
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
|
|
124
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
125
|
+
return { manifest: null, error: 'package.yaml is not a mapping' };
|
|
126
|
+
}
|
|
127
|
+
return { manifest: parsed };
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return { manifest: null, error: `package.yaml invalid: ${e.message}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Walk `core/packages/<name>/package.yaml`. Skips the `.archive` dir, the bundled
|
|
135
|
+
* `README.md`, and any non-directory entry. Filesystem presence is the source
|
|
136
|
+
* of truth for installed content packs.
|
|
137
|
+
*/
|
|
138
|
+
export function listInstalledPacks(hqRoot) {
|
|
139
|
+
const dir = packagesDir(hqRoot);
|
|
140
|
+
if (!fs.existsSync(dir))
|
|
141
|
+
return [];
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
144
|
+
if (name.startsWith('.'))
|
|
145
|
+
continue; // .archive, .DS_Store, etc.
|
|
146
|
+
const packDir = path.join(dir, name);
|
|
147
|
+
let st;
|
|
148
|
+
try {
|
|
149
|
+
st = fs.statSync(packDir);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!st.isDirectory())
|
|
155
|
+
continue; // README.md and friends
|
|
156
|
+
const { manifest, error } = readPackManifest(packDir);
|
|
157
|
+
if (!manifest && !fs.existsSync(path.join(packDir, 'package.yaml'))) {
|
|
158
|
+
continue; // a plain dir that isn't a pack -- ignore silently
|
|
159
|
+
}
|
|
160
|
+
out.push({ name, dir: packDir, manifest, error });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove only the host symlinks that resolve into THIS pack's directory
|
|
166
|
+
* (status `live` or `broken`). Foreign links and real files are left in place
|
|
167
|
+
* -- same collision philosophy as scan-packages.sh. This is what prevents an
|
|
168
|
+
* uninstall from leaving dangling symlinks behind.
|
|
169
|
+
*/
|
|
170
|
+
export function unwirePack(hqRoot, packDir, contributes) {
|
|
171
|
+
const result = { unlinked: [], skipped: [] };
|
|
172
|
+
for (const link of contributionLinks(hqRoot, packDir, contributes)) {
|
|
173
|
+
const status = linkStatus(link);
|
|
174
|
+
if (status === 'live' || status === 'broken') {
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(link.dst);
|
|
177
|
+
result.unlinked.push({ key: link.key, item: link.item, dst: link.dst });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
result.skipped.push({
|
|
181
|
+
key: link.key,
|
|
182
|
+
item: link.item,
|
|
183
|
+
dst: link.dst,
|
|
184
|
+
reason: 'foreign',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
result.skipped.push({
|
|
190
|
+
key: link.key,
|
|
191
|
+
item: link.item,
|
|
192
|
+
dst: link.dst,
|
|
193
|
+
reason: status === 'missing' ? 'missing' : 'foreign',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Host introspection: hqVersion + recommended_packages catalog
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
/** Locate core.yaml -- v14+ nests it under `core/`, older layouts at the root. */
|
|
203
|
+
function coreYamlPath(hqRoot) {
|
|
204
|
+
const nested = path.join(hqRoot, 'core', 'core.yaml');
|
|
205
|
+
if (fs.existsSync(nested))
|
|
206
|
+
return nested;
|
|
207
|
+
const flat = path.join(hqRoot, 'core.yaml');
|
|
208
|
+
if (fs.existsSync(flat))
|
|
209
|
+
return flat;
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
export function readHqVersion(hqRoot) {
|
|
213
|
+
const p = coreYamlPath(hqRoot);
|
|
214
|
+
if (!p)
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
const c = yaml.load(fs.readFileSync(p, 'utf-8'));
|
|
218
|
+
return c?.hqVersion ?? null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Read `recommended_packages` from core.yaml (the curated content-pack catalog). */
|
|
225
|
+
export function readRecommendedPackages(hqRoot) {
|
|
226
|
+
const p = coreYamlPath(hqRoot);
|
|
227
|
+
if (!p)
|
|
228
|
+
return [];
|
|
229
|
+
try {
|
|
230
|
+
const c = yaml.load(fs.readFileSync(p, 'utf-8'));
|
|
231
|
+
const list = c?.recommended_packages;
|
|
232
|
+
return Array.isArray(list) ? list : [];
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=pack-contributions.js.map
|
|
239
|
+
//# debugId=f5492359-fdf3-5e85-9a81-699c4cd68f6a
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|