@indigoai-us/hq-cli 5.28.0 → 5.30.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.
|
@@ -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,16 +30,24 @@ import {
|
|
|
25
30
|
classifyAclSource,
|
|
26
31
|
formatBrowseTable,
|
|
27
32
|
parseCompanySlugFromPath,
|
|
33
|
+
toBucketRelative,
|
|
34
|
+
toCompanyAnchored,
|
|
28
35
|
runBrowse,
|
|
29
36
|
runCat,
|
|
37
|
+
runSearch,
|
|
38
|
+
runGet,
|
|
39
|
+
readPins,
|
|
40
|
+
addPin,
|
|
41
|
+
pinFilePath,
|
|
30
42
|
runSharedWithMe,
|
|
31
43
|
formatSharedWithMeTable,
|
|
44
|
+
type BrowseVendResult,
|
|
32
45
|
type FilesBrowseS3Client,
|
|
33
46
|
type FilesBrowseVaultClient,
|
|
34
47
|
type FilesSharedWithMeVaultClient,
|
|
35
48
|
type S3ClientFactory,
|
|
36
49
|
} from "./files-browse.js";
|
|
37
|
-
import type { ExplicitGrant
|
|
50
|
+
import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
38
51
|
import {
|
|
39
52
|
ListObjectsV2Command,
|
|
40
53
|
GetObjectCommand,
|
|
@@ -68,24 +81,17 @@ function fakeGrant(p: string): ExplicitGrant {
|
|
|
68
81
|
};
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
function
|
|
84
|
+
function fakeStsVend(): BrowseVendResult {
|
|
72
85
|
return {
|
|
73
86
|
credentials: {
|
|
74
87
|
accessKeyId: "ASIA-test",
|
|
75
88
|
secretAccessKey: "secret-test",
|
|
76
89
|
sessionToken: "session-test",
|
|
77
|
-
expiration: new Date(Date.now() + 900_000).toISOString(),
|
|
78
90
|
},
|
|
79
|
-
paths: ["companies/indigo/"],
|
|
80
|
-
operations: "read-only",
|
|
81
|
-
purpose: "browse",
|
|
82
|
-
policySize: 512,
|
|
83
|
-
...overrides,
|
|
84
91
|
};
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
interface StubVaultOpts {
|
|
88
|
-
vend?: VendResult;
|
|
89
95
|
grants?: ExplicitGrant[];
|
|
90
96
|
entity?:
|
|
91
97
|
| { uid: string; slug: string; name?: string; bucketName?: string }
|
|
@@ -95,7 +101,8 @@ interface StubVaultOpts {
|
|
|
95
101
|
function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
96
102
|
client: FilesBrowseVaultClient;
|
|
97
103
|
spies: {
|
|
98
|
-
|
|
104
|
+
stsVend: ReturnType<typeof vi.fn>;
|
|
105
|
+
vendSelf: ReturnType<typeof vi.fn>;
|
|
99
106
|
listMyExplicitGrants: ReturnType<typeof vi.fn>;
|
|
100
107
|
findInMyNamespace: ReturnType<typeof vi.fn>;
|
|
101
108
|
};
|
|
@@ -109,7 +116,8 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
|
109
116
|
name: "Indigo",
|
|
110
117
|
bucketName: "hq-vault-cmp-indigo",
|
|
111
118
|
});
|
|
112
|
-
const
|
|
119
|
+
const stsVend = vi.fn(async () => fakeStsVend());
|
|
120
|
+
const vendSelf = vi.fn(async () => fakeStsVend());
|
|
113
121
|
const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
|
|
114
122
|
const findInMyNamespace = vi.fn(async () => entity);
|
|
115
123
|
const get = vi.fn(async (uid: string) => {
|
|
@@ -117,11 +125,14 @@ function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
|
117
125
|
return entity;
|
|
118
126
|
});
|
|
119
127
|
const client: FilesBrowseVaultClient = {
|
|
120
|
-
vend,
|
|
128
|
+
sts: { vend: stsVend, vendSelf },
|
|
121
129
|
listMyExplicitGrants,
|
|
122
130
|
entity: { get, findInMyNamespace },
|
|
123
131
|
};
|
|
124
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
client,
|
|
134
|
+
spies: { stsVend, vendSelf, listMyExplicitGrants, findInMyNamespace },
|
|
135
|
+
};
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
interface StubS3Opts {
|
|
@@ -174,36 +185,83 @@ describe("parseCompanySlugFromPath", () => {
|
|
|
174
185
|
});
|
|
175
186
|
});
|
|
176
187
|
|
|
188
|
+
// ── toBucketRelative / toCompanyAnchored (namespace translation) ────────────
|
|
189
|
+
|
|
190
|
+
describe("toBucketRelative", () => {
|
|
191
|
+
it("strips the companies/<slug>/ anchor to a company-relative key", () => {
|
|
192
|
+
expect(toBucketRelative("companies/indigo/knowledge/foo.md", "indigo")).toBe(
|
|
193
|
+
"knowledge/foo.md",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("strips the anchor for a bare prefix (trailing slash preserved)", () => {
|
|
198
|
+
expect(toBucketRelative("companies/indigo/scratch/", "indigo")).toBe(
|
|
199
|
+
"scratch/",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("yields empty string for the bare company root", () => {
|
|
204
|
+
expect(toBucketRelative("companies/indigo/", "indigo")).toBe("");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("tolerates leading slashes", () => {
|
|
208
|
+
expect(toBucketRelative("/companies/indigo/x/y", "indigo")).toBe("x/y");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("passes through a path that lacks the anchor (e.g. already relative)", () => {
|
|
212
|
+
expect(toBucketRelative("knowledge/foo.md", "indigo")).toBe(
|
|
213
|
+
"knowledge/foo.md",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("does not strip a different company's anchor", () => {
|
|
218
|
+
expect(toBucketRelative("companies/acme/x", "indigo")).toBe(
|
|
219
|
+
"companies/acme/x",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("toCompanyAnchored", () => {
|
|
225
|
+
it("re-attaches the companies/<slug>/ anchor", () => {
|
|
226
|
+
expect(toCompanyAnchored("knowledge/foo.md", "indigo")).toBe(
|
|
227
|
+
"companies/indigo/knowledge/foo.md",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("round-trips with toBucketRelative", () => {
|
|
232
|
+
const anchored = "companies/indigo/a/b/c.md";
|
|
233
|
+
expect(toCompanyAnchored(toBucketRelative(anchored, "indigo"), "indigo")).toBe(
|
|
234
|
+
anchored,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
177
239
|
// ── classifyAclSource ───────────────────────────────────────────────────────
|
|
178
240
|
|
|
179
241
|
describe("classifyAclSource", () => {
|
|
180
|
-
it("returns shared-with-you when any grant
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
).toBe("shared-with-you");
|
|
242
|
+
it("returns shared-with-you when any grant prefix covers the key", () => {
|
|
243
|
+
expect(classifyAclSource("scratch/foo.txt", ["scratch/"])).toBe(
|
|
244
|
+
"shared-with-you",
|
|
245
|
+
);
|
|
185
246
|
});
|
|
186
247
|
|
|
187
|
-
it("returns role-bypass when no grant covers the key", () => {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
).toBe("role-bypass");
|
|
248
|
+
it("returns role-bypass when no grant prefix covers the key", () => {
|
|
249
|
+
expect(classifyAclSource("secrets/db.txt", ["scratch/"])).toBe(
|
|
250
|
+
"role-bypass",
|
|
251
|
+
);
|
|
192
252
|
});
|
|
193
253
|
|
|
194
254
|
it("returns role-bypass on an empty grant list", () => {
|
|
195
|
-
expect(classifyAclSource("
|
|
196
|
-
"role-bypass",
|
|
197
|
-
);
|
|
255
|
+
expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
|
|
198
256
|
});
|
|
199
257
|
|
|
200
|
-
it("
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
258
|
+
it("treats an empty-string prefix as a company-wide grant (shared)", () => {
|
|
259
|
+
expect(classifyAclSource("anything/x", [""])).toBe("shared-with-you");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("matches against the first covering prefix — multiple are fine", () => {
|
|
205
263
|
expect(
|
|
206
|
-
classifyAclSource("
|
|
264
|
+
classifyAclSource("scratch/sub/y.bin", ["other/", "scratch/"]),
|
|
207
265
|
).toBe("shared-with-you");
|
|
208
266
|
});
|
|
209
267
|
});
|
|
@@ -300,9 +358,9 @@ describe("formatBrowseTable", () => {
|
|
|
300
358
|
// ── runBrowse ───────────────────────────────────────────────────────────────
|
|
301
359
|
|
|
302
360
|
describe("runBrowse", () => {
|
|
303
|
-
it("vends
|
|
361
|
+
it("vends via /sts/vend (company) and lists the company-relative prefix", async () => {
|
|
304
362
|
const { client, spies } = makeStubVaultClient({});
|
|
305
|
-
const { factory } = makeStubS3Factory({
|
|
363
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
306
364
|
listResponses: [{ Contents: [] }],
|
|
307
365
|
});
|
|
308
366
|
await runBrowse({
|
|
@@ -311,29 +369,32 @@ describe("runBrowse", () => {
|
|
|
311
369
|
s3Factory: factory,
|
|
312
370
|
region: "us-east-1",
|
|
313
371
|
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
expect(
|
|
317
|
-
expect(
|
|
318
|
-
|
|
319
|
-
|
|
372
|
+
// Vend through the multi-tenant STS route — NOT the legacy POST /vend.
|
|
373
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
|
|
375
|
+
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
376
|
+
// S3 list prefix is company-relative (the bug: was anchored → 0 results).
|
|
377
|
+
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
378
|
+
expect(listCmd.input.Prefix).toBe("scratch/");
|
|
320
379
|
});
|
|
321
380
|
|
|
322
|
-
it("paginates ListObjectsV2 fully and
|
|
381
|
+
it("paginates ListObjectsV2 fully, classifies ACL, and re-anchors keys for display", async () => {
|
|
323
382
|
const { client } = makeStubVaultClient({
|
|
324
|
-
grants
|
|
383
|
+
// Real grants are glob/anchored; normalization folds this to "scratch/".
|
|
384
|
+
grants: [fakeGrant("companies/indigo/scratch/*")],
|
|
325
385
|
});
|
|
326
386
|
const { factory, sendSpy } = makeStubS3Factory({
|
|
387
|
+
// S3 keys are company-relative (no companies/<slug>/ prefix).
|
|
327
388
|
listResponses: [
|
|
328
389
|
{
|
|
329
390
|
Contents: [
|
|
330
391
|
{
|
|
331
|
-
Key: "
|
|
392
|
+
Key: "scratch/a.txt",
|
|
332
393
|
Size: 10,
|
|
333
394
|
LastModified: new Date("2026-01-01T00:00:00Z"),
|
|
334
395
|
},
|
|
335
396
|
{
|
|
336
|
-
Key: "
|
|
397
|
+
Key: "secrets/db.txt",
|
|
337
398
|
Size: 20,
|
|
338
399
|
LastModified: new Date("2026-01-02T00:00:00Z"),
|
|
339
400
|
},
|
|
@@ -343,13 +404,13 @@ describe("runBrowse", () => {
|
|
|
343
404
|
{
|
|
344
405
|
Contents: [
|
|
345
406
|
{
|
|
346
|
-
Key: "
|
|
407
|
+
Key: "scratch/sub/b.bin",
|
|
347
408
|
Size: 30,
|
|
348
409
|
LastModified: new Date("2026-01-03T00:00:00Z"),
|
|
349
410
|
},
|
|
350
411
|
// S3 directory marker — should be filtered out.
|
|
351
412
|
{
|
|
352
|
-
Key: "
|
|
413
|
+
Key: "scratch/empty/",
|
|
353
414
|
Size: 0,
|
|
354
415
|
LastModified: new Date("2026-01-04T00:00:00Z"),
|
|
355
416
|
},
|
|
@@ -365,6 +426,7 @@ describe("runBrowse", () => {
|
|
|
365
426
|
});
|
|
366
427
|
expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
|
|
367
428
|
expect(result.rows).toHaveLength(3);
|
|
429
|
+
// Displayed keys are re-anchored to companies/<slug>/.
|
|
368
430
|
const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
|
|
369
431
|
expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
|
|
370
432
|
"shared-with-you",
|
|
@@ -394,13 +456,11 @@ describe("runBrowse", () => {
|
|
|
394
456
|
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
395
457
|
//
|
|
396
458
|
// Personal mode resolves the entity via `entity.get(personalUid)` (skipping
|
|
397
|
-
// the company-namespace lookup),
|
|
398
|
-
// every row
|
|
399
|
-
// relative — companies/<slug>/ prefix is NOT required
|
|
400
|
-
// incorrect, since the person bucket is owner-only with no companies/
|
|
401
|
-
// subtree).
|
|
459
|
+
// the company-namespace lookup), vends via `/sts/vend-self`, omits the
|
|
460
|
+
// explicit-grants fetch, and tags every row `aclSource: "personal-vault"`.
|
|
461
|
+
// The path arg is bucket-relative — companies/<slug>/ prefix is NOT required.
|
|
402
462
|
|
|
403
|
-
it("personalMode: resolves
|
|
463
|
+
it("personalMode: resolves via entity.get, vends /sts/vend-self, skips namespace + grants", async () => {
|
|
404
464
|
const { client, spies } = makeStubVaultClient({
|
|
405
465
|
entity: {
|
|
406
466
|
uid: "prs_test",
|
|
@@ -431,10 +491,9 @@ describe("runBrowse", () => {
|
|
|
431
491
|
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
432
492
|
// Grants graph is a company concept — must not be fetched.
|
|
433
493
|
expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
|
|
434
|
-
//
|
|
435
|
-
expect(spies.
|
|
436
|
-
|
|
437
|
-
);
|
|
494
|
+
// Personal vends self, never the company route.
|
|
495
|
+
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
496
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
438
497
|
});
|
|
439
498
|
|
|
440
499
|
it("personalMode: empty pathPrefix lists the bucket root", async () => {
|
|
@@ -540,9 +599,9 @@ describe("runBrowse", () => {
|
|
|
540
599
|
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
541
600
|
|
|
542
601
|
describe("runCat", () => {
|
|
543
|
-
it("vends
|
|
602
|
+
it("vends via /sts/vend and GetObjects the company-relative key", async () => {
|
|
544
603
|
const { client, spies } = makeStubVaultClient({});
|
|
545
|
-
const { factory } = makeStubS3Factory({
|
|
604
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
546
605
|
getResponse: {
|
|
547
606
|
Body: Readable.from(Buffer.from("hello world")),
|
|
548
607
|
} as GetObjectCommandOutput,
|
|
@@ -557,8 +616,14 @@ describe("runCat", () => {
|
|
|
557
616
|
hqRoot: tmpRoot,
|
|
558
617
|
stdout: sink,
|
|
559
618
|
});
|
|
560
|
-
expect(spies.
|
|
561
|
-
expect(spies.
|
|
619
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
620
|
+
expect(spies.stsVend.mock.calls[0][0]).toEqual({ companyUid: "cmp_indigo" });
|
|
621
|
+
// GetObject key is company-relative (anchor stripped).
|
|
622
|
+
const getCmd = sendSpy.mock.calls.find(
|
|
623
|
+
(c) => c[0] instanceof GetObjectCommand,
|
|
624
|
+
)?.[0] as GetObjectCommand;
|
|
625
|
+
expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
|
|
626
|
+
expect(getCmd.input.Key).toBe("scratch/a.txt");
|
|
562
627
|
});
|
|
563
628
|
|
|
564
629
|
it("writes to --out when outside the companies tree and reports byte count", async () => {
|
|
@@ -601,7 +666,8 @@ describe("runCat", () => {
|
|
|
601
666
|
).rejects.toThrow(/Refusing to write/);
|
|
602
667
|
// Critically: no vend was issued (guard runs first) and no S3 call
|
|
603
668
|
// was made — failing closed is the whole point of the guard.
|
|
604
|
-
expect(spies.
|
|
669
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
670
|
+
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
605
671
|
expect(sendSpy).not.toHaveBeenCalled();
|
|
606
672
|
// And no file was written under the protected tree.
|
|
607
673
|
expect(fs.existsSync(badOut)).toBe(false);
|
|
@@ -623,7 +689,7 @@ describe("runCat", () => {
|
|
|
623
689
|
|
|
624
690
|
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
625
691
|
|
|
626
|
-
it("personalMode: streams from the person bucket, no slug parse
|
|
692
|
+
it("personalMode: streams from the person bucket via /sts/vend-self, no slug parse", async () => {
|
|
627
693
|
const { client, spies } = makeStubVaultClient({
|
|
628
694
|
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
629
695
|
});
|
|
@@ -657,14 +723,10 @@ describe("runCat", () => {
|
|
|
657
723
|
|
|
658
724
|
expect(result.destination.kind).toBe("stdout");
|
|
659
725
|
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
660
|
-
//
|
|
661
|
-
expect(spies.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
purpose: "browse",
|
|
665
|
-
}),
|
|
666
|
-
);
|
|
667
|
-
// GetObject targeted the person bucket.
|
|
726
|
+
// Personal vends self, against the bucket-relative key.
|
|
727
|
+
expect(spies.vendSelf).toHaveBeenCalledWith({ personUid: "prs_test" });
|
|
728
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
729
|
+
// GetObject targeted the person bucket with the bucket-relative key.
|
|
668
730
|
const getCmd = sendSpy.mock.calls.find(
|
|
669
731
|
(c) => c[0] instanceof GetObjectCommand,
|
|
670
732
|
)?.[0] as GetObjectCommand;
|
|
@@ -774,3 +836,201 @@ describe("runSharedWithMe", () => {
|
|
|
774
836
|
expect(out).toContain("knowledge/");
|
|
775
837
|
});
|
|
776
838
|
});
|
|
839
|
+
|
|
840
|
+
// ── runSearch ─────────────────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
describe("runSearch", () => {
|
|
843
|
+
it("filters the company listing by case-insensitive substring on the key", async () => {
|
|
844
|
+
const { client, spies } = makeStubVaultClient({});
|
|
845
|
+
const { factory } = makeStubS3Factory({
|
|
846
|
+
listResponses: [
|
|
847
|
+
{
|
|
848
|
+
Contents: [
|
|
849
|
+
{ Key: "knowledge/Roadmap.md", Size: 1, LastModified: new Date() },
|
|
850
|
+
{ Key: "reports/q3.pdf", Size: 2, LastModified: new Date() },
|
|
851
|
+
{ Key: "knowledge/notes.md", Size: 3, LastModified: new Date() },
|
|
852
|
+
],
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
});
|
|
856
|
+
const rows = await runSearch({
|
|
857
|
+
query: "roadmap", // lower-case query matches mixed-case key
|
|
858
|
+
companySlug: "indigo",
|
|
859
|
+
vaultClient: client,
|
|
860
|
+
s3Factory: factory,
|
|
861
|
+
region: "us-east-1",
|
|
862
|
+
});
|
|
863
|
+
// Keys are re-anchored for display; only the matching one survives.
|
|
864
|
+
expect(rows.map((r) => r.key)).toEqual([
|
|
865
|
+
"companies/indigo/knowledge/Roadmap.md",
|
|
866
|
+
]);
|
|
867
|
+
// Vends via the multi-tenant route (inherited from runBrowse).
|
|
868
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("returns an empty array when nothing matches", async () => {
|
|
872
|
+
const { client } = makeStubVaultClient({});
|
|
873
|
+
const { factory } = makeStubS3Factory({
|
|
874
|
+
listResponses: [
|
|
875
|
+
{ Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
|
|
876
|
+
],
|
|
877
|
+
});
|
|
878
|
+
const rows = await runSearch({
|
|
879
|
+
query: "zzz-no-match",
|
|
880
|
+
companySlug: "indigo",
|
|
881
|
+
vaultClient: client,
|
|
882
|
+
s3Factory: factory,
|
|
883
|
+
region: "us-east-1",
|
|
884
|
+
});
|
|
885
|
+
expect(rows).toEqual([]);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// ── pin set (readPins / addPin) ─────────────────────────────────────────────
|
|
890
|
+
|
|
891
|
+
describe("pin set", () => {
|
|
892
|
+
it("readPins returns an empty set when the file is missing", () => {
|
|
893
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("addPin creates the file, dedups, and sorts prefixes", () => {
|
|
897
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
898
|
+
addPin(tmpRoot, "indigo", "knowledge/"); // duplicate — ignored
|
|
899
|
+
addPin(tmpRoot, "indigo", "data/");
|
|
900
|
+
const pf = readPins(tmpRoot);
|
|
901
|
+
expect(pf.pins.indigo).toEqual(["data/", "knowledge/"]);
|
|
902
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(true);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("addPin keeps per-company lists separate", () => {
|
|
906
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
907
|
+
addPin(tmpRoot, "acme", "docs/");
|
|
908
|
+
const pf = readPins(tmpRoot);
|
|
909
|
+
expect(pf.pins).toEqual({ indigo: ["knowledge/"], acme: ["docs/"] });
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("readPins tolerates a corrupt pins.json (→ fresh)", () => {
|
|
913
|
+
fs.mkdirSync(path.dirname(pinFilePath(tmpRoot)), { recursive: true });
|
|
914
|
+
fs.writeFileSync(pinFilePath(tmpRoot), "{ not valid json");
|
|
915
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// ── runGet ────────────────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
function makeGetStubS3(opts: {
|
|
922
|
+
listKeys: Array<{ Key: string; Size: number }>;
|
|
923
|
+
bodyFor: (key: string) => Buffer;
|
|
924
|
+
}): { factory: S3ClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
|
|
925
|
+
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
926
|
+
if (cmd instanceof ListObjectsV2Command) {
|
|
927
|
+
return {
|
|
928
|
+
Contents: opts.listKeys.map((k) => ({ ...k, LastModified: new Date() })),
|
|
929
|
+
} as ListObjectsV2CommandOutput;
|
|
930
|
+
}
|
|
931
|
+
if (cmd instanceof GetObjectCommand) {
|
|
932
|
+
const key = (cmd as GetObjectCommand).input.Key as string;
|
|
933
|
+
return {
|
|
934
|
+
Body: Readable.from(opts.bodyFor(key)),
|
|
935
|
+
} as unknown as GetObjectCommandOutput;
|
|
936
|
+
}
|
|
937
|
+
throw new Error(`unexpected command: ${cmd}`);
|
|
938
|
+
});
|
|
939
|
+
const factory = vi.fn(
|
|
940
|
+
() => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
|
|
941
|
+
) as unknown as S3ClientFactory;
|
|
942
|
+
return { factory, sendSpy };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
describe("runGet", () => {
|
|
946
|
+
it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
|
|
947
|
+
const { client, spies } = makeStubVaultClient({});
|
|
948
|
+
const { factory, sendSpy } = makeGetStubS3({
|
|
949
|
+
listKeys: [
|
|
950
|
+
{ Key: "knowledge/a.md", Size: 3 },
|
|
951
|
+
{ Key: "knowledge/sub/b.md", Size: 3 },
|
|
952
|
+
],
|
|
953
|
+
bodyFor: (k) => Buffer.from(k === "knowledge/a.md" ? "AAA" : "BBB"),
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const result = await runGet({
|
|
957
|
+
path: "companies/indigo/knowledge/",
|
|
958
|
+
hqRoot: tmpRoot,
|
|
959
|
+
companySlug: "indigo",
|
|
960
|
+
vaultClient: client,
|
|
961
|
+
s3Factory: factory,
|
|
962
|
+
region: "us-east-1",
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Vends via the multi-tenant company route.
|
|
966
|
+
expect(spies.stsVend).toHaveBeenCalledWith({ companyUid: "cmp_indigo" });
|
|
967
|
+
expect(result.filesWritten).toBe(2);
|
|
968
|
+
|
|
969
|
+
// Files landed in place under companies/<slug>/.
|
|
970
|
+
expect(
|
|
971
|
+
fs.readFileSync(
|
|
972
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "a.md"),
|
|
973
|
+
"utf-8",
|
|
974
|
+
),
|
|
975
|
+
).toBe("AAA");
|
|
976
|
+
expect(
|
|
977
|
+
fs.readFileSync(
|
|
978
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "sub", "b.md"),
|
|
979
|
+
"utf-8",
|
|
980
|
+
),
|
|
981
|
+
).toBe("BBB");
|
|
982
|
+
|
|
983
|
+
// GetObject used company-relative keys (no companies/<slug>/ anchor).
|
|
984
|
+
const getKeys = sendSpy.mock.calls
|
|
985
|
+
.filter((c) => c[0] instanceof GetObjectCommand)
|
|
986
|
+
.map((c) => (c[0] as GetObjectCommand).input.Key);
|
|
987
|
+
expect(getKeys).toEqual(["knowledge/a.md", "knowledge/sub/b.md"]);
|
|
988
|
+
|
|
989
|
+
// Pin registered for the in-place prefix.
|
|
990
|
+
expect(readPins(tmpRoot).pins.indigo).toEqual(["knowledge/"]);
|
|
991
|
+
expect(result.pinned).toEqual({ companySlug: "indigo", prefix: "knowledge/" });
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("--into writes outside companies/ and registers NO pin", async () => {
|
|
995
|
+
const into = path.join(tmpRoot, "extract");
|
|
996
|
+
const { client } = makeStubVaultClient({});
|
|
997
|
+
const { factory } = makeGetStubS3({
|
|
998
|
+
listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
|
|
999
|
+
bodyFor: () => Buffer.from("AAA"),
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const result = await runGet({
|
|
1003
|
+
path: "companies/indigo/knowledge/",
|
|
1004
|
+
into,
|
|
1005
|
+
hqRoot: tmpRoot,
|
|
1006
|
+
companySlug: "indigo",
|
|
1007
|
+
vaultClient: client,
|
|
1008
|
+
s3Factory: factory,
|
|
1009
|
+
region: "us-east-1",
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Written relative to the requested prefix, under --into.
|
|
1013
|
+
expect(fs.readFileSync(path.join(into, "a.md"), "utf-8")).toBe("AAA");
|
|
1014
|
+
// No pin — --into is outside the sync envelope.
|
|
1015
|
+
expect(result.pinned).toBeUndefined();
|
|
1016
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(false);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("throws when no objects exist under the path", async () => {
|
|
1020
|
+
const { client } = makeStubVaultClient({});
|
|
1021
|
+
const { factory } = makeGetStubS3({
|
|
1022
|
+
listKeys: [],
|
|
1023
|
+
bodyFor: () => Buffer.from(""),
|
|
1024
|
+
});
|
|
1025
|
+
await expect(
|
|
1026
|
+
runGet({
|
|
1027
|
+
path: "companies/indigo/nope/",
|
|
1028
|
+
hqRoot: tmpRoot,
|
|
1029
|
+
companySlug: "indigo",
|
|
1030
|
+
vaultClient: client,
|
|
1031
|
+
s3Factory: factory,
|
|
1032
|
+
region: "us-east-1",
|
|
1033
|
+
}),
|
|
1034
|
+
).rejects.toThrow(/No objects under/);
|
|
1035
|
+
});
|
|
1036
|
+
});
|