@indigoai-us/hq-cli 5.17.0 → 5.18.1
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/cloud.d.ts +63 -1
- package/dist/commands/cloud.js +214 -11
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.pull-per-company.test.ts +188 -0
- package/src/commands/cloud.ts +327 -5
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq files browse` + `hq files cat` (files-browse.ts).
|
|
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):
|
|
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`.
|
|
12
|
+
*
|
|
13
|
+
* Plus the pure helpers (parseCompanySlugFromPath, classifyAclSource,
|
|
14
|
+
* assertOutPathOutsideCompanies, formatBrowseTable).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { Readable, Writable } from "node:stream";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
assertOutPathOutsideCompanies,
|
|
25
|
+
classifyAclSource,
|
|
26
|
+
formatBrowseTable,
|
|
27
|
+
parseCompanySlugFromPath,
|
|
28
|
+
runBrowse,
|
|
29
|
+
runCat,
|
|
30
|
+
type FilesBrowseS3Client,
|
|
31
|
+
type FilesBrowseVaultClient,
|
|
32
|
+
type S3ClientFactory,
|
|
33
|
+
} from "./files-browse.js";
|
|
34
|
+
import type { ExplicitGrant, VendResult } from "@indigoai-us/hq-cloud";
|
|
35
|
+
import {
|
|
36
|
+
ListObjectsV2Command,
|
|
37
|
+
GetObjectCommand,
|
|
38
|
+
type ListObjectsV2CommandOutput,
|
|
39
|
+
type GetObjectCommandOutput,
|
|
40
|
+
} from "@aws-sdk/client-s3";
|
|
41
|
+
|
|
42
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
let tmpRoot: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-files-browse-"));
|
|
48
|
+
// Create the companies/ subtree so the guard has a real path to compare
|
|
49
|
+
// against (the guard's check is string-level so this isn't strictly
|
|
50
|
+
// required, but it mirrors the real layout).
|
|
51
|
+
fs.mkdirSync(path.join(tmpRoot, "companies", "indigo"), { recursive: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function fakeGrant(p: string): ExplicitGrant {
|
|
60
|
+
return {
|
|
61
|
+
companyUid: "cmp_indigo",
|
|
62
|
+
path: p,
|
|
63
|
+
permission: "read",
|
|
64
|
+
source: "person",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fakeVendResult(overrides: Partial<VendResult> = {}): VendResult {
|
|
69
|
+
return {
|
|
70
|
+
credentials: {
|
|
71
|
+
accessKeyId: "ASIA-test",
|
|
72
|
+
secretAccessKey: "secret-test",
|
|
73
|
+
sessionToken: "session-test",
|
|
74
|
+
expiration: new Date(Date.now() + 900_000).toISOString(),
|
|
75
|
+
},
|
|
76
|
+
paths: ["companies/indigo/"],
|
|
77
|
+
operations: "read-only",
|
|
78
|
+
purpose: "browse",
|
|
79
|
+
policySize: 512,
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface StubVaultOpts {
|
|
85
|
+
vend?: VendResult;
|
|
86
|
+
grants?: ExplicitGrant[];
|
|
87
|
+
entity?:
|
|
88
|
+
| { uid: string; slug: string; name?: string; bucketName?: string }
|
|
89
|
+
| null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeStubVaultClient(opts: StubVaultOpts = {}): {
|
|
93
|
+
client: FilesBrowseVaultClient;
|
|
94
|
+
spies: {
|
|
95
|
+
vend: ReturnType<typeof vi.fn>;
|
|
96
|
+
listMyExplicitGrants: ReturnType<typeof vi.fn>;
|
|
97
|
+
findInMyNamespace: ReturnType<typeof vi.fn>;
|
|
98
|
+
};
|
|
99
|
+
} {
|
|
100
|
+
const entity =
|
|
101
|
+
opts.entity === null
|
|
102
|
+
? null
|
|
103
|
+
: (opts.entity ?? {
|
|
104
|
+
uid: "cmp_indigo",
|
|
105
|
+
slug: "indigo",
|
|
106
|
+
name: "Indigo",
|
|
107
|
+
bucketName: "hq-vault-cmp-indigo",
|
|
108
|
+
});
|
|
109
|
+
const vend = vi.fn(async () => opts.vend ?? fakeVendResult());
|
|
110
|
+
const listMyExplicitGrants = vi.fn(async () => opts.grants ?? []);
|
|
111
|
+
const findInMyNamespace = vi.fn(async () => entity);
|
|
112
|
+
const get = vi.fn(async (uid: string) => {
|
|
113
|
+
if (!entity) throw new Error(`entity ${uid} not found`);
|
|
114
|
+
return entity;
|
|
115
|
+
});
|
|
116
|
+
const client: FilesBrowseVaultClient = {
|
|
117
|
+
vend,
|
|
118
|
+
listMyExplicitGrants,
|
|
119
|
+
entity: { get, findInMyNamespace },
|
|
120
|
+
};
|
|
121
|
+
return { client, spies: { vend, listMyExplicitGrants, findInMyNamespace } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface StubS3Opts {
|
|
125
|
+
listResponses?: ListObjectsV2CommandOutput[];
|
|
126
|
+
getResponse?: GetObjectCommandOutput;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeStubS3Factory(opts: StubS3Opts): {
|
|
130
|
+
factory: S3ClientFactory;
|
|
131
|
+
sendSpy: ReturnType<typeof vi.fn>;
|
|
132
|
+
factorySpy: ReturnType<typeof vi.fn>;
|
|
133
|
+
} {
|
|
134
|
+
const responses = [...(opts.listResponses ?? [])];
|
|
135
|
+
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
136
|
+
if (cmd instanceof ListObjectsV2Command) {
|
|
137
|
+
const next = responses.shift() ?? { Contents: [] };
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
if (cmd instanceof GetObjectCommand) {
|
|
141
|
+
return opts.getResponse ?? { Body: undefined };
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`unexpected command: ${cmd}`);
|
|
144
|
+
});
|
|
145
|
+
const factorySpy = vi.fn(
|
|
146
|
+
() =>
|
|
147
|
+
({
|
|
148
|
+
send: sendSpy,
|
|
149
|
+
}) as unknown as FilesBrowseS3Client,
|
|
150
|
+
);
|
|
151
|
+
return { factory: factorySpy as unknown as S3ClientFactory, sendSpy, factorySpy };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── parseCompanySlugFromPath ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe("parseCompanySlugFromPath", () => {
|
|
157
|
+
it("extracts slug from a canonical prefix", () => {
|
|
158
|
+
expect(parseCompanySlugFromPath("companies/indigo/scratch/")).toBe("indigo");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("tolerates leading slashes", () => {
|
|
162
|
+
expect(parseCompanySlugFromPath("/companies/acme/foo")).toBe("acme");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects paths not anchored at companies/", () => {
|
|
166
|
+
expect(() => parseCompanySlugFromPath("personal/notes/")).toThrow(
|
|
167
|
+
/Expected a path starting with 'companies\//,
|
|
168
|
+
);
|
|
169
|
+
expect(() => parseCompanySlugFromPath("")).toThrow();
|
|
170
|
+
expect(() => parseCompanySlugFromPath("companies/")).toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── classifyAclSource ───────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe("classifyAclSource", () => {
|
|
177
|
+
it("returns shared-with-you when any grant prefixes the key", () => {
|
|
178
|
+
const grants = [fakeGrant("companies/indigo/scratch/")];
|
|
179
|
+
expect(
|
|
180
|
+
classifyAclSource("companies/indigo/scratch/foo.txt", grants),
|
|
181
|
+
).toBe("shared-with-you");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns role-bypass when no grant covers the key", () => {
|
|
185
|
+
const grants = [fakeGrant("companies/indigo/scratch/")];
|
|
186
|
+
expect(
|
|
187
|
+
classifyAclSource("companies/indigo/secrets/db.txt", grants),
|
|
188
|
+
).toBe("role-bypass");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns role-bypass on an empty grant list", () => {
|
|
192
|
+
expect(classifyAclSource("companies/indigo/anything/x", [])).toBe(
|
|
193
|
+
"role-bypass",
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("matches against the first covering grant — multiple grants are fine", () => {
|
|
198
|
+
const grants = [
|
|
199
|
+
fakeGrant("companies/other/"),
|
|
200
|
+
fakeGrant("companies/indigo/scratch/"),
|
|
201
|
+
];
|
|
202
|
+
expect(
|
|
203
|
+
classifyAclSource("companies/indigo/scratch/sub/y.bin", grants),
|
|
204
|
+
).toBe("shared-with-you");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── assertOutPathOutsideCompanies (writes-guard) ───────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("assertOutPathOutsideCompanies", () => {
|
|
211
|
+
it("refuses a destination directly under <hqRoot>/companies/", () => {
|
|
212
|
+
const dest = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
|
|
213
|
+
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow(
|
|
214
|
+
/Refusing to write/,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("refuses a destination deep under <hqRoot>/companies/", () => {
|
|
219
|
+
const dest = path.join(
|
|
220
|
+
tmpRoot,
|
|
221
|
+
"companies",
|
|
222
|
+
"indigo",
|
|
223
|
+
"scratch",
|
|
224
|
+
"deep",
|
|
225
|
+
"leaked.txt",
|
|
226
|
+
);
|
|
227
|
+
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("refuses the bare companies/ root", () => {
|
|
231
|
+
const dest = path.join(tmpRoot, "companies");
|
|
232
|
+
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("permits a destination outside <hqRoot>/companies/ (e.g. /tmp)", () => {
|
|
236
|
+
const dest = path.join(tmpRoot, "scratch.txt");
|
|
237
|
+
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).not.toThrow();
|
|
238
|
+
expect(assertOutPathOutsideCompanies(dest, tmpRoot)).toBe(
|
|
239
|
+
path.resolve(dest),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("permits an absolute path under /tmp that is unrelated to hqRoot", () => {
|
|
244
|
+
// Use a sibling tmp dir as the "outside" location so the test is
|
|
245
|
+
// platform-portable (no hard-coded /tmp/foo paths).
|
|
246
|
+
const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-out-"));
|
|
247
|
+
try {
|
|
248
|
+
const dest = path.join(otherRoot, "leaked.txt");
|
|
249
|
+
expect(() => assertOutPathOutsideCompanies(dest, tmpRoot)).not.toThrow();
|
|
250
|
+
} finally {
|
|
251
|
+
fs.rmSync(otherRoot, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("refuses a relative path that resolves under companies/", () => {
|
|
256
|
+
// On macOS the OS tmp dir is a symlink (`/tmp` → `/private/tmp`,
|
|
257
|
+
// `/var/folders/...` is the canonical form). Resolving the realpath
|
|
258
|
+
// on both sides keeps `path.resolve`'s output and the guard's
|
|
259
|
+
// protected-root match on the same physical tree, so a cwd-relative
|
|
260
|
+
// "companies/indigo/…" lookup is correctly classified as protected.
|
|
261
|
+
const realRoot = fs.realpathSync(tmpRoot);
|
|
262
|
+
const cwd = process.cwd();
|
|
263
|
+
process.chdir(realRoot);
|
|
264
|
+
try {
|
|
265
|
+
expect(() =>
|
|
266
|
+
assertOutPathOutsideCompanies("companies/indigo/foo.txt", realRoot),
|
|
267
|
+
).toThrow();
|
|
268
|
+
} finally {
|
|
269
|
+
process.chdir(cwd);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── formatBrowseTable ───────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
describe("formatBrowseTable", () => {
|
|
277
|
+
it("renders an empty marker when no rows", () => {
|
|
278
|
+
expect(formatBrowseTable([])).toMatch(/No objects/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("includes key, size, modified, and ACL source in the row", () => {
|
|
282
|
+
const out = formatBrowseTable([
|
|
283
|
+
{
|
|
284
|
+
key: "companies/indigo/scratch/a.txt",
|
|
285
|
+
size: 42,
|
|
286
|
+
lastModified: new Date("2026-01-15T10:00:00Z"),
|
|
287
|
+
aclSource: "shared-with-you",
|
|
288
|
+
},
|
|
289
|
+
]);
|
|
290
|
+
expect(out).toContain("companies/indigo/scratch/a.txt");
|
|
291
|
+
expect(out).toContain("42");
|
|
292
|
+
expect(out).toContain("2026-01-15");
|
|
293
|
+
expect(out).toContain("shared-with-you");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── runBrowse ───────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("runBrowse", () => {
|
|
300
|
+
it("vends with purpose='browse' (NOT 'sync') for the requested prefix", async () => {
|
|
301
|
+
const { client, spies } = makeStubVaultClient({});
|
|
302
|
+
const { factory } = makeStubS3Factory({
|
|
303
|
+
listResponses: [{ Contents: [] }],
|
|
304
|
+
});
|
|
305
|
+
await runBrowse({
|
|
306
|
+
pathPrefix: "companies/indigo/scratch/",
|
|
307
|
+
vaultClient: client,
|
|
308
|
+
s3Factory: factory,
|
|
309
|
+
region: "us-east-1",
|
|
310
|
+
});
|
|
311
|
+
expect(spies.vend).toHaveBeenCalledTimes(1);
|
|
312
|
+
const arg = spies.vend.mock.calls[0][0];
|
|
313
|
+
expect(arg.purpose).toBe("browse");
|
|
314
|
+
expect(arg.purpose).not.toBe("sync");
|
|
315
|
+
expect(arg.operations).toBe("read-only");
|
|
316
|
+
expect(arg.paths).toEqual(["companies/indigo/scratch/"]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("paginates ListObjectsV2 fully and classifies each key's ACL source", async () => {
|
|
320
|
+
const { client } = makeStubVaultClient({
|
|
321
|
+
grants: [fakeGrant("companies/indigo/scratch/")],
|
|
322
|
+
});
|
|
323
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
324
|
+
listResponses: [
|
|
325
|
+
{
|
|
326
|
+
Contents: [
|
|
327
|
+
{
|
|
328
|
+
Key: "companies/indigo/scratch/a.txt",
|
|
329
|
+
Size: 10,
|
|
330
|
+
LastModified: new Date("2026-01-01T00:00:00Z"),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
Key: "companies/indigo/secrets/db.txt",
|
|
334
|
+
Size: 20,
|
|
335
|
+
LastModified: new Date("2026-01-02T00:00:00Z"),
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
NextContinuationToken: "page2",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
Contents: [
|
|
342
|
+
{
|
|
343
|
+
Key: "companies/indigo/scratch/sub/b.bin",
|
|
344
|
+
Size: 30,
|
|
345
|
+
LastModified: new Date("2026-01-03T00:00:00Z"),
|
|
346
|
+
},
|
|
347
|
+
// S3 directory marker — should be filtered out.
|
|
348
|
+
{
|
|
349
|
+
Key: "companies/indigo/scratch/empty/",
|
|
350
|
+
Size: 0,
|
|
351
|
+
LastModified: new Date("2026-01-04T00:00:00Z"),
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
});
|
|
357
|
+
const result = await runBrowse({
|
|
358
|
+
pathPrefix: "companies/indigo/scratch/",
|
|
359
|
+
vaultClient: client,
|
|
360
|
+
s3Factory: factory,
|
|
361
|
+
region: "us-east-1",
|
|
362
|
+
});
|
|
363
|
+
expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
|
|
364
|
+
expect(result.rows).toHaveLength(3);
|
|
365
|
+
const byKey = Object.fromEntries(result.rows.map((r) => [r.key, r]));
|
|
366
|
+
expect(byKey["companies/indigo/scratch/a.txt"].aclSource).toBe(
|
|
367
|
+
"shared-with-you",
|
|
368
|
+
);
|
|
369
|
+
expect(byKey["companies/indigo/scratch/sub/b.bin"].aclSource).toBe(
|
|
370
|
+
"shared-with-you",
|
|
371
|
+
);
|
|
372
|
+
// Visible only via role-bypass — this is the leak-out-of-grant case.
|
|
373
|
+
expect(byKey["companies/indigo/secrets/db.txt"].aclSource).toBe(
|
|
374
|
+
"role-bypass",
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("throws when the slug doesn't resolve in the caller's namespace", async () => {
|
|
379
|
+
const { client } = makeStubVaultClient({ entity: null });
|
|
380
|
+
const { factory } = makeStubS3Factory({ listResponses: [] });
|
|
381
|
+
await expect(
|
|
382
|
+
runBrowse({
|
|
383
|
+
pathPrefix: "companies/notmine/scratch/",
|
|
384
|
+
vaultClient: client,
|
|
385
|
+
s3Factory: factory,
|
|
386
|
+
region: "us-east-1",
|
|
387
|
+
}),
|
|
388
|
+
).rejects.toThrow(/No company found for slug 'notmine'/);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
describe("runCat", () => {
|
|
395
|
+
it("vends with purpose='browse' for a cat call", async () => {
|
|
396
|
+
const { client, spies } = makeStubVaultClient({});
|
|
397
|
+
const { factory } = makeStubS3Factory({
|
|
398
|
+
getResponse: {
|
|
399
|
+
Body: Readable.from(Buffer.from("hello world")),
|
|
400
|
+
} as GetObjectCommandOutput,
|
|
401
|
+
});
|
|
402
|
+
// Drain to a sink so process.stdout isn't touched.
|
|
403
|
+
const sink = new Writable({ write(_c, _e, cb) { cb(); } });
|
|
404
|
+
await runCat({
|
|
405
|
+
key: "companies/indigo/scratch/a.txt",
|
|
406
|
+
vaultClient: client,
|
|
407
|
+
s3Factory: factory,
|
|
408
|
+
region: "us-east-1",
|
|
409
|
+
hqRoot: tmpRoot,
|
|
410
|
+
stdout: sink,
|
|
411
|
+
});
|
|
412
|
+
expect(spies.vend).toHaveBeenCalledTimes(1);
|
|
413
|
+
expect(spies.vend.mock.calls[0][0].purpose).toBe("browse");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("writes to --out when outside the companies tree and reports byte count", async () => {
|
|
417
|
+
const { client } = makeStubVaultClient({});
|
|
418
|
+
const payload = Buffer.from("safe-payload");
|
|
419
|
+
const { factory } = makeStubS3Factory({
|
|
420
|
+
getResponse: {
|
|
421
|
+
Body: Readable.from(payload),
|
|
422
|
+
} as GetObjectCommandOutput,
|
|
423
|
+
});
|
|
424
|
+
const outFile = path.join(tmpRoot, "safe.txt");
|
|
425
|
+
const result = await runCat({
|
|
426
|
+
key: "companies/indigo/scratch/a.txt",
|
|
427
|
+
out: outFile,
|
|
428
|
+
vaultClient: client,
|
|
429
|
+
s3Factory: factory,
|
|
430
|
+
region: "us-east-1",
|
|
431
|
+
hqRoot: tmpRoot,
|
|
432
|
+
});
|
|
433
|
+
expect(result.destination).toEqual({ kind: "file", absPath: outFile });
|
|
434
|
+
expect(fs.readFileSync(outFile, "utf-8")).toBe("safe-payload");
|
|
435
|
+
expect(result.bytesWritten).toBe(payload.length);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("refuses --out under <hqRoot>/companies/ BEFORE vending (no leak)", async () => {
|
|
439
|
+
const { client, spies } = makeStubVaultClient({});
|
|
440
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
441
|
+
getResponse: { Body: Readable.from(Buffer.from("nope")) } as GetObjectCommandOutput,
|
|
442
|
+
});
|
|
443
|
+
const badOut = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
|
|
444
|
+
await expect(
|
|
445
|
+
runCat({
|
|
446
|
+
key: "companies/indigo/scratch/a.txt",
|
|
447
|
+
out: badOut,
|
|
448
|
+
vaultClient: client,
|
|
449
|
+
s3Factory: factory,
|
|
450
|
+
region: "us-east-1",
|
|
451
|
+
hqRoot: tmpRoot,
|
|
452
|
+
}),
|
|
453
|
+
).rejects.toThrow(/Refusing to write/);
|
|
454
|
+
// Critically: no vend was issued (guard runs first) and no S3 call
|
|
455
|
+
// was made — failing closed is the whole point of the guard.
|
|
456
|
+
expect(spies.vend).not.toHaveBeenCalled();
|
|
457
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
458
|
+
// And no file was written under the protected tree.
|
|
459
|
+
expect(fs.existsSync(badOut)).toBe(false);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("rejects a vault key that isn't anchored at companies/", async () => {
|
|
463
|
+
const { client } = makeStubVaultClient({});
|
|
464
|
+
const { factory } = makeStubS3Factory({});
|
|
465
|
+
await expect(
|
|
466
|
+
runCat({
|
|
467
|
+
key: "personal/notes/x.txt",
|
|
468
|
+
vaultClient: client,
|
|
469
|
+
s3Factory: factory,
|
|
470
|
+
region: "us-east-1",
|
|
471
|
+
hqRoot: tmpRoot,
|
|
472
|
+
}),
|
|
473
|
+
).rejects.toThrow(/Expected a path starting with 'companies\//);
|
|
474
|
+
});
|
|
475
|
+
});
|