@indigoai-us/hq-cli 5.26.0 → 5.29.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/files-browse.d.ts +122 -27
- package/dist/commands/files-browse.js +194 -50
- package/dist/commands/sync-narrow.js +15 -4
- package/dist/lib/local-tree-diff.d.ts +7 -2
- package/dist/lib/local-tree-diff.js +18 -6
- package/package.json +2 -2
- package/src/commands/files-browse.test.ts +225 -79
- package/src/commands/files-browse.ts +290 -60
- package/src/commands/sync-narrow.test.ts +66 -6
- package/src/commands/sync-narrow.ts +15 -1
- package/src/lib/local-tree-diff.test.ts +40 -20
- package/src/lib/local-tree-diff.ts +22 -6
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
!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]="
|
|
27
|
+
!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]="20a280f3-0fed-5868-9ad4-fcc562253486")}catch(e){}}();
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
|
|
@@ -55,7 +55,16 @@ export function buildNarrowPlan(input) {
|
|
|
55
55
|
if (!fs.existsSync(walkRoot)) {
|
|
56
56
|
return emptyPlan();
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// Walk with `walkRoot` as the rel-root so each file's `relPath` is
|
|
59
|
+
// COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
|
|
60
|
+
// server's explicit-grant paths, the per-company journal keys, and the
|
|
61
|
+
// hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
|
|
62
|
+
// (the old behavior) produced `companies/<slug>/meetings/a.md`, which
|
|
63
|
+
// matched neither the company-relative grants nor the journal keys — so
|
|
64
|
+
// every file fell out of `prospectivePrefixSet` AND missed its journal
|
|
65
|
+
// entry, making narrow flag the entire tree as dirty orphans. See the
|
|
66
|
+
// namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
|
|
67
|
+
walkLocal(walkRoot, walkRoot, (file) => {
|
|
59
68
|
if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
|
|
60
69
|
staying.push(file);
|
|
61
70
|
return;
|
|
@@ -100,7 +109,10 @@ function emptyPlan() {
|
|
|
100
109
|
* Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
|
|
101
110
|
* target chain (matches the share-engine convention).
|
|
102
111
|
*/
|
|
103
|
-
function walkLocal(dir,
|
|
112
|
+
function walkLocal(dir,
|
|
113
|
+
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
114
|
+
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
115
|
+
relRoot, emit) {
|
|
104
116
|
let entries;
|
|
105
117
|
try {
|
|
106
118
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -115,7 +127,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
115
127
|
}
|
|
116
128
|
for (const entry of entries) {
|
|
117
129
|
const absPath = path.join(dir, entry.name);
|
|
118
|
-
const relPath = path.relative(
|
|
130
|
+
const relPath = path.relative(relRoot, absPath);
|
|
119
131
|
if (entry.isSymbolicLink()) {
|
|
120
132
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
121
133
|
// about pruning files that the LOCAL tree has materialized here; a
|
|
@@ -132,7 +144,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
134
146
|
if (entry.isDirectory()) {
|
|
135
|
-
walkLocal(absPath,
|
|
147
|
+
walkLocal(absPath, relRoot, emit);
|
|
136
148
|
continue;
|
|
137
149
|
}
|
|
138
150
|
if (entry.isFile()) {
|
|
@@ -241,4 +253,4 @@ export function formatBytes(n) {
|
|
|
241
253
|
return `${v.toFixed(2)} ${units[i]}`;
|
|
242
254
|
}
|
|
243
255
|
//# sourceMappingURL=local-tree-diff.js.map
|
|
244
|
-
//# debugId=
|
|
256
|
+
//# debugId=20a280f3-0fed-5868-9ad4-fcc562253486
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.29.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "~5.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.43.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for `hq files browse` + `hq files cat` (files-browse.ts).
|
|
3
3
|
*
|
|
4
|
-
* Stubs
|
|
5
|
-
* and the S3 client
|
|
6
|
-
*
|
|
4
|
+
* Stubs the multi-tenant STS vend routes (`sts.vend` / `sts.vendSelf`),
|
|
5
|
+
* listMyExplicitGrants, entity.findInMyNamespace/get, and the S3 client.
|
|
6
|
+
* Coverage focus:
|
|
7
7
|
*
|
|
8
|
-
* 1. vend
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* 1. Browse/cat vend via `/sts/vend` (company) and `/sts/vend-self`
|
|
9
|
+
* (personal) — NOT the legacy `POST /vend`, which is non-functional in
|
|
10
|
+
* multi-tenant prod (undefined BUCKET_ARN → MalformedPolicyDocument).
|
|
11
|
+
* 2. Namespace translation: company vault keys are company-relative, so the
|
|
12
|
+
* S3 list/get prefix is the bucket-relative form while the CLI surface
|
|
13
|
+
* stays anchored at `companies/<slug>/`.
|
|
14
|
+
* 3. `--out` refuses any destination under `<hqRoot>/companies/`.
|
|
15
|
+
* 4. ACL-source classification over the company-relative key space.
|
|
12
16
|
*
|
|
13
|
-
* Plus the pure helpers (parseCompanySlugFromPath,
|
|
14
|
-
* assertOutPathOutsideCompanies,
|
|
17
|
+
* Plus the pure helpers (parseCompanySlugFromPath, toBucketRelative,
|
|
18
|
+
* toCompanyAnchored, classifyAclSource, assertOutPathOutsideCompanies,
|
|
19
|
+
* formatBrowseTable).
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -25,13 +30,19 @@ import {
|
|
|
25
30
|
classifyAclSource,
|
|
26
31
|
formatBrowseTable,
|
|
27
32
|
parseCompanySlugFromPath,
|
|
33
|
+
toBucketRelative,
|
|
34
|
+
toCompanyAnchored,
|
|
28
35
|
runBrowse,
|
|
29
36
|
runCat,
|
|
37
|
+
runSharedWithMe,
|
|
38
|
+
formatSharedWithMeTable,
|
|
39
|
+
type BrowseVendResult,
|
|
30
40
|
type FilesBrowseS3Client,
|
|
31
41
|
type FilesBrowseVaultClient,
|
|
42
|
+
type FilesSharedWithMeVaultClient,
|
|
32
43
|
type S3ClientFactory,
|
|
33
44
|
} from "./files-browse.js";
|
|
34
|
-
import type { ExplicitGrant
|
|
45
|
+
import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
35
46
|
import {
|
|
36
47
|
ListObjectsV2Command,
|
|
37
48
|
GetObjectCommand,
|
|
@@ -65,24 +76,17 @@ function fakeGrant(p: string): ExplicitGrant {
|
|
|
65
76
|
};
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
function
|
|
79
|
+
function fakeStsVend(): BrowseVendResult {
|
|
69
80
|
return {
|
|
70
81
|
credentials: {
|
|
71
82
|
accessKeyId: "ASIA-test",
|
|
72
83
|
secretAccessKey: "secret-test",
|
|
73
84
|
sessionToken: "session-test",
|
|
74
|
-
expiration: new Date(Date.now() + 900_000).toISOString(),
|
|
75
85
|
},
|
|
76
|
-
paths: ["companies/indigo/"],
|
|
77
|
-
operations: "read-only",
|
|
78
|
-
purpose: "browse",
|
|
79
|
-
policySize: 512,
|
|
80
|
-
...overrides,
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
interface StubVaultOpts {
|
|
85
|
-
vend?: VendResult;
|
|
86
90
|
grants?: ExplicitGrant[];
|
|
87
91
|
entity?:
|
|
88
92
|
| { uid: string; slug: string; name?: string; bucketName?: string }
|
|
@@ -92,7 +96,8 @@ interface StubVaultOpts {
|
|
|
92
96
|
function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
93
97
|
client: FilesBrowseVaultClient;
|
|
94
98
|
spies: {
|
|
95
|
-
|
|
99
|
+
stsVend: ReturnType<typeof vi.fn>;
|
|
100
|
+
vendSelf: ReturnType<typeof vi.fn>;
|
|
96
101
|
listMyExplicitGrants: ReturnType<typeof vi.fn>;
|
|
97
102
|
findInMyNamespace: ReturnType<typeof vi.fn>;
|
|
98
103
|
};
|
|
@@ -106,7 +111,8 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
|
106
111
|
name: "Indigo",
|
|
107
112
|
bucketName: "hq-vault-cmp-indigo",
|
|
108
113
|
});
|
|
109
|
-
const
|
|
114
|
+
const stsVend = vi.fn(async () => fakeStsVend());
|
|
115
|
+
const vendSelf = vi.fn(async () => fakeStsVend());
|
|
110
116
|
const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
|
|
111
117
|
const findInMyNamespace = vi.fn(async () => entity);
|
|
112
118
|
const get = vi.fn(async (uid: string) => {
|
|
@@ -114,11 +120,14 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
|
114
120
|
return entity;
|
|
115
121
|
});
|
|
116
122
|
const client: FilesBrowseVaultClient = {
|
|
117
|
-
vend,
|
|
123
|
+
sts: { vend: stsVend, vendSelf },
|
|
118
124
|
listMyExplicitGrants,
|
|
119
125
|
entity: { get, findInMyNamespace },
|
|
120
126
|
};
|
|
121
|
-
return {
|
|
127
|
+
return {
|
|
128
|
+
client,
|
|
129
|
+
spies: { stsVend, vendSelf, listMyExplicitGrants, findInMyNamespace },
|
|
130
|
+
};
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
interface StubS3Opts {
|
|
@@ -171,36 +180,83 @@ describe("parseCompanySlugFromPath", () => {
|
|
|
171
180
|
});
|
|
172
181
|
});
|
|
173
182
|
|
|
183
|
+
// ── toBucketRelative / toCompanyAnchored (namespace translation) ────────────
|
|
184
|
+
|
|
185
|
+
describe("toBucketRelative", () => {
|
|
186
|
+
it("strips the companies/<slug>/ anchor to a company-relative key", () => {
|
|
187
|
+
expect(toBucketRelative("companies/indigo/knowledge/foo.md", "indigo")).toBe(
|
|
188
|
+
"knowledge/foo.md",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("strips the anchor for a bare prefix (trailing slash preserved)", () => {
|
|
193
|
+
expect(toBucketRelative("companies/indigo/scratch/", "indigo")).toBe(
|
|
194
|
+
"scratch/",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("yields empty string for the bare company root", () => {
|
|
199
|
+
expect(toBucketRelative("companies/indigo/", "indigo")).toBe("");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("tolerates leading slashes", () => {
|
|
203
|
+
expect(toBucketRelative("/companies/indigo/x/y", "indigo")).toBe("x/y");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("passes through a path that lacks the anchor (e.g. already relative)", () => {
|
|
207
|
+
expect(toBucketRelative("knowledge/foo.md", "indigo")).toBe(
|
|
208
|
+
"knowledge/foo.md",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not strip a different company's anchor", () => {
|
|
213
|
+
expect(toBucketRelative("companies/acme/x", "indigo")).toBe(
|
|
214
|
+
"companies/acme/x",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("toCompanyAnchored", () => {
|
|
220
|
+
it("re-attaches the companies/<slug>/ anchor", () => {
|
|
221
|
+
expect(toCompanyAnchored("knowledge/foo.md", "indigo")).toBe(
|
|
222
|
+
"companies/indigo/knowledge/foo.md",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("round-trips with toBucketRelative", () => {
|
|
227
|
+
const anchored = "companies/indigo/a/b/c.md";
|
|
228
|
+
expect(toCompanyAnchored(toBucketRelative(anchored, "indigo"), "indigo")).toBe(
|
|
229
|
+
anchored,
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
174
234
|
// ── classifyAclSource ───────────────────────────────────────────────────────
|
|
175
235
|
|
|
176
236
|
describe("classifyAclSource", () => {
|
|
177
|
-
it("returns shared-with-you when any grant
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
).toBe("shared-with-you");
|
|
237
|
+
it("returns shared-with-you when any grant prefix covers the key", () => {
|
|
238
|
+
expect(classifyAclSource("scratch/foo.txt", ["scratch/"])).toBe(
|
|
239
|
+
"shared-with-you",
|
|
240
|
+
);
|
|
182
241
|
});
|
|
183
242
|
|
|
184
|
-
it("returns role-bypass when no grant covers the key", () => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
).toBe("role-bypass");
|
|
243
|
+
it("returns role-bypass when no grant prefix covers the key", () => {
|
|
244
|
+
expect(classifyAclSource("secrets/db.txt", ["scratch/"])).toBe(
|
|
245
|
+
"role-bypass",
|
|
246
|
+
);
|
|
189
247
|
});
|
|
190
248
|
|
|
191
249
|
it("returns role-bypass on an empty grant list", () => {
|
|
192
|
-
expect(classifyAclSource("
|
|
193
|
-
"role-bypass",
|
|
194
|
-
);
|
|
250
|
+
expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
|
|
195
251
|
});
|
|
196
252
|
|
|
197
|
-
it("
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
253
|
+
it("treats an empty-string prefix as a company-wide grant (shared)", () => {
|
|
254
|
+
expect(classifyAclSource("anything/x", [""])).toBe("shared-with-you");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("matches against the first covering prefix — multiple are fine", () => {
|
|
202
258
|
expect(
|
|
203
|
-
classifyAclSource("
|
|
259
|
+
classifyAclSource("scratch/sub/y.bin", ["other/", "scratch/"]),
|
|
204
260
|
).toBe("shared-with-you");
|
|
205
261
|
});
|
|
206
262
|
});
|
|
@@ -297,9 +353,9 @@ describe("formatBrowseTable", () => {
|
|
|
297
353
|
// ── runBrowse ───────────────────────────────────────────────────────────────
|
|
298
354
|
|
|
299
355
|
describe("runBrowse", () => {
|
|
300
|
-
it("vends
|
|
356
|
+
it("vends via /sts/vend (company) and lists the company-relative prefix", async () => {
|
|
301
357
|
const { client, spies } = makeStubVaultClient({});
|
|
302
|
-
const { factory } = makeStubS3Factory({
|
|
358
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
303
359
|
listResponses: [{ Contents: [] }],
|
|
304
360
|
});
|
|
305
361
|
await runBrowse({
|
|
@@ -308,29 +364,32 @@ describe("runBrowse", () => {
|
|
|
308
364
|
s3Factory: factory,
|
|
309
365
|
region: "us-east-1",
|
|
310
366
|
});
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
expect(
|
|
314
|
-
expect(
|
|
315
|
-
|
|
316
|
-
|
|
367
|
+
// Vend through the multi-tenant STS route — NOT the legacy POST /vend.
|
|
368
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
|
|
370
|
+
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
371
|
+
// S3 list prefix is company-relative (the bug: was anchored → 0 results).
|
|
372
|
+
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
373
|
+
expect(listCmd.input.Prefix).toBe("scratch/");
|
|
317
374
|
});
|
|
318
375
|
|
|
319
|
-
it("paginates ListObjectsV2 fully and
|
|
376
|
+
it("paginates ListObjectsV2 fully, classifies ACL, and re-anchors keys for display", async () => {
|
|
320
377
|
const { client } = makeStubVaultClient({
|
|
321
|
-
grants
|
|
378
|
+
// Real grants are glob/anchored; normalization folds this to "scratch/".
|
|
379
|
+
grants: [fakeGrant("companies/indigo/scratch/*")],
|
|
322
380
|
});
|
|
323
381
|
const { factory, sendSpy } = makeStubS3Factory({
|
|
382
|
+
// S3 keys are company-relative (no companies/<slug>/ prefix).
|
|
324
383
|
listResponses: [
|
|
325
384
|
{
|
|
326
385
|
Contents: [
|
|
327
386
|
{
|
|
328
|
-
Key: "
|
|
387
|
+
Key: "scratch/a.txt",
|
|
329
388
|
Size: 10,
|
|
330
389
|
LastModified: new Date("2026-01-01T00:00:00Z"),
|
|
331
390
|
},
|
|
332
391
|
{
|
|
333
|
-
Key: "
|
|
392
|
+
Key: "secrets/db.txt",
|
|
334
393
|
Size: 20,
|
|
335
394
|
LastModified: new Date("2026-01-02T00:00:00Z"),
|
|
336
395
|
},
|
|
@@ -340,13 +399,13 @@ describe("runBrowse", () => {
|
|
|
340
399
|
{
|
|
341
400
|
Contents: [
|
|
342
401
|
{
|
|
343
|
-
Key: "
|
|
402
|
+
Key: "scratch/sub/b.bin",
|
|
344
403
|
Size: 30,
|
|
345
404
|
LastModified: new Date("2026-01-03T00:00:00Z"),
|
|
346
405
|
},
|
|
347
406
|
// S3 directory marker — should be filtered out.
|
|
348
407
|
{
|
|
349
|
-
Key: "
|
|
408
|
+
Key: "scratch/empty/",
|
|
350
409
|
Size: 0,
|
|
351
410
|
LastModified: new Date("2026-01-04T00:00:00Z"),
|
|
352
411
|
},
|
|
@@ -362,6 +421,7 @@ describe("runBrowse", () => {
|
|
|
362
421
|
});
|
|
363
422
|
expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
|
|
364
423
|
expect(result.rows).toHaveLength(3);
|
|
424
|
+
// Displayed keys are re-anchored to companies/<slug>/.
|
|
365
425
|
const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
|
|
366
426
|
expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
|
|
367
427
|
"shared-with-you",
|
|
@@ -391,13 +451,11 @@ describe("runBrowse", () => {
|
|
|
391
451
|
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
392
452
|
//
|
|
393
453
|
// Personal mode resolves the entity via `entity.get(personalUid)` (skipping
|
|
394
|
-
// the company-namespace lookup),
|
|
395
|
-
// every row
|
|
396
|
-
// relative — companies/<slug>/ prefix is NOT required
|
|
397
|
-
// incorrect, since the person bucket is owner-only with no companies/
|
|
398
|
-
// subtree).
|
|
454
|
+
// the company-namespace lookup), vends via `/sts/vend-self`, omits the
|
|
455
|
+
// explicit-grants fetch, and tags every row `aclSource: "personal-vault"`.
|
|
456
|
+
// The path arg is bucket-relative — companies/<slug>/ prefix is NOT required.
|
|
399
457
|
|
|
400
|
-
it("personalMode: resolves
|
|
458
|
+
it("personalMode: resolves via entity.get, vends /sts/vend-self, skips namespace + grants", async () => {
|
|
401
459
|
const { client, spies } = makeStubVaultClient({
|
|
402
460
|
entity: {
|
|
403
461
|
uid: "prs_test",
|
|
@@ -428,10 +486,9 @@ describe("runBrowse", () => {
|
|
|
428
486
|
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
429
487
|
// Grants graph is a company concept — must not be fetched.
|
|
430
488
|
expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
|
|
431
|
-
//
|
|
432
|
-
expect(spies.
|
|
433
|
-
|
|
434
|
-
);
|
|
489
|
+
// Personal vends self, never the company route.
|
|
490
|
+
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
491
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
435
492
|
});
|
|
436
493
|
|
|
437
494
|
it("personalMode: empty pathPrefix lists the bucket root", async () => {
|
|
@@ -537,9 +594,9 @@ describe("runBrowse", () => {
|
|
|
537
594
|
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
538
595
|
|
|
539
596
|
describe("runCat", () => {
|
|
540
|
-
it("vends
|
|
597
|
+
it("vends via /sts/vend and GetObjects the company-relative key", async () => {
|
|
541
598
|
const { client, spies } = makeStubVaultClient({});
|
|
542
|
-
const { factory } = makeStubS3Factory({
|
|
599
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
543
600
|
getResponse: {
|
|
544
601
|
Body: Readable.from(Buffer.from("hello world")),
|
|
545
602
|
} as GetObjectCommandOutput,
|
|
@@ -554,8 +611,14 @@ describe("runCat", () => {
|
|
|
554
611
|
hqRoot: tmpRoot,
|
|
555
612
|
stdout: sink,
|
|
556
613
|
});
|
|
557
|
-
expect(spies.
|
|
558
|
-
expect(spies.
|
|
614
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
615
|
+
expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
|
|
616
|
+
// GetObject key is company-relative (anchor stripped).
|
|
617
|
+
const getCmd = sendSpy.mock.calls.find(
|
|
618
|
+
(c) => c[0] instanceof GetObjectCommand,
|
|
619
|
+
)?.[0] as GetObjectCommand;
|
|
620
|
+
expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
|
|
621
|
+
expect(getCmd.input.Key).toBe("scratch/a.txt");
|
|
559
622
|
});
|
|
560
623
|
|
|
561
624
|
it("writes to --out when outside the companies tree and reports byte count", async () => {
|
|
@@ -598,7 +661,8 @@ describe("runCat", () => {
|
|
|
598
661
|
).rejects.toThrow(/Refusing to write/);
|
|
599
662
|
// Critically: no vend was issued (guard runs first) and no S3 call
|
|
600
663
|
// was made — failing closed is the whole point of the guard.
|
|
601
|
-
expect(spies.
|
|
664
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
665
|
+
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
602
666
|
expect(sendSpy).not.toHaveBeenCalled();
|
|
603
667
|
// And no file was written under the protected tree.
|
|
604
668
|
expect(fs.existsSync(badOut)).toBe(false);
|
|
@@ -620,7 +684,7 @@ describe("runCat", () => {
|
|
|
620
684
|
|
|
621
685
|
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
622
686
|
|
|
623
|
-
it("personalMode: streams from the person bucket, no slug parse
|
|
687
|
+
it("personalMode: streams from the person bucket via /sts/vend-self, no slug parse", async () => {
|
|
624
688
|
const { client, spies } = makeStubVaultClient({
|
|
625
689
|
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
626
690
|
});
|
|
@@ -654,14 +718,10 @@ describe("runCat", () => {
|
|
|
654
718
|
|
|
655
719
|
expect(result.destination.kind).toBe("stdout");
|
|
656
720
|
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
657
|
-
//
|
|
658
|
-
expect(spies.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
purpose: "browse",
|
|
662
|
-
}),
|
|
663
|
-
);
|
|
664
|
-
// GetObject targeted the person bucket.
|
|
721
|
+
// Personal vends self, against the bucket-relative key.
|
|
722
|
+
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
723
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
724
|
+
// GetObject targeted the person bucket with the bucket-relative key.
|
|
665
725
|
const getCmd = sendSpy.mock.calls.find(
|
|
666
726
|
(c) => c[0] instanceof GetObjectCommand,
|
|
667
727
|
)?.[0] as GetObjectCommand;
|
|
@@ -685,3 +745,89 @@ describe("runCat", () => {
|
|
|
685
745
|
).rejects.toThrow(/personalMode requires personalUid/);
|
|
686
746
|
});
|
|
687
747
|
});
|
|
748
|
+
|
|
749
|
+
// ── runSharedWithMe ──────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
function sharedWithMeClient(opts: {
|
|
752
|
+
memberships?: Array<{ companyUid: string }>;
|
|
753
|
+
grantsByCompany?: Record<string, ExplicitGrant[]>;
|
|
754
|
+
slugByUid?: Record<string, string>;
|
|
755
|
+
}): FilesSharedWithMeVaultClient {
|
|
756
|
+
return {
|
|
757
|
+
listMyMemberships: async () => opts.memberships ?? [],
|
|
758
|
+
listMyExplicitGrants: async (companyUid: string) =>
|
|
759
|
+
opts.grantsByCompany?.[companyUid] ?? [],
|
|
760
|
+
entity: {
|
|
761
|
+
get: async (uid: string) => ({
|
|
762
|
+
uid,
|
|
763
|
+
slug: opts.slugByUid?.[uid] ?? uid,
|
|
764
|
+
}),
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
describe("runSharedWithMe", () => {
|
|
770
|
+
it("lists explicit grants for a single company (companyUid supplied)", async () => {
|
|
771
|
+
const rows = await runSharedWithMe({
|
|
772
|
+
vaultClient: sharedWithMeClient({
|
|
773
|
+
grantsByCompany: {
|
|
774
|
+
cmp_indigo: [fakeGrant("knowledge/"), fakeGrant("reports/q3.pdf")],
|
|
775
|
+
},
|
|
776
|
+
}),
|
|
777
|
+
companyUid: "cmp_indigo",
|
|
778
|
+
companySlug: "indigo",
|
|
779
|
+
});
|
|
780
|
+
expect(rows).toEqual([
|
|
781
|
+
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
782
|
+
{ companySlug: "indigo", path: "reports/q3.pdf", permission: "read", source: "person" },
|
|
783
|
+
]);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("rolls up across every membership when no company is supplied", async () => {
|
|
787
|
+
const rows = await runSharedWithMe({
|
|
788
|
+
vaultClient: sharedWithMeClient({
|
|
789
|
+
memberships: [{ companyUid: "cmp_b" }, { companyUid: "cmp_a" }],
|
|
790
|
+
slugByUid: { cmp_a: "acme", cmp_b: "beta" },
|
|
791
|
+
grantsByCompany: {
|
|
792
|
+
cmp_a: [fakeGrant("docs/")],
|
|
793
|
+
cmp_b: [fakeGrant("shared/")],
|
|
794
|
+
},
|
|
795
|
+
}),
|
|
796
|
+
});
|
|
797
|
+
// Sorted by company slug, then path.
|
|
798
|
+
expect(rows.map((r) => [r.companySlug, r.path])).toEqual([
|
|
799
|
+
["acme", "docs/"],
|
|
800
|
+
["beta", "shared/"],
|
|
801
|
+
]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("skips a company whose grant fetch throws (best-effort roll-up)", async () => {
|
|
805
|
+
const client: FilesSharedWithMeVaultClient = {
|
|
806
|
+
listMyMemberships: async () => [{ companyUid: "cmp_ok" }, { companyUid: "cmp_bad" }],
|
|
807
|
+
listMyExplicitGrants: async (uid: string) => {
|
|
808
|
+
if (uid === "cmp_bad") throw new Error("boom");
|
|
809
|
+
return [fakeGrant("ok/")];
|
|
810
|
+
},
|
|
811
|
+
entity: { get: async (uid: string) => ({ uid, slug: uid }) },
|
|
812
|
+
};
|
|
813
|
+
const rows = await runSharedWithMe({ vaultClient: client });
|
|
814
|
+
expect(rows).toEqual([
|
|
815
|
+
{ companySlug: "cmp_ok", path: "ok/", permission: "read", source: "person" },
|
|
816
|
+
]);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("formats an empty result with the role-bypass caveat", () => {
|
|
820
|
+
const out = formatSharedWithMeTable([]);
|
|
821
|
+
expect(out).toContain("Nothing is explicitly shared with you");
|
|
822
|
+
expect(out).toContain("role-bypass");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("formats rows as a table with company + path + permission + source", () => {
|
|
826
|
+
const out = formatSharedWithMeTable([
|
|
827
|
+
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
828
|
+
]);
|
|
829
|
+
expect(out).toContain("COMPANY");
|
|
830
|
+
expect(out).toContain("indigo");
|
|
831
|
+
expect(out).toContain("knowledge/");
|
|
832
|
+
});
|
|
833
|
+
});
|