1
8
9import { describe, expect, it } from "vitest";
10import { createHash, createHmac } from "node:crypto";
11
12import {
13 S3Client,
14 parseListBucketXml,
15 parseUploadIdXml,
16 buildCompleteMultipartXml,
17} from "./client.js";
18import { signS3Request, sha256Hex, amzDate, uriEncode } from "./presign.js";
19import type { S3Config, FetchLike, FetchResponse, FetchInit } from "./types.js";
20
21const NOW = new Date("2025-02-01T12:00:00.000Z");
22
23const CONFIG: S3Config = {
24 endpoint: "https://minio.local:9000",
25 region: "us-east-1",
26 bucket: "factory",
27 accessKeyId: "AKID",
28 secretAccessKey: "SECRET",
29 forcePathStyle: true,
30};
31
32function makeResponse(
33 status: number,
34 body: Uint8Array | string | null = null,
35 headers: Record<string, string> = {},
36): FetchResponse {
37 const bytes =
38 body === null || body === undefined
39 ? null
40 : typeof body === "string"
41 ? new TextEncoder().encode(body)
42 : body;
43 return {
44 ok: status >= 200 && status < 300,
45 status,
46 headers,
47 body: bytes,
48 async json() {
49 return JSON.parse(new TextDecoder().decode(bytes ?? new Uint8Array(0)));
50 },
51 async text() {
52 return bytes === null ? "" : new TextDecoder().decode(bytes);
53 },
54 };
55}
56
57
58function fakeFetch(script: (() => FetchResponse)[]): {
59 fetchLike: FetchLike;
60 calls: { url: string; init: FetchInit }[];
61} {
62 const calls: { url: string; init: FetchInit }[] = [];
63 const fetchLike: FetchLike = (url, init) => {
64 calls.push({ url, init: init ?? {} });
65 const next = script.shift();
66 if (!next) throw new Error("fakeFetch: no more scripted responses");
67 return Promise.resolve(next());
68 };
69 return { fetchLike, calls };
70}
71
72const LIST_FIXTURE = `<?xml version="1.0" encoding="UTF-8"?>
73<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
74 <Name>factory</Name>
75 <Prefix>workspaces/wsp_1/</Prefix>
76 <KeyCount>3</KeyCount>
77 <MaxKeys>1000</MaxKeys>
78 <IsTruncated>true</IsTruncated>
79 <Contents>
80 <Key>workspaces/wsp_1/files/file_1/v1/a.png</Key>
81 <LastModified>2025-01-01T00:00:00.000Z</LastModified>
82 <ETag>"abc123"</ETag>
83 <Size>1234</Size>
84 <StorageClass>STANDARD</StorageClass>
85 </Contents>
86 <Contents>
87 <Key>workspaces/wsp_1/files/file_1/v1/a.png__thumb_sm</Key>
88 <LastModified>2025-01-01T00:00:01.000Z</LastModified>
89 <ETag>"def456"</ETag>
90 <Size>67</Size>
91 <StorageClass>STANDARD</StorageClass>
92 </Contents>
93 <Contents>
94 <Key>workspaces/wsp_1/files/file_2/v1/b&c.pdf</Key>
95 <LastModified>2025-01-01T00:00:02.000Z</LastModified>
96 <ETag>"789"</ETag>
97 <Size>999</Size>
98 <StorageClass>STANDARD</StorageClass>
99 </Contents>
100 <NextContinuationToken>tok_123</NextContinuationToken>
101</ListBucketResult>`;
102
103describe("S3Client.putObject", () => {
104 it("sends a SigV4-signed PUT with the exact payload sha256", async () => {
105 const body = new TextEncoder().encode("hello object");
106 const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]);
107 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
108 await client.putObject("workspaces/wsp_1/files/f1/v1/a.png", body, "image/png");
109
110 expect(calls).toHaveLength(1);
111 const { url, init } = calls[0]!;
112 expect(url).toBe("https://minio.local:9000/factory/workspaces/wsp_1/files/f1/v1/a.png");
113 expect(init.method).toBe("PUT");
114 expect(new Uint8Array(init.body as Uint8Array)).toEqual(body);
115
116 const h: Record<string, string> = {};
117 for (const [k, v] of Object.entries(init.headers ?? {})) h[k.toLowerCase()] = v;
118
119 expect(h["x-amz-content-sha256"]).toBe(sha256Hex(body));
120
121
122 const ref = signS3Request({
123 method: "PUT",
124 path: "/factory/workspaces/wsp_1/files/f1/v1/a.png",
125 headers: { "content-type": "image/png", "x-amz-date": amzDate(NOW) },
126 payloadHash: sha256Hex(body),
127 config: CONFIG,
128 now: NOW,
129 alwaysSignContentSha: true,
130 });
131 expect(h["authorization"]).toBe(ref.authorization);
132 });
133
134 it("accepts string bodies (UTF-8 encoded)", async () => {
135 const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]);
136 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
137 await client.putObject("k.txt", "héllo", "text/plain");
138 const sent = new TextDecoder().decode(new Uint8Array(calls[0]!.init.body as Uint8Array));
139 expect(sent).toBe("héllo");
140 });
141
142 it("signs keys with spaces ONCE (no %2520 double-encoding) — SigV4 regression", async () => {
143
144
145
146
147 const body = new TextEncoder().encode("sheet");
148 const key = "files/wsp_1/file_1/1/ARL Budget 208384.xlsx";
149 const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]);
150 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
151 await client.putObject(key, body, "application/octet-stream");
152
153 expect(calls).toHaveLength(1);
154 const { url, init } = calls[0]!;
155 expect(url).toBe(`https://minio.local:9000/factory/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx`);
156 expect(url).not.toContain("%25");
157
158
159
160 const h: Record<string, string> = {};
161 for (const [k, v] of Object.entries(init.headers ?? {})) h[k.toLowerCase()] = v;
162 const signed = signS3Request({
163 method: "PUT",
164 path: `/factory/${key}`,
165 headers: { "content-type": "application/octet-stream", "x-amz-date": amzDate(NOW) },
166 payloadHash: sha256Hex(body),
167 config: CONFIG,
168 now: NOW,
169 alwaysSignContentSha: true,
170 });
171 expect(h["authorization"]).toBe(signed.authorization);
172 const wirePath = new URL(url).pathname;
173 const awsCanonical = wirePath
174 .split("/")
175 .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false)))
176 .join("/");
177 expect(signed.canonicalRequest).toContain(awsCanonical);
178 expect(awsCanonical).toContain("ARL%20Budget%20208384.xlsx");
179 });
180});
181
182describe("S3Client.getObject", () => {
183 it("returns body + content-type + size on 200", async () => {
184 const payload = new Uint8Array([1, 2, 3, 4, 5]);
185 const { fetchLike } = fakeFetch([
186 () => makeResponse(200, payload, { "content-type": "image/png", "content-length": "5" }),
187 ]);
188 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
189 const got = await client.getObject("some/key.png");
190 expect(Array.from(got.body)).toEqual([1, 2, 3, 4, 5]);
191 expect(got.contentType).toBe("image/png");
192 expect(got.size).toBe(5);
193 });
194
195 it("404 → non-retryable StorageError with status 404", async () => {
196 const { fetchLike } = fakeFetch([
197 () => makeResponse(404, new TextEncoder().encode("<Error><Code>NoSuchKey</Code></Error>")),
198 ]);
199 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
200 await expect(client.getObject("missing.png")).rejects.toMatchObject({
201 name: "StorageError",
202 code: "STORAGE_ERROR",
203 status: 404,
204 retryable: false,
205 });
206 });
207
208 it("500 → retryable StorageError", async () => {
209 const { fetchLike } = fakeFetch([() => makeResponse(500, "oops")]);
210 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
211 await expect(client.getObject("x.png")).rejects.toMatchObject({
212 code: "STORAGE_ERROR",
213 status: 500,
214 retryable: true,
215 });
216 });
217
218 it("network failure → STORAGE_TIMEOUT, retryable", async () => {
219 const fetchLike: FetchLike = () => Promise.reject(new Error("ECONNRESET"));
220 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
221 await expect(client.getObject("x.png")).rejects.toMatchObject({
222 code: "STORAGE_TIMEOUT",
223 retryable: true,
224 });
225 });
226});
227
228describe("S3Client.headObject / deleteObject", () => {
229 it("headObject parses metadata headers", async () => {
230 const { fetchLike, calls } = fakeFetch([
231 () =>
232 makeResponse(200, "", {
233 "content-length": "42",
234 "content-type": "application/pdf",
235 etag: '"abc"',
236 "last-modified": "Tue, 01 Jan 2025 00:00:00 GMT",
237 }),
238 ]);
239 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
240 const meta = await client.headObject("d.pdf");
241 expect(calls[0]!.init.method).toBe("HEAD");
242 expect(meta).toEqual({
243 size: 42,
244 contentType: "application/pdf",
245 etag: '"abc"',
246 lastModified: "Tue, 01 Jan 2025 00:00:00 GMT",
247 });
248 });
249
250 it("deleteObject sends DELETE (204 ok)", async () => {
251 const { fetchLike, calls } = fakeFetch([() => makeResponse(204, "")]);
252 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
253 await client.deleteObject("gone.png");
254 expect(calls[0]!.init.method).toBe("DELETE");
255 });
256});
257
258describe("S3Client.readPrefix", () => {
259 it("range-GETs the first N bytes and parses the total from Content-Range", async () => {
260 const prefix = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]);
261 const { fetchLike, calls } = fakeFetch([
262 () =>
263 makeResponse(206, prefix, {
264 "content-range": "bytes 0-7/482113",
265 "content-type": "image/png",
266 }),
267 ]);
268 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
269 const got = await client.readPrefix("files/w1/f1/1/a.png", 8);
270 expect(calls[0]!.init.method).toBe("GET");
271 expect((calls[0]!.init.headers as Record<string, string>)["range"]).toBe("bytes=0-7");
272 expect(Array.from(got.prefix)).toEqual([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]);
273 expect(got.totalSize).toBe(482113);
274 });
275
276 it("tolerates an unknown total (Content-Range …/*) and falls back to the body length", async () => {
277 const prefix = new Uint8Array([1, 2, 3]);
278 const { fetchLike } = fakeFetch([
279 () => makeResponse(206, prefix, { "content-range": "bytes 0-2/*" }),
280 ]);
281 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
282 const got = await client.readPrefix("k.bin", 3);
283 expect(got.totalSize).toBe(3);
284 });
285});
286
287describe("S3Client.listObjects + parseListBucketXml", () => {
288 it("sends list-type=2 + prefix query and parses the XML fixture", async () => {
289 const { fetchLike, calls } = fakeFetch([() => makeResponse(200, LIST_FIXTURE)]);
290 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
291 const res = await client.listObjects("workspaces/wsp_1/", 1000);
292 expect(calls[0]!.url).toContain("list-type=2");
293 expect(calls[0]!.url).toContain("prefix=workspaces%2Fwsp_1%2F");
294 expect(calls[0]!.url).toContain("max-keys=1000");
295 expect(res.keys).toEqual([
296 "workspaces/wsp_1/files/file_1/v1/a.png",
297 "workspaces/wsp_1/files/file_1/v1/a.png__thumb_sm",
298 "workspaces/wsp_1/files/file_2/v1/b&c.pdf",
299 ]);
300 expect(res.truncated).toBe(true);
301 expect(res.nextToken).toBe("tok_123");
302 });
303
304 it("parseListBucketXml handles a non-truncated, empty listing", () => {
305 const xml = `<ListBucketResult><Name>b</Name><Prefix></Prefix><KeyCount>0</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated></ListBucketResult>`;
306 const res = parseListBucketXml(xml);
307 expect(res.keys).toEqual([]);
308 expect(res.truncated).toBe(false);
309 expect(res.nextToken).toBeUndefined();
310 });
311
312 it("parseListBucketXml falls back to NextMarker", () => {
313 const res = parseListBucketXml(
314 `<ListBucketResult><Contents><Key>a/b</Key></Contents><IsTruncated>true</IsTruncated><NextMarker>mm</NextMarker></ListBucketResult>`,
315 );
316 expect(res.nextToken).toBe("mm");
317 });
318});
319
320describe("multipart upload calls", () => {
321 it("createMultipartUpload → POST ?uploads= → UploadId", async () => {
322 const { fetchLike, calls } = fakeFetch([
323 () =>
324 makeResponse(
325 200,
326 `<?xml version="1.0"?><InitiateMultipartUploadResult><Bucket>b</Bucket><Key>k</Key><UploadId>UP123</UploadId></InitiateMultipartUploadResult>`,
327 ),
328 ]);
329 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
330 const { uploadId } = await client.createMultipartUpload("big/video.mp4");
331 expect(calls[0]!.url).toContain("uploads=");
332 expect(calls[0]!.init.method).toBe("POST");
333 expect(uploadId).toBe("UP123");
334 });
335
336 it("uploadPart → PUT ?partNumber&uploadId, returns ETag", async () => {
337 const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "", { etag: '"etag-1"' })]);
338 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
339 const part = await client.uploadPart("big/video.mp4", "UP123", 1, new Uint8Array(1024));
340 expect(calls[0]!.url).toContain("partNumber=1");
341 expect(calls[0]!.url).toContain("uploadId=UP123");
342 expect(part).toEqual({ partNumber: 1, etag: "etag-1" });
343 });
344
345 it("completeMultipartUpload → signed POST with sorted parts XML", async () => {
346 const { fetchLike, calls } = fakeFetch([
347 () => makeResponse(200, "<CompleteMultipartUploadResult/>"),
348 ]);
349 const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW });
350 await client.completeMultipartUpload("big/video.mp4", "UP123", [
351 { partNumber: 2, etag: "e2" },
352 { partNumber: 1, etag: "e1" },
353 ]);
354 const body = new TextDecoder().decode(new Uint8Array(calls[0]!.init.body as Uint8Array));
355 expect(body).toContain("<PartNumber>1</PartNumber>");
356 expect(body.indexOf("<PartNumber>1</PartNumber>")).toBeLessThan(
357 body.indexOf("<PartNumber>2</PartNumber>"),
358 );
359
360 const auth = calls[0]!.init.headers?.["Authorization"] ?? "";
361 expect(auth).toContain("SignedHeaders=");
362 expect(auth.toLowerCase()).toContain("content-type");
363 });
364
365 it("parseUploadIdXml / buildCompleteMultipartXml are pure", () => {
366 expect(parseUploadIdXml(`<x><UploadId>AB</UploadId></x>`)).toBe("AB");
367 const xml = buildCompleteMultipartXml([{ partNumber: 1, etag: "e" }]);
368 expect(xml).toContain("<CompleteMultipartUpload>");
369 expect(xml).toContain(`<ETag>"e"</ETag>`);
370 });
371});
372
373describe("onRequest observability", () => {
374 it("fires before and after each request with status + duration", async () => {
375 const { fetchLike } = fakeFetch([() => makeResponse(200, "x", { "content-length": "1" })]);
376 const events: object[] = [];
377 const client = new S3Client({
378 config: CONFIG,
379 fetchLike,
380 now: () => NOW,
381 onRequest: (info) => events.push(info),
382 });
383 await client.getObject("a.png");
384 expect(events).toHaveLength(2);
385 expect(events[0]).toMatchObject({ method: "GET" });
386 expect(events[1]).toMatchObject({ status: 200, ok: true });
387 expect(typeof (events[1] as { durationMs?: number }).durationMs).toBe("number");
388 });
389
390 it("independent hmac sanity (guards the test's own reference)", () => {
391 const h = createHmac("sha256", "k").update("m").digest("hex");
392 expect(createHash("sha256").update("m").digest("hex")).toBe(sha256Hex("m"));
393 expect(h.length).toBe(64);
394 });
395});
396