@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 VaultClient.vend / listMyExplicitGrants / entity.findInMyNamespace
5
- * and the S3 client so we cover the three acceptance criteria the unit
6
- * suite is responsible for (acceptance 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 uses `purpose: 'browse'` (NOT `'sync'`) for both subcommands.
9
- * 2. `--out` refuses any destination under `<hqRoot>/companies/`.
10
- * 3. ACL-source classification: keys with a covering explicit grant →
11
- * `shared-with-you`; keys with no covering grant `role-bypass`.
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, classifyAclSource,
14
- * assertOutPathOutsideCompanies, formatBrowseTable).
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, VendResult } from "@indigoai-us/hq-cloud";
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 fakeVendResult(overrides: Partial<VendResult> = {}): VendResult {
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
- vend: ReturnType<typeof vi.fn>;
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 vend = vi.fn(async () => opts.vend ?? fakeVendResult());
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 { client, spies: { vend, listMyExplicitGrants, findInMyNamespace } };
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 prefixes the key", () => {
181
- const grants = [fakeGrant("companies/indigo/scratch/")];
182
- expect(
183
- classifyAclSource("companies/indigo/scratch/foo.txt", grants),
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
- const grants = [fakeGrant("companies/indigo/scratch/")];
189
- expect(
190
- classifyAclSource("companies/indigo/secrets/db.txt", grants),
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("companies/indigo/anything/x", [])).toBe(
196
- "role-bypass",
197
- );
255
+ expect(classifyAclSource("anything/x", [])).toBe("role-bypass");
198
256
  });
199
257
 
200
- it("matches against the first covering grant multiple grants are fine", () => {
201
- const grants = [
202
- fakeGrant("companies/other/"),
203
- fakeGrant("companies/indigo/scratch/"),
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("companies/indigo/scratch/sub/y.bin", grants),
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 with purpose='browse' (NOT 'sync') for the requested prefix", async () => {
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
- expect(spies.vend).toHaveBeenCalledTimes(1);
315
- const arg = spies.vend.mock.calls[0][0];
316
- expect(arg.purpose).toBe("browse");
317
- expect(arg.purpose).not.toBe("sync");
318
- expect(arg.operations).toBe("read-only");
319
- expect(arg.paths).toEqual(["companies/indigo/scratch/"]);
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 classifies each key's ACL source", async () => {
381
+ it("paginates ListObjectsV2 fully, classifies ACL, and re-anchors keys for display", async () => {
323
382
  const { client } = makeStubVaultClient({
324
- grants: [fakeGrant("companies/indigo/scratch/")],
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: "companies/indigo/scratch/a.txt",
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: "companies/indigo/secrets/db.txt",
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: "companies/indigo/scratch/sub/b.bin",
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: "companies/indigo/scratch/empty/",
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), omits the explicit-grants fetch, and tags
398
- // every row with `aclSource: "personal-vault"`. The path arg is bucket-
399
- // relative — companies/<slug>/ prefix is NOT required (and would be
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 entity via entity.get(personalUid), skips namespace lookup", async () => {
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
- // Vend still issued for browse purpose, no policy difference.
435
- expect(spies.vend).toHaveBeenCalledWith(
436
- expect.objectContaining({ purpose: "browse", operations: "read-only" }),
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 with purpose='browse' for a cat call", async () => {
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.vend).toHaveBeenCalledTimes(1);
561
- expect(spies.vend.mock.calls[0][0].purpose).toBe("browse");
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.vend).not.toHaveBeenCalled();
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 on the key", async () => {
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
- // Vend issued against the bucket-relative key, browse purpose.
661
- expect(spies.vend).toHaveBeenCalledWith(
662
- expect.objectContaining({
663
- paths: [".claude/CLAUDE.md"],
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
+ });