/** * @factory/storage — client.test.ts (S3 client over a fake fetchLike). * * Signed-header verification (Authorization + x-amz-content-sha256 match an * independent signS3Request), put/get/404/500 semantics, XML list parsing * from a fixture, multipart call shapes, onRequest observability. */ import { describe, expect, it } from "vitest"; import { createHash, createHmac } from "node:crypto"; import { S3Client, parseListBucketXml, parseUploadIdXml, buildCompleteMultipartXml, } from "./client.js"; import { signS3Request, sha256Hex, amzDate, uriEncode } from "./presign.js"; import type { S3Config, FetchLike, FetchResponse, FetchInit } from "./types.js"; const NOW = new Date("2025-02-01T12:00:00.000Z"); const CONFIG: S3Config = { endpoint: "https://minio.local:9000", region: "us-east-1", bucket: "factory", accessKeyId: "AKID", secretAccessKey: "SECRET", forcePathStyle: true, }; function makeResponse( status: number, body: Uint8Array | string | null = null, headers: Record = {}, ): FetchResponse { const bytes = body === null || body === undefined ? null : typeof body === "string" ? new TextEncoder().encode(body) : body; return { ok: status >= 200 && status < 300, status, headers, body: bytes, async json() { return JSON.parse(new TextDecoder().decode(bytes ?? new Uint8Array(0))); }, async text() { return bytes === null ? "" : new TextDecoder().decode(bytes); }, }; } /** Records every call, replays scripted responses in order. */ function fakeFetch(script: (() => FetchResponse)[]): { fetchLike: FetchLike; calls: { url: string; init: FetchInit }[]; } { const calls: { url: string; init: FetchInit }[] = []; const fetchLike: FetchLike = (url, init) => { calls.push({ url, init: init ?? {} }); const next = script.shift(); if (!next) throw new Error("fakeFetch: no more scripted responses"); return Promise.resolve(next()); }; return { fetchLike, calls }; } const LIST_FIXTURE = ` factory workspaces/wsp_1/ 3 1000 true workspaces/wsp_1/files/file_1/v1/a.png 2025-01-01T00:00:00.000Z "abc123" 1234 STANDARD workspaces/wsp_1/files/file_1/v1/a.png__thumb_sm 2025-01-01T00:00:01.000Z "def456" 67 STANDARD workspaces/wsp_1/files/file_2/v1/b&c.pdf 2025-01-01T00:00:02.000Z "789" 999 STANDARD tok_123 `; describe("S3Client.putObject", () => { it("sends a SigV4-signed PUT with the exact payload sha256", async () => { const body = new TextEncoder().encode("hello object"); const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await client.putObject("workspaces/wsp_1/files/f1/v1/a.png", body, "image/png"); expect(calls).toHaveLength(1); const { url, init } = calls[0]!; expect(url).toBe("https://minio.local:9000/factory/workspaces/wsp_1/files/f1/v1/a.png"); expect(init.method).toBe("PUT"); expect(new Uint8Array(init.body as Uint8Array)).toEqual(body); const h: Record = {}; for (const [k, v] of Object.entries(init.headers ?? {})) h[k.toLowerCase()] = v; // Payload hash must equal sha256 of the body. expect(h["x-amz-content-sha256"]).toBe(sha256Hex(body)); // Authorization must match an independent signing of the same request // (the client forces the x-amz-content-sha256 signed header). const ref = signS3Request({ method: "PUT", path: "/factory/workspaces/wsp_1/files/f1/v1/a.png", headers: { "content-type": "image/png", "x-amz-date": amzDate(NOW) }, payloadHash: sha256Hex(body), config: CONFIG, now: NOW, alwaysSignContentSha: true, }); expect(h["authorization"]).toBe(ref.authorization); }); it("accepts string bodies (UTF-8 encoded)", async () => { const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await client.putObject("k.txt", "héllo", "text/plain"); const sent = new TextDecoder().decode(new Uint8Array(calls[0]!.init.body as Uint8Array)); expect(sent).toBe("héllo"); }); it("signs keys with spaces ONCE (no %2520 double-encoding) — SigV4 regression", async () => { // The `ARL Budget 208384.xlsx` 403: a pre-encoded key path was re-encoded // by the signer, so the signature covered `%2520` while the wire carried // `%20`. Now the client signs the RAW path and the wire URL is that same // path encoded exactly once — what S3/MinIO re-canonicalize to. const body = new TextEncoder().encode("sheet"); const key = "files/wsp_1/file_1/1/ARL Budget 208384.xlsx"; const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "")]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await client.putObject(key, body, "application/octet-stream"); expect(calls).toHaveLength(1); const { url, init } = calls[0]!; expect(url).toBe(`https://minio.local:9000/factory/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx`); expect(url).not.toContain("%25"); // The signed canonical URI must equal the wire path re-canonicalized the // AWS way (decode each segment, re-encode once). const h: Record = {}; for (const [k, v] of Object.entries(init.headers ?? {})) h[k.toLowerCase()] = v; const signed = signS3Request({ method: "PUT", path: `/factory/${key}`, // raw headers: { "content-type": "application/octet-stream", "x-amz-date": amzDate(NOW) }, payloadHash: sha256Hex(body), config: CONFIG, now: NOW, alwaysSignContentSha: true, }); expect(h["authorization"]).toBe(signed.authorization); const wirePath = new URL(url).pathname; const awsCanonical = wirePath .split("/") .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false))) .join("/"); expect(signed.canonicalRequest).toContain(awsCanonical); expect(awsCanonical).toContain("ARL%20Budget%20208384.xlsx"); }); }); describe("S3Client.getObject", () => { it("returns body + content-type + size on 200", async () => { const payload = new Uint8Array([1, 2, 3, 4, 5]); const { fetchLike } = fakeFetch([ () => makeResponse(200, payload, { "content-type": "image/png", "content-length": "5" }), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const got = await client.getObject("some/key.png"); expect(Array.from(got.body)).toEqual([1, 2, 3, 4, 5]); expect(got.contentType).toBe("image/png"); expect(got.size).toBe(5); }); it("404 → non-retryable StorageError with status 404", async () => { const { fetchLike } = fakeFetch([ () => makeResponse(404, new TextEncoder().encode("NoSuchKey")), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await expect(client.getObject("missing.png")).rejects.toMatchObject({ name: "StorageError", code: "STORAGE_ERROR", status: 404, retryable: false, }); }); it("500 → retryable StorageError", async () => { const { fetchLike } = fakeFetch([() => makeResponse(500, "oops")]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await expect(client.getObject("x.png")).rejects.toMatchObject({ code: "STORAGE_ERROR", status: 500, retryable: true, }); }); it("network failure → STORAGE_TIMEOUT, retryable", async () => { const fetchLike: FetchLike = () => Promise.reject(new Error("ECONNRESET")); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await expect(client.getObject("x.png")).rejects.toMatchObject({ code: "STORAGE_TIMEOUT", retryable: true, }); }); }); describe("S3Client.headObject / deleteObject", () => { it("headObject parses metadata headers", async () => { const { fetchLike, calls } = fakeFetch([ () => makeResponse(200, "", { "content-length": "42", "content-type": "application/pdf", etag: '"abc"', "last-modified": "Tue, 01 Jan 2025 00:00:00 GMT", }), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const meta = await client.headObject("d.pdf"); expect(calls[0]!.init.method).toBe("HEAD"); expect(meta).toEqual({ size: 42, contentType: "application/pdf", etag: '"abc"', lastModified: "Tue, 01 Jan 2025 00:00:00 GMT", }); }); it("deleteObject sends DELETE (204 ok)", async () => { const { fetchLike, calls } = fakeFetch([() => makeResponse(204, "")]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await client.deleteObject("gone.png"); expect(calls[0]!.init.method).toBe("DELETE"); }); }); describe("S3Client.readPrefix", () => { it("range-GETs the first N bytes and parses the total from Content-Range", async () => { const prefix = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]); // 8 KiB-ish sniff const { fetchLike, calls } = fakeFetch([ () => makeResponse(206, prefix, { "content-range": "bytes 0-7/482113", "content-type": "image/png", }), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const got = await client.readPrefix("files/w1/f1/1/a.png", 8); expect(calls[0]!.init.method).toBe("GET"); expect((calls[0]!.init.headers as Record)["range"]).toBe("bytes=0-7"); expect(Array.from(got.prefix)).toEqual([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]); expect(got.totalSize).toBe(482113); }); it("tolerates an unknown total (Content-Range …/*) and falls back to the body length", async () => { const prefix = new Uint8Array([1, 2, 3]); const { fetchLike } = fakeFetch([ () => makeResponse(206, prefix, { "content-range": "bytes 0-2/*" }), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const got = await client.readPrefix("k.bin", 3); expect(got.totalSize).toBe(3); }); }); describe("S3Client.listObjects + parseListBucketXml", () => { it("sends list-type=2 + prefix query and parses the XML fixture", async () => { const { fetchLike, calls } = fakeFetch([() => makeResponse(200, LIST_FIXTURE)]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const res = await client.listObjects("workspaces/wsp_1/", 1000); expect(calls[0]!.url).toContain("list-type=2"); expect(calls[0]!.url).toContain("prefix=workspaces%2Fwsp_1%2F"); expect(calls[0]!.url).toContain("max-keys=1000"); expect(res.keys).toEqual([ "workspaces/wsp_1/files/file_1/v1/a.png", "workspaces/wsp_1/files/file_1/v1/a.png__thumb_sm", "workspaces/wsp_1/files/file_2/v1/b&c.pdf", // entity-decoded ]); expect(res.truncated).toBe(true); expect(res.nextToken).toBe("tok_123"); }); it("parseListBucketXml handles a non-truncated, empty listing", () => { const xml = `b01000false`; const res = parseListBucketXml(xml); expect(res.keys).toEqual([]); expect(res.truncated).toBe(false); expect(res.nextToken).toBeUndefined(); }); it("parseListBucketXml falls back to NextMarker", () => { const res = parseListBucketXml( `a/btruemm`, ); expect(res.nextToken).toBe("mm"); }); }); describe("multipart upload calls", () => { it("createMultipartUpload → POST ?uploads= → UploadId", async () => { const { fetchLike, calls } = fakeFetch([ () => makeResponse( 200, `bkUP123`, ), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const { uploadId } = await client.createMultipartUpload("big/video.mp4"); expect(calls[0]!.url).toContain("uploads="); expect(calls[0]!.init.method).toBe("POST"); expect(uploadId).toBe("UP123"); }); it("uploadPart → PUT ?partNumber&uploadId, returns ETag", async () => { const { fetchLike, calls } = fakeFetch([() => makeResponse(200, "", { etag: '"etag-1"' })]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); const part = await client.uploadPart("big/video.mp4", "UP123", 1, new Uint8Array(1024)); expect(calls[0]!.url).toContain("partNumber=1"); expect(calls[0]!.url).toContain("uploadId=UP123"); expect(part).toEqual({ partNumber: 1, etag: "etag-1" }); }); it("completeMultipartUpload → signed POST with sorted parts XML", async () => { const { fetchLike, calls } = fakeFetch([ () => makeResponse(200, ""), ]); const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW }); await client.completeMultipartUpload("big/video.mp4", "UP123", [ { partNumber: 2, etag: "e2" }, { partNumber: 1, etag: "e1" }, ]); const body = new TextDecoder().decode(new Uint8Array(calls[0]!.init.body as Uint8Array)); expect(body).toContain("1"); expect(body.indexOf("1")).toBeLessThan( body.indexOf("2"), ); // The request was signed with content-type application/xml. const auth = calls[0]!.init.headers?.["Authorization"] ?? ""; expect(auth).toContain("SignedHeaders="); expect(auth.toLowerCase()).toContain("content-type"); }); it("parseUploadIdXml / buildCompleteMultipartXml are pure", () => { expect(parseUploadIdXml(`AB`)).toBe("AB"); const xml = buildCompleteMultipartXml([{ partNumber: 1, etag: "e" }]); expect(xml).toContain(""); expect(xml).toContain(`"e"`); }); }); describe("onRequest observability", () => { it("fires before and after each request with status + duration", async () => { const { fetchLike } = fakeFetch([() => makeResponse(200, "x", { "content-length": "1" })]); const events: object[] = []; const client = new S3Client({ config: CONFIG, fetchLike, now: () => NOW, onRequest: (info) => events.push(info), }); await client.getObject("a.png"); expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ method: "GET" }); expect(events[1]).toMatchObject({ status: 200, ok: true }); expect(typeof (events[1] as { durationMs?: number }).durationMs).toBe("number"); }); it("independent hmac sanity (guards the test's own reference)", () => { const h = createHmac("sha256", "k").update("m").digest("hex"); expect(createHash("sha256").update("m").digest("hex")).toBe(sha256Hex("m")); expect(h.length).toBe(64); }); });