import { randomUUIDv7, S3Client, S3Options } from "bun"; import { afterAll, describe, expect, it } from "bun:test"; import { getSecret } from "harness"; const options: S3Options = { accessKeyId: "test", secretAccessKey: "test", region: "eu-west-3", bucket: "my_bucket", }; function createBunServer(fetch: Parameters[0]["fetch"]) { // @ts-ignore const server = Bun.serve({ port: 0, fetch, }); server.unref(); return server; } describe("S3 - List Objects", () => { it("Should set encoded continuation-token in the request url before list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ continuationToken: "continue=ation-_m^token", }); expect(reqUrl!).toEndWith("/?continuation-token=continue%3Dation-_m%5Etoken&list-type=2"); }); it("Should set encoded delimiter in the request url before list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ delimiter: "files/", }); expect(reqUrl!).toEndWith("/?delimiter=files%2F&list-type=2"); }); it("Should set encoding-type in the request url before list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ encodingType: "url", }); expect(reqUrl!).toEndWith("/?encoding-type=url&list-type=2"); }); it("Should set fetch-owner (true) in the request url before list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ fetchOwner: true, }); expect(reqUrl!).toEndWith("/?fetch-owner=true&list-type=2"); }); it("Should set fetch-owner (false) in the request url before list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ fetchOwner: false, }); expect(reqUrl!).toEndWith("/?fetch-owner=false&list-type=2"); }); it("Should set max-keys in the request url after list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ maxKeys: 2034, }); expect(reqUrl!).toEndWith("/?list-type=2&max-keys=2034"); }); it("Should set encoded prefix in the request url after list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ prefix: "some/sub/&folder", }); expect(reqUrl!).toEndWith("/?list-type=2&prefix=some%2Fsub%2F%26folder"); }); it("Should set encoded start-after in the request url after list-type", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ startAfter: "àwsôme/fìles", }); expect(reqUrl!).toEndWith("/?list-type=2&start-after=%C3%A0ws%C3%B4me%2Ff%C3%ACles"); }); it("Should work with multiple options all encoded in correct order", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response(`<>`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list({ prefix: "some/sub/&folder", startAfter: "àwsôme/fìles", maxKeys: 2034, fetchOwner: true, encodingType: "url", delimiter: "files/", continuationToken: "continue=ation-_m^token", }); expect(reqUrl!).toEndWith( "/?continuation-token=continue%3Dation-_m%5Etoken&delimiter=files%2F&encoding-type=url&fetch-owner=true&list-type=2&max-keys=2034&prefix=some%2Fsub%2F%26folder&start-after=%C3%A0ws%C3%B4me%2Ff%C3%ACles", ); }); it("Should work without provided option", async () => { using server = createBunServer(async => { return new Response( ` my_bucket `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ name: "my_bucket", }); }); it("Should work with extra options", async () => { let reqHeaders: Headers; using server = createBunServer(async req => { reqHeaders = req.headers; return new Response( ` my_bucket `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); await client.list(undefined, { ...options, bucket: "another-bucket", sessionToken: "good token", }); expect(reqHeaders!.get("x-amz-security-token")).toBe("good token"); }); it("Should work without xmlns attrib", async () => { using server = createBunServer(async => { return new Response( ` my_bucket `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ name: "my_bucket", }); }); it("Should return parsed response with bucket Name", async () => { using server = createBunServer(async => { return new Response( ` my_bucket `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ name: "my_bucket", }); }); it("Should return parsed response with Prefix", async () => { using server = createBunServer(async => { return new Response( ` some/prefix `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ prefix: "some/prefix", }); }); it("Should return parsed response with KeyCount", async () => { using server = createBunServer(async => { return new Response( ` 18 `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ keyCount: 18, }); }); it("Should return parsed response with MaxKeys", async () => { using server = createBunServer(async => { return new Response( ` 2323 `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ maxKeys: 2323, }); }); it("Should return parsed response with Delimiter", async () => { using server = createBunServer(async => { return new Response( ` good@&/de$ `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ delimiter: "good@&/de$ { using server = createBunServer(async => { return new Response( ` current pagination token `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ continuationToken: "current pagination token", }); }); it("Should return parsed response with EncodingType", async () => { using server = createBunServer(async => { return new Response( ` url `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ encodingType: "url", }); }); it("Should return parsed response with NextContinuationToken", async () => { using server = createBunServer(async => { return new Response( ` some next token `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ nextContinuationToken: "some next token", }); }); it("Should return parsed response with IsTruncated (false)", async () => { using server = createBunServer(async => { return new Response( ` false `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ isTruncated: false, }); }); it("Should return parsed response with IsTruncated (true)", async () => { using server = createBunServer(async => { return new Response( ` true `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ isTruncated: true, }); }); it("Should return parsed response with StartAfter", async () => { using server = createBunServer(async => { return new Response( ` some/file/name.pdf `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ startAfter: "some/file/name.pdf", }); }); it("Should return parsed response with CommonPrefixes", async () => { using server = createBunServer(async => { return new Response( ` photos/videos/ documents/public `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ commonPrefixes: [ { prefix: "photos/", }, { prefix: "videos/", }, { prefix: "documents/public", }, ], }); }); it("Should return parsed response with Contents", async () => { using server = createBunServer(async => { return new Response( ` my_files/important/bun.js 2025-01-20T22:12:38.000Z false 2012-12-21T00:00:00.000Z "4c6426ac7ef186464ecbb0d81cbfcb1e" 102400 someId23Sodgopez STANDARD my_files/important/bun1.2.3.js 2025-02-07 "etag-with-quotes" someId23Sodgopez some display name GLACIER all-empty_file `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ contents: [ { key: "my_files/important/bun.js", eTag: '"4c6426ac7ef186464ecbb0d81cbfcb1e"', lastModified: "2025-01-20T22:12:38.000Z", size: 102400, storageClass: "STANDARD", owner: { id: "someId23Sodgopez", }, }, { key: "my_files/important/bun1.2.3.js", eTag: '"etag-with-quotes"', lastModified: "2025-02-07", storageClass: "GLACIER", owner: { id: "someId23Sodgopez", displayName: "some display name", }, }, { key: "all-empty_file", eTag: "", lastModified: "", // @ts-expect-error storageClass: "", }, ], }); }); it("Should return parsed response with all fields", async () => { using server = createBunServer(async => { return new Response( ` inqnuam / 0 10000 awsome.dummy thing current pagination token url some next token false from_static_file 2025-01-20T23:02:53.000Z "ef2b83534e23713ee9751d492178109e" 922282819299999 some display name some_id_ STANDARD_IA some/file/name.pdf photos/videos/ `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list( { continuationToken: "token", prefix: "files/", }, {}, ); expect(res).toEqual({ name: "inqnuam", prefix: "/", delimiter: "awsome.dummy thing", startAfter: "some/file/name.pdf", encodingType: "url", continuationToken: "current pagination token", nextContinuationToken: "some next token", isTruncated: false, keyCount: 0, maxKeys: 10000, contents: [ { key: "from_static_file", eTag: '"ef2b83534e23713ee9751d492178109e"', lastModified: "2025-01-20T23:02:53.000Z", size: 922282819299999, storageClass: "STANDARD_IA", owner: { displayName: "some display name", id: "some_id_", }, }, ], commonPrefixes: [ { prefix: "photos/", }, { prefix: "videos/", }, ], }); }); it("Should work with static method", async () => { let reqUrl: string; using server = createBunServer(async req => { reqUrl = req.url; return new Response( ` some next token `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const res = await S3Client.list({ prefix: "some/prefix" }, { ...options, endpoint: server.url.href }); expect(reqUrl!).toEndWith("/my_bucket/?list-type=2&prefix=some%2Fprefix"); expect(res).toEqual({ nextContinuationToken: "some next token", }); }); it("Should work with big responses", async () => { const contents = new Array(40 * 1000).fill("").map(x => ({ key: randomUUIDv7(), eTag: '"4c6426ac7ef186464ecbb0d81cbfcb1e"', lastModified: new Date().toISOString(), size: 922282819299999, storageClass: "STANDARD_IA", owner: { id: "some_id_", }, })); using server = createBunServer(async => { const asXml = contents .map( x => ` ${x.key} ${x.lastModified} "4c6426ac7ef186464ecbb0d81cbfcb1e" 922282819299999 some_id_ STANDARD_IA `, ) .join(""); return new Response(`${asXml}`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ // @ts-ignore contents, }); }); it("Should not crash with bad xml", async () => { using server = createBunServer(async => { return new Response( ` `, { headers: { "Content-Type": "application/xml", }, status: 200, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({}); }); it("Should throw Error if request failed", async () => { using server = createBunServer(async => { return new Response( ` WhoKnows`, { headers: { "Content-Type": "application/xml", }, status: 400, }, ); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); try { await client.list(); expect.unreachable(); } catch (error: any) { expect(error.code).toBe("WhoKnows"); } }); it("Should throw if option is not an Object", async () => { using server = createBunServer(async => { return new Response(`Should not be errored here`, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); try { // @ts-expect-error await client.list(11143n); expect.unreachable(); } catch (error: any) { expect(error.code).toBe("ERR_INVALID_ARG_TYPE"); } }); it("Should work with an actual S3 output", async () => { // do not change any byte in this string const actualOutput = ` awsome-very-dummy-bucket31000false01951feb-1db5-7000-aee3-213b4df4015f/file_1.txt2025-02-19T20:34:18.000Z"9297ab3fbd56b42f6566284119238125"9STANDARD01951feb-1db5-7000-aee3-213b4df4015f/file_2.txt2025-02-19T20:34:18.000Z"6685cd62b95f2c58818cb20e7292168b"9STANDARD01951feb-1db5-7000-aee3-213b4df4015f/file_3.txt2025-02-19T20:34:18.000Z"bffd51760cd2c6b531756efac72110c3"9STANDARD`; using server = createBunServer(async => { return new Response(actualOutput, { headers: { "Content-Type": "application/xml", }, status: 200, }); }); const client = new S3Client({ ...options, endpoint: server.url.href, }); const res = await client.list(); expect(res).toEqual({ name: "awsome-very-dummy-bucket", isTruncated: false, keyCount: 3, maxKeys: 1000, contents: [ { key: "01951feb-1db5-7000-aee3-213b4df4015f/file_1.txt", eTag: '"9297ab3fbd56b42f6566284119238125"', lastModified: "2025-02-19T20:34:18.000Z", size: 9, storageClass: "STANDARD", }, { key: "01951feb-1db5-7000-aee3-213b4df4015f/file_2.txt", eTag: '"6685cd62b95f2c58818cb20e7292168b"', lastModified: "2025-02-19T20:34:18.000Z", size: 9, storageClass: "STANDARD", }, { key: "01951feb-1db5-7000-aee3-213b4df4015f/file_3.txt", eTag: '"bffd51760cd2c6b531756efac72110c3"', lastModified: "2025-02-19T20:34:18.000Z", size: 9, storageClass: "STANDARD", }, ], }); }); it("Should throw error when no creds is found from instance method", async () => { const client = new S3Client(); try { await client.list(); expect.unreachable(); } catch (error: any) { expect(error.code).toBe("ERR_S3_MISSING_CREDENTIALS"); } }); it("Should throw error when no creds is found from static method", async () => { try { await S3Client.list(); expect.unreachable(); } catch (error: any) { expect(error.code).toBe("ERR_S3_MISSING_CREDENTIALS"); } }); }); const optionsFromEnv: S3Options = { accessKeyId: getSecret("S3_R2_ACCESS_KEY"), secretAccessKey: getSecret("S3_R2_SECRET_KEY"), endpoint: getSecret("S3_R2_ENDPOINT"), bucket: getSecret("S3_R2_BUCKET"), }; describe.skipIf(!optionsFromEnv.accessKeyId)("S3 - CI - List Objects", () => { const bucket = new S3Client(optionsFromEnv); const keyPrefix = `${randomUUIDv7()}/`; const file_1 = `${keyPrefix}file_1.txt`; const file_2 = `${keyPrefix}file_2.txt`; const file_3 = `${keyPrefix}file_3.txt`; const file_4 = `${keyPrefix}file_a>b.txt`; const file_5 = `${keyPrefix}file_a>c.txt`; const file_6 = `${keyPrefix}file_a>d.txt`; afterAll(async () => { try { // TODO replace with deleteObjects once merged await Promise.all([file_1, file_2, file_3, file_4, file_5, file_6].map(async key => await bucket.delete(key))); } catch (error) { console.error(error); } }); it("should list objects with prefix, maxKeys and nextContinuationToken", async () => { await bucket.write(file_1, "content 1"); await bucket.write(file_2, "content 2"); await bucket.write(file_3, "content 3"); const first_response = await bucket.list({ prefix: keyPrefix, maxKeys: 1 }); expect(first_response.name).toBeString(); expect(first_response.prefix).toBe(keyPrefix); expect(first_response.nextContinuationToken).toBeString(); expect(first_response.isTruncated).toBeTrue(); expect(first_response.keyCount).toBe(1); expect(first_response.maxKeys).toBe(1); expect(first_response.contents).toBeArray(); expect(first_response.contents![0].key).toBe(file_1); const final_response = await bucket.list({ prefix: keyPrefix, maxKeys: 30, continuationToken: first_response.nextContinuationToken, }); expect(final_response.nextContinuationToken).toBeUndefined(); expect(final_response.continuationToken).toBeString(); expect(final_response.isTruncated).toBeFalse(); expect(final_response.keyCount).toBe(2); expect(final_response.maxKeys).toBe(30); expect(final_response.contents).toHaveLength(2); expect(final_response.contents![0].key).toBe(file_2); expect(final_response.contents![1].key).toBe(file_3); const storedFile = final_response.contents![1]; expect(storedFile.eTag).toBeString(); expect(storedFile.lastModified).toBeString(); expect(storedFile.size).toBe(9); }); it("should list objects with startAfter, encodingType and fetchOwner", async () => { await bucket.write(file_4, "content 4"); await bucket.write(file_5, "content 5"); await bucket.write(file_6, "content 6"); const res = await bucket.list({ prefix: keyPrefix, // isolates the test when Bucket is not empty startAfter: file_4, fetchOwner: true, encodingType: "url", }); expect(res.encodingType).toBe("url"); expect(res.keyCount).toBe(2); expect(res.startAfter).toEndWith(encodeURIComponent("file_a>b.txt")); expect(res.contents).toBeArray(); expect(res.contents).toHaveLength(2); expect(res.contents![0].key).toEndWith(encodeURIComponent("file_a>c.txt")); expect(res.contents![1].key).toEndWith(encodeURIComponent("file_a>d.txt")); const storedFile = res.contents![1]; expect(storedFile.owner).toBeObject(); expect(storedFile.owner!.id).toBeString(); }); });