@indigoai-us/hq-cli 5.47.6 → 5.47.8
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 +46 -6
- package/dist/commands/files-browse.js +134 -40
- package/dist/commands/signals.js +8 -3
- package/dist/commands/sources.js +8 -3
- package/package.json +2 -2
- package/src/commands/files-browse.test.ts +205 -41
- package/src/commands/files-browse.ts +227 -48
- package/src/commands/signals.ts +6 -0
- package/src/commands/sources.ts +6 -0
- package/test/commands/signals.test.ts +15 -8
- package/test/commands/sources.test.ts +15 -10
- package/test/helpers/vault-service-mock.ts +72 -1
|
@@ -42,11 +42,13 @@ import {
|
|
|
42
42
|
pinFilePath,
|
|
43
43
|
runSharedWithMe,
|
|
44
44
|
formatSharedWithMeTable,
|
|
45
|
+
createCompanyPresignClient,
|
|
45
46
|
type BrowseVendResult,
|
|
46
47
|
type FilesBrowseS3Client,
|
|
47
48
|
type FilesBrowseVaultClient,
|
|
48
49
|
type FilesSharedWithMeVaultClient,
|
|
49
50
|
type S3ClientFactory,
|
|
51
|
+
type CompanyBrowseClientFactory,
|
|
50
52
|
} from "./files-browse.js";
|
|
51
53
|
import type { ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
52
54
|
import {
|
|
@@ -143,6 +145,14 @@ interface StubS3Opts {
|
|
|
143
145
|
|
|
144
146
|
function makeStubS3Factory(opts: StubS3Opts): {
|
|
145
147
|
factory: S3ClientFactory;
|
|
148
|
+
/**
|
|
149
|
+
* The SAME stub typed as a company-mode client factory. Company browse/cat/
|
|
150
|
+
* get/search now inject `companyClient` (the presign/list seam) instead of
|
|
151
|
+
* `s3Factory`; the stub ignores its factory arg either way, so one stub
|
|
152
|
+
* serves both. Personal-mode tests use `factory`; company-mode tests use
|
|
153
|
+
* `companyFactory`.
|
|
154
|
+
*/
|
|
155
|
+
companyFactory: CompanyBrowseClientFactory;
|
|
146
156
|
sendSpy: ReturnType<typeof vi.fn>;
|
|
147
157
|
factorySpy: ReturnType<typeof vi.fn>;
|
|
148
158
|
} {
|
|
@@ -163,7 +173,12 @@ function makeStubS3Factory(opts: StubS3Opts): {
|
|
|
163
173
|
send: sendSpy,
|
|
164
174
|
}) as unknown as FilesBrowseS3Client,
|
|
165
175
|
);
|
|
166
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
factory: factorySpy as unknown as S3ClientFactory,
|
|
178
|
+
companyFactory: factorySpy as unknown as CompanyBrowseClientFactory,
|
|
179
|
+
sendSpy,
|
|
180
|
+
factorySpy,
|
|
181
|
+
};
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
// ── parseCompanySlugFromPath ────────────────────────────────────────────────
|
|
@@ -359,22 +374,22 @@ describe("formatBrowseTable", () => {
|
|
|
359
374
|
// ── runBrowse ───────────────────────────────────────────────────────────────
|
|
360
375
|
|
|
361
376
|
describe("runBrowse", () => {
|
|
362
|
-
it("
|
|
377
|
+
it("company mode lists via the presign/list client (no STS vend) on the company-relative prefix", async () => {
|
|
363
378
|
const { client, spies } = makeStubVaultClient({});
|
|
364
|
-
const {
|
|
379
|
+
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
365
380
|
listResponses: [{ Contents: [] }],
|
|
366
381
|
});
|
|
367
382
|
await runBrowse({
|
|
368
383
|
pathPrefix: "companies/indigo/scratch/",
|
|
369
384
|
vaultClient: client,
|
|
370
|
-
|
|
385
|
+
companyClient: companyFactory,
|
|
371
386
|
region: "us-east-1",
|
|
372
387
|
});
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
expect(spies.stsVend.
|
|
388
|
+
// HQ-59: company browse no longer vends STS creds — it goes through the
|
|
389
|
+
// server-side list API, so NEITHER vend route is touched.
|
|
390
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
376
391
|
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
377
|
-
//
|
|
392
|
+
// List prefix is company-relative (the bug: was anchored → 0 results).
|
|
378
393
|
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
379
394
|
expect(listCmd.input.Prefix).toBe("scratch/");
|
|
380
395
|
});
|
|
@@ -384,8 +399,8 @@ describe("runBrowse", () => {
|
|
|
384
399
|
// Real grants are glob/anchored; normalization folds this to "scratch/".
|
|
385
400
|
grants: [fakeGrant("companies/indigo/scratch/*")],
|
|
386
401
|
});
|
|
387
|
-
const {
|
|
388
|
-
//
|
|
402
|
+
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
403
|
+
// List objects are company-relative (no companies/<slug>/ prefix).
|
|
389
404
|
listResponses: [
|
|
390
405
|
{
|
|
391
406
|
Contents: [
|
|
@@ -422,7 +437,7 @@ describe("runBrowse", () => {
|
|
|
422
437
|
const result = await runBrowse({
|
|
423
438
|
pathPrefix: "companies/indigo/scratch/",
|
|
424
439
|
vaultClient: client,
|
|
425
|
-
|
|
440
|
+
companyClient: companyFactory,
|
|
426
441
|
region: "us-east-1",
|
|
427
442
|
});
|
|
428
443
|
expect(sendSpy).toHaveBeenCalledTimes(2); // pagination
|
|
@@ -600,9 +615,9 @@ describe("runBrowse", () => {
|
|
|
600
615
|
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
601
616
|
|
|
602
617
|
describe("runCat", () => {
|
|
603
|
-
it("
|
|
618
|
+
it("company mode fetches the company-relative key via presign (no STS vend)", async () => {
|
|
604
619
|
const { client, spies } = makeStubVaultClient({});
|
|
605
|
-
const {
|
|
620
|
+
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
606
621
|
getResponse: {
|
|
607
622
|
Body: Readable.from(Buffer.from("hello world")),
|
|
608
623
|
} as GetObjectCommandOutput,
|
|
@@ -612,25 +627,25 @@ describe("runCat", () => {
|
|
|
612
627
|
await runCat({
|
|
613
628
|
key: "companies/indigo/scratch/a.txt",
|
|
614
629
|
vaultClient: client,
|
|
615
|
-
|
|
630
|
+
companyClient: companyFactory,
|
|
616
631
|
region: "us-east-1",
|
|
617
632
|
hqRoot: tmpRoot,
|
|
618
633
|
stdout: sink,
|
|
619
634
|
});
|
|
620
|
-
|
|
621
|
-
expect(spies.stsVend.
|
|
635
|
+
// HQ-59: company cat goes through the presign API — no STS vend.
|
|
636
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
637
|
+
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
622
638
|
// GetObject key is company-relative (anchor stripped).
|
|
623
639
|
const getCmd = sendSpy.mock.calls.find(
|
|
624
640
|
(c) => c[0] instanceof GetObjectCommand,
|
|
625
641
|
)?.[0] as GetObjectCommand;
|
|
626
|
-
expect(getCmd.input.Bucket).toBe("hq-vault-cmp-indigo");
|
|
627
642
|
expect(getCmd.input.Key).toBe("scratch/a.txt");
|
|
628
643
|
});
|
|
629
644
|
|
|
630
645
|
it("writes to --out when outside the companies tree and reports byte count", async () => {
|
|
631
646
|
const { client } = makeStubVaultClient({});
|
|
632
647
|
const payload = Buffer.from("safe-payload");
|
|
633
|
-
const {
|
|
648
|
+
const { companyFactory } = makeStubS3Factory({
|
|
634
649
|
getResponse: {
|
|
635
650
|
Body: Readable.from(payload),
|
|
636
651
|
} as GetObjectCommandOutput,
|
|
@@ -640,7 +655,7 @@ describe("runCat", () => {
|
|
|
640
655
|
key: "companies/indigo/scratch/a.txt",
|
|
641
656
|
out: outFile,
|
|
642
657
|
vaultClient: client,
|
|
643
|
-
|
|
658
|
+
companyClient: companyFactory,
|
|
644
659
|
region: "us-east-1",
|
|
645
660
|
hqRoot: tmpRoot,
|
|
646
661
|
});
|
|
@@ -651,7 +666,7 @@ describe("runCat", () => {
|
|
|
651
666
|
|
|
652
667
|
it("refuses --out under <hqRoot>/companies/ BEFORE vending (no leak)", async () => {
|
|
653
668
|
const { client, spies } = makeStubVaultClient({});
|
|
654
|
-
const {
|
|
669
|
+
const { companyFactory, sendSpy } = makeStubS3Factory({
|
|
655
670
|
getResponse: { Body: Readable.from(Buffer.from("nope")) } as GetObjectCommandOutput,
|
|
656
671
|
});
|
|
657
672
|
const badOut = path.join(tmpRoot, "companies", "indigo", "leaked.txt");
|
|
@@ -660,13 +675,13 @@ describe("runCat", () => {
|
|
|
660
675
|
key: "companies/indigo/scratch/a.txt",
|
|
661
676
|
out: badOut,
|
|
662
677
|
vaultClient: client,
|
|
663
|
-
|
|
678
|
+
companyClient: companyFactory,
|
|
664
679
|
region: "us-east-1",
|
|
665
680
|
hqRoot: tmpRoot,
|
|
666
681
|
}),
|
|
667
682
|
).rejects.toThrow(/Refusing to write/);
|
|
668
|
-
// Critically:
|
|
669
|
-
// was made
|
|
683
|
+
// Critically: the guard runs FIRST — no vend, and no list/presign/S3 call
|
|
684
|
+
// was made. Failing closed is the whole point of the guard.
|
|
670
685
|
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
671
686
|
expect(spies.vendSelf).not.toHaveBeenCalled();
|
|
672
687
|
expect(sendSpy).not.toHaveBeenCalled();
|
|
@@ -843,7 +858,7 @@ describe("runSharedWithMe", () => {
|
|
|
843
858
|
describe("runSearch", () => {
|
|
844
859
|
it("filters the company listing by case-insensitive substring on the key", async () => {
|
|
845
860
|
const { client, spies } = makeStubVaultClient({});
|
|
846
|
-
const {
|
|
861
|
+
const { companyFactory } = makeStubS3Factory({
|
|
847
862
|
listResponses: [
|
|
848
863
|
{
|
|
849
864
|
Contents: [
|
|
@@ -858,20 +873,21 @@ describe("runSearch", () => {
|
|
|
858
873
|
query: "roadmap", // lower-case query matches mixed-case key
|
|
859
874
|
companySlug: "indigo",
|
|
860
875
|
vaultClient: client,
|
|
861
|
-
|
|
876
|
+
companyClient: companyFactory,
|
|
862
877
|
region: "us-east-1",
|
|
863
878
|
});
|
|
864
879
|
// Keys are re-anchored for display; only the matching one survives.
|
|
865
880
|
expect(rows.map((r) => r.key)).toEqual([
|
|
866
881
|
"companies/indigo/knowledge/Roadmap.md",
|
|
867
882
|
]);
|
|
868
|
-
//
|
|
869
|
-
|
|
883
|
+
// HQ-59: search lists via the presign/list API (inherited from runBrowse) —
|
|
884
|
+
// no STS vend.
|
|
885
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
870
886
|
});
|
|
871
887
|
|
|
872
888
|
it("returns an empty array when nothing matches", async () => {
|
|
873
889
|
const { client } = makeStubVaultClient({});
|
|
874
|
-
const {
|
|
890
|
+
const { companyFactory } = makeStubS3Factory({
|
|
875
891
|
listResponses: [
|
|
876
892
|
{ Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
|
|
877
893
|
],
|
|
@@ -880,7 +896,7 @@ describe("runSearch", () => {
|
|
|
880
896
|
query: "zzz-no-match",
|
|
881
897
|
companySlug: "indigo",
|
|
882
898
|
vaultClient: client,
|
|
883
|
-
|
|
899
|
+
companyClient: companyFactory,
|
|
884
900
|
region: "us-east-1",
|
|
885
901
|
});
|
|
886
902
|
expect(rows).toEqual([]);
|
|
@@ -922,7 +938,7 @@ describe("pin set", () => {
|
|
|
922
938
|
function makeGetStubS3(opts: {
|
|
923
939
|
listKeys: Array<{ Key: string; Size: number }>;
|
|
924
940
|
bodyFor: (key: string) => Buffer;
|
|
925
|
-
}): {
|
|
941
|
+
}): { companyFactory: CompanyBrowseClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
|
|
926
942
|
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
927
943
|
if (cmd instanceof ListObjectsV2Command) {
|
|
928
944
|
return {
|
|
@@ -937,16 +953,17 @@ function makeGetStubS3(opts: {
|
|
|
937
953
|
}
|
|
938
954
|
throw new Error(`unexpected command: ${cmd}`);
|
|
939
955
|
});
|
|
940
|
-
|
|
956
|
+
// `get` is company-only now, so the stub is the presign/list seam.
|
|
957
|
+
const companyFactory = vi.fn(
|
|
941
958
|
() => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
|
|
942
|
-
) as unknown as
|
|
943
|
-
return {
|
|
959
|
+
) as unknown as CompanyBrowseClientFactory;
|
|
960
|
+
return { companyFactory, sendSpy };
|
|
944
961
|
}
|
|
945
962
|
|
|
946
963
|
describe("runGet", () => {
|
|
947
964
|
it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
|
|
948
965
|
const { client, spies } = makeStubVaultClient({});
|
|
949
|
-
const {
|
|
966
|
+
const { companyFactory, sendSpy } = makeGetStubS3({
|
|
950
967
|
listKeys: [
|
|
951
968
|
{ Key: "knowledge/a.md", Size: 3 },
|
|
952
969
|
{ Key: "knowledge/sub/b.md", Size: 3 },
|
|
@@ -959,12 +976,12 @@ describe("runGet", () => {
|
|
|
959
976
|
hqRoot: tmpRoot,
|
|
960
977
|
companySlug: "indigo",
|
|
961
978
|
vaultClient: client,
|
|
962
|
-
|
|
979
|
+
companyClient: companyFactory,
|
|
963
980
|
region: "us-east-1",
|
|
964
981
|
});
|
|
965
982
|
|
|
966
|
-
//
|
|
967
|
-
expect(spies.stsVend).
|
|
983
|
+
// HQ-59: get materializes through the presign/list API — no STS vend.
|
|
984
|
+
expect(spies.stsVend).not.toHaveBeenCalled();
|
|
968
985
|
expect(result.filesWritten).toBe(2);
|
|
969
986
|
|
|
970
987
|
// Files landed in place under companies/<slug>/.
|
|
@@ -995,7 +1012,7 @@ describe("runGet", () => {
|
|
|
995
1012
|
it("--into writes outside companies/ and registers NO pin", async () => {
|
|
996
1013
|
const into = path.join(tmpRoot, "extract");
|
|
997
1014
|
const { client } = makeStubVaultClient({});
|
|
998
|
-
const {
|
|
1015
|
+
const { companyFactory } = makeGetStubS3({
|
|
999
1016
|
listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
|
|
1000
1017
|
bodyFor: () => Buffer.from("AAA"),
|
|
1001
1018
|
});
|
|
@@ -1006,7 +1023,7 @@ describe("runGet", () => {
|
|
|
1006
1023
|
hqRoot: tmpRoot,
|
|
1007
1024
|
companySlug: "indigo",
|
|
1008
1025
|
vaultClient: client,
|
|
1009
|
-
|
|
1026
|
+
companyClient: companyFactory,
|
|
1010
1027
|
region: "us-east-1",
|
|
1011
1028
|
});
|
|
1012
1029
|
|
|
@@ -1019,7 +1036,7 @@ describe("runGet", () => {
|
|
|
1019
1036
|
|
|
1020
1037
|
it("throws when no objects exist under the path", async () => {
|
|
1021
1038
|
const { client } = makeStubVaultClient({});
|
|
1022
|
-
const {
|
|
1039
|
+
const { companyFactory } = makeGetStubS3({
|
|
1023
1040
|
listKeys: [],
|
|
1024
1041
|
bodyFor: () => Buffer.from(""),
|
|
1025
1042
|
});
|
|
@@ -1029,7 +1046,7 @@ describe("runGet", () => {
|
|
|
1029
1046
|
hqRoot: tmpRoot,
|
|
1030
1047
|
companySlug: "indigo",
|
|
1031
1048
|
vaultClient: client,
|
|
1032
|
-
|
|
1049
|
+
companyClient: companyFactory,
|
|
1033
1050
|
region: "us-east-1",
|
|
1034
1051
|
}),
|
|
1035
1052
|
).rejects.toThrow(/No objects under/);
|
|
@@ -1083,3 +1100,150 @@ describe("--company parent binding (optsWithGlobals)", () => {
|
|
|
1083
1100
|
expect(local).toBeUndefined();
|
|
1084
1101
|
});
|
|
1085
1102
|
});
|
|
1103
|
+
|
|
1104
|
+
// ── createCompanyPresignClient (HQ-59 list/presign translation) ─────────────
|
|
1105
|
+
//
|
|
1106
|
+
// The company-mode browse client. Verifies it translates the S3-shaped
|
|
1107
|
+
// commands the orchestrators issue into the server-side list/presign API:
|
|
1108
|
+
// - ListObjectsV2Command → GET /v1/files/list (company-relative objects)
|
|
1109
|
+
// - GetObjectCommand → POST /v1/files/presign → fetch(url) → Body
|
|
1110
|
+
// fetch is the single network seam (vaultApiFetch uses it; the presigned
|
|
1111
|
+
// download uses it), so we spy on globalThis.fetch and route by URL.
|
|
1112
|
+
|
|
1113
|
+
describe("createCompanyPresignClient", () => {
|
|
1114
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
1115
|
+
|
|
1116
|
+
beforeEach(() => {
|
|
1117
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
1118
|
+
});
|
|
1119
|
+
afterEach(() => {
|
|
1120
|
+
fetchSpy.mockRestore();
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
function json(status: number, body: unknown): Response {
|
|
1124
|
+
return new Response(JSON.stringify(body), {
|
|
1125
|
+
status,
|
|
1126
|
+
headers: { "content-type": "application/json" },
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
it("ListObjectsV2 → GET /v1/files/list, mapping objects + cursor onto the S3 shape", async () => {
|
|
1131
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1132
|
+
json(200, {
|
|
1133
|
+
prefix: "scratch/",
|
|
1134
|
+
objects: [
|
|
1135
|
+
{
|
|
1136
|
+
key: "scratch/a.txt",
|
|
1137
|
+
size: 12,
|
|
1138
|
+
lastModified: "2026-01-01T00:00:00.000Z",
|
|
1139
|
+
etag: "abc123",
|
|
1140
|
+
permission: "read",
|
|
1141
|
+
},
|
|
1142
|
+
],
|
|
1143
|
+
cursor: "next-page",
|
|
1144
|
+
truncated: true,
|
|
1145
|
+
}),
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1149
|
+
const out = await client.send(
|
|
1150
|
+
new ListObjectsV2Command({
|
|
1151
|
+
Bucket: "ignored",
|
|
1152
|
+
Prefix: "scratch/",
|
|
1153
|
+
ContinuationToken: undefined,
|
|
1154
|
+
}),
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
// Request: GET to /v1/files/list with company + prefix query, bearer token.
|
|
1158
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
1159
|
+
expect(String(url)).toContain("/v1/files/list");
|
|
1160
|
+
expect(String(url)).toContain("company=cmp_indigo");
|
|
1161
|
+
expect(String(url)).toContain("prefix=scratch%2F");
|
|
1162
|
+
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer tok");
|
|
1163
|
+
|
|
1164
|
+
// Response mapped onto the ListObjectsV2 shape the orchestrators read.
|
|
1165
|
+
expect(out.Contents).toHaveLength(1);
|
|
1166
|
+
expect(out.Contents?.[0].Key).toBe("scratch/a.txt");
|
|
1167
|
+
expect(out.Contents?.[0].Size).toBe(12);
|
|
1168
|
+
expect(out.Contents?.[0].ETag).toBe('"abc123"');
|
|
1169
|
+
expect(out.Contents?.[0].LastModified?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
|
1170
|
+
expect(out.NextContinuationToken).toBe("next-page");
|
|
1171
|
+
expect(out.IsTruncated).toBe(true);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("ListObjectsV2 forwards the ContinuationToken as the cursor query param", async () => {
|
|
1175
|
+
fetchSpy.mockResolvedValueOnce(json(200, { objects: [], cursor: null, truncated: false }));
|
|
1176
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1177
|
+
await client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/", ContinuationToken: "page2" }));
|
|
1178
|
+
expect(String(fetchSpy.mock.calls[0][0])).toContain("cursor=page2");
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("ListObjectsV2 throws on a non-2xx list response", async () => {
|
|
1182
|
+
fetchSpy.mockResolvedValueOnce(json(403, { error: "Forbidden", code: "X" }));
|
|
1183
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1184
|
+
await expect(
|
|
1185
|
+
client.send(new ListObjectsV2Command({ Bucket: "b", Prefix: "k/" })),
|
|
1186
|
+
).rejects.toThrow(/Forbidden/);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("GetObject → POST /v1/files/presign then fetches the URL and returns a Body", async () => {
|
|
1190
|
+
// 1) presign POST → returns a URL; 2) the URL fetch → returns the bytes.
|
|
1191
|
+
fetchSpy
|
|
1192
|
+
.mockResolvedValueOnce(
|
|
1193
|
+
json(200, { results: [{ key: "scratch/a.txt", op: "get", url: "https://s3.example/presigned" }] }),
|
|
1194
|
+
)
|
|
1195
|
+
.mockResolvedValueOnce(new Response("file-bytes-here", { status: 200 }));
|
|
1196
|
+
|
|
1197
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1198
|
+
const out = await client.send(
|
|
1199
|
+
new GetObjectCommand({ Bucket: "ignored", Key: "scratch/a.txt" }),
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
// First call: POST presign with the right body.
|
|
1203
|
+
const [presignUrl, presignInit] = fetchSpy.mock.calls[0];
|
|
1204
|
+
expect(String(presignUrl)).toContain("/v1/files/presign");
|
|
1205
|
+
expect(presignInit?.method).toBe("POST");
|
|
1206
|
+
expect(JSON.parse(presignInit?.body as string)).toEqual({
|
|
1207
|
+
company: "cmp_indigo",
|
|
1208
|
+
key: "scratch/a.txt",
|
|
1209
|
+
op: "get",
|
|
1210
|
+
});
|
|
1211
|
+
// Second call: fetch the presigned URL.
|
|
1212
|
+
expect(String(fetchSpy.mock.calls[1][0])).toBe("https://s3.example/presigned");
|
|
1213
|
+
|
|
1214
|
+
// Body is a readable stream of the downloaded bytes.
|
|
1215
|
+
const chunks: Buffer[] = [];
|
|
1216
|
+
for await (const c of out.Body as unknown as AsyncIterable<Buffer>) {
|
|
1217
|
+
chunks.push(Buffer.from(c));
|
|
1218
|
+
}
|
|
1219
|
+
expect(Buffer.concat(chunks).toString("utf-8")).toBe("file-bytes-here");
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("GetObject throws the per-key error when presign denies the key (no URL)", async () => {
|
|
1223
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1224
|
+
json(200, {
|
|
1225
|
+
results: [
|
|
1226
|
+
{ key: "secret/x", op: "get", error: "Caller lacks read permission on 'secret/x'", code: "FILES_PRESIGN_FORBIDDEN" },
|
|
1227
|
+
],
|
|
1228
|
+
}),
|
|
1229
|
+
);
|
|
1230
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1231
|
+
await expect(
|
|
1232
|
+
client.send(new GetObjectCommand({ Bucket: "b", Key: "secret/x" })),
|
|
1233
|
+
).rejects.toThrow(/lacks read permission/);
|
|
1234
|
+
// The denial short-circuits BEFORE any download fetch.
|
|
1235
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it("GetObject throws when the presigned download itself fails", async () => {
|
|
1239
|
+
fetchSpy
|
|
1240
|
+
.mockResolvedValueOnce(
|
|
1241
|
+
json(200, { results: [{ key: "k", op: "get", url: "https://s3.example/x" }] }),
|
|
1242
|
+
)
|
|
1243
|
+
.mockResolvedValueOnce(new Response("nope", { status: 500 }));
|
|
1244
|
+
const client = createCompanyPresignClient({ token: "tok", companyUid: "cmp_indigo" });
|
|
1245
|
+
await expect(
|
|
1246
|
+
client.send(new GetObjectCommand({ Bucket: "b", Key: "k" })),
|
|
1247
|
+
).rejects.toThrow(/Failed to download 'k'/);
|
|
1248
|
+
});
|
|
1249
|
+
});
|