From fa1481a5d2268ed7a1640f2a375767f06bd0a3e2 Mon Sep 17 00:00:00 2001 From: Abel Castro Date: Tue, 3 Mar 2026 12:10:59 +0100 Subject: [PATCH 1/2] Add BlobStorageS3Storage tests with aws-sdk-client-mock --- packages/api/cms-api/package.json | 1 + .../s3/blob-storage-s3.storage.spec.ts | 276 ++++++++++++++++++ pnpm-lock.yaml | 100 +++++++ 3 files changed, 377 insertions(+) create mode 100644 packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts diff --git a/packages/api/cms-api/package.json b/packages/api/cms-api/package.json index b2cc8d3a67..959a83518e 100644 --- a/packages/api/cms-api/package.json +++ b/packages/api/cms-api/package.json @@ -102,6 +102,7 @@ "@types/nodemailer": "^6.4.23", "@types/probe-image-size": "^7.2.5", "@types/request-ip": "^0.0.41", + "aws-sdk-client-mock": "^4.1.0", "chokidar-cli": "^3.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", diff --git a/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts b/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts new file mode 100644 index 0000000000..c9b09e7db1 --- /dev/null +++ b/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts @@ -0,0 +1,276 @@ +import { + CreateBucketCommand, + DeleteBucketCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { mockClient } from "aws-sdk-client-mock"; +import { Readable } from "stream"; + +import { BlobStorageS3Storage } from "./blob-storage-s3.storage"; + +function sdkError(statusCode: number, message = "Error"): Error { + return Object.assign(new Error(message), { $response: { statusCode } }); +} + +function streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + +describe("BlobStorageS3Storage", () => { + const s3Mock = mockClient(S3Client); + + let storage: BlobStorageS3Storage; + + beforeEach(() => { + s3Mock.reset(); + storage = new BlobStorageS3Storage({ bucket: "my-bucket", region: "eu-central-1" }); + }); + + describe("folderExists", () => { + it("should return true when the bucket exists", async () => { + s3Mock.on(HeadBucketCommand, { Bucket: "my-bucket" }).resolves({}); + + expect(await storage.folderExists("some-folder")).toBe(true); + }); + + it("should return false when the bucket does not exist", async () => { + s3Mock.on(HeadBucketCommand, { Bucket: "my-bucket" }).rejects(sdkError(404)); + + expect(await storage.folderExists("some-folder")).toBe(false); + }); + + it("should rethrow non-404 errors", async () => { + s3Mock.on(HeadBucketCommand).rejects(sdkError(403, "Forbidden")); + + await expect(storage.folderExists("some-folder")).rejects.toMatchObject({ message: "Forbidden" }); + }); + }); + + describe("createFolder", () => { + it("should send a CreateBucketCommand with the configured bucket", async () => { + s3Mock.on(CreateBucketCommand).resolves({}); + + await storage.createFolder("some-folder"); + + const calls = s3Mock.commandCalls(CreateBucketCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ Bucket: "my-bucket" }); + }); + }); + + describe("removeFolder", () => { + it("should send a DeleteBucketCommand with the configured bucket", async () => { + s3Mock.on(DeleteBucketCommand).resolves({}); + + await storage.removeFolder("some-folder"); + + const calls = s3Mock.commandCalls(DeleteBucketCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ Bucket: "my-bucket" }); + }); + }); + + describe("fileExists", () => { + it("should return true when HeadObject succeeds", async () => { + s3Mock.on(HeadObjectCommand, { Bucket: "my-bucket", Key: "folder/file.txt" }).resolves({}); + + expect(await storage.fileExists("folder", "file.txt")).toBe(true); + }); + + it("should return false when HeadObject returns 404", async () => { + s3Mock.on(HeadObjectCommand).rejects(sdkError(404)); + + expect(await storage.fileExists("folder", "missing.txt")).toBe(false); + }); + + it("should rethrow non-404 errors", async () => { + s3Mock.on(HeadObjectCommand).rejects(sdkError(500, "Internal")); + + await expect(storage.fileExists("folder", "file.txt")).rejects.toMatchObject({ message: "Internal" }); + }); + }); + + describe("createFile", () => { + it("should upload a Buffer using PutObjectCommand", async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + await storage.createFile("uploads", "buf.txt", Buffer.from("hello buffer"), { contentType: "text/plain", size: 12 }); + + const calls = s3Mock.commandCalls(PutObjectCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toMatchObject({ + Bucket: "my-bucket", + Key: "uploads/buf.txt", + ContentType: "text/plain", + ContentLength: 12, + }); + }); + }); + + describe("getFile", () => { + it("should return a readable stream with the file contents", async () => { + s3Mock.on(GetObjectCommand, { Bucket: "my-bucket", Key: "folder/read-me.txt" }).resolves({ + Body: Readable.from([Buffer.from("read this")]) as never, + }); + + const stream = await storage.getFile("folder", "read-me.txt"); + const content = await streamToBuffer(stream); + expect(content.toString()).toBe("read this"); + }); + }); + + describe("getPartialFile", () => { + it("should request the correct byte range", async () => { + s3Mock.on(GetObjectCommand).resolves({ + Body: Readable.from([Buffer.from("3456")]) as never, + }); + + const stream = await storage.getPartialFile("folder", "partial.txt", 3, 4); + const content = await streamToBuffer(stream); + expect(content.toString()).toBe("3456"); + + const calls = s3Mock.commandCalls(GetObjectCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toMatchObject({ + Bucket: "my-bucket", + Key: "folder/partial.txt", + Range: "bytes=3-6", + }); + }); + }); + + describe("listFiles", () => { + it("should return an empty array when there are no objects", async () => { + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] }); + + const files = await storage.listFiles("some-folder"); + expect(files).toEqual([]); + }); + + it("should return file names with the folder prefix stripped", async () => { + s3Mock.on(ListObjectsV2Command).resolves({ + Contents: [{ Key: "listing/a.txt" }, { Key: "listing/b.txt" }], + }); + + const files = await storage.listFiles("listing"); + expect(files).toEqual(["a.txt", "b.txt"]); + + const calls = s3Mock.commandCalls(ListObjectsV2Command); + expect(calls[0].args[0].input).toMatchObject({ + Bucket: "my-bucket", + Prefix: "listing/", + }); + }); + + it("should handle pagination with continuation tokens", async () => { + s3Mock + .on(ListObjectsV2Command) + .resolvesOnce({ + Contents: [{ Key: "folder/page1.txt" }], + NextContinuationToken: "token-abc", + }) + .resolvesOnce({ + Contents: [{ Key: "folder/page2.txt" }], + NextContinuationToken: undefined, + }); + + const files = await storage.listFiles("folder"); + expect(files).toEqual(["page1.txt", "page2.txt"]); + + const calls = s3Mock.commandCalls(ListObjectsV2Command); + expect(calls).toHaveLength(2); + expect(calls[1].args[0].input.ContinuationToken).toBe("token-abc"); + }); + + it("should skip objects without a Key", async () => { + s3Mock.on(ListObjectsV2Command).resolves({ + Contents: [{ Key: "folder/valid.txt" }, {}, { Key: "folder/also-valid.txt" }], + }); + + const files = await storage.listFiles("folder"); + expect(files).toEqual(["valid.txt", "also-valid.txt"]); + }); + }); + + describe("removeFile", () => { + it("should send a DeleteObjectCommand", async () => { + s3Mock.on(DeleteObjectCommand).resolves({}); + + await storage.removeFile("folder", "to-delete.txt"); + + const calls = s3Mock.commandCalls(DeleteObjectCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ + Bucket: "my-bucket", + Key: "folder/to-delete.txt", + }); + }); + }); + + describe("getFileMetaData", () => { + it("should return size, etag, lastModified, and contentType", async () => { + const lastModified = new Date("2025-01-15T10:00:00Z"); + s3Mock.on(HeadObjectCommand, { Bucket: "my-bucket", Key: "meta/info.txt" }).resolves({ + ContentLength: 42, + ETag: '"abc123"', + LastModified: lastModified, + ContentType: "text/plain", + }); + + const meta = await storage.getFileMetaData("meta", "info.txt"); + + expect(meta).toEqual({ + size: 42, + etag: '"abc123"', + lastModified, + contentType: "text/plain", + }); + }); + }); + + describe("getBackendFilePathPrefix", () => { + it("should return s3://", () => { + expect(storage.getBackendFilePathPrefix()).toBe("s3://"); + }); + }); + + describe("without a bucket (folderName as bucket)", () => { + let noBucketStorage: BlobStorageS3Storage; + + beforeEach(() => { + noBucketStorage = new BlobStorageS3Storage({ bucket: "", region: "eu-central-1" }); + }); + + it("should use folderName as bucket for folderExists", async () => { + s3Mock.on(HeadBucketCommand, { Bucket: "my-folder" }).resolves({}); + + expect(await noBucketStorage.folderExists("my-folder")).toBe(true); + }); + + it("should use folderName as bucket and fileName as key for fileExists", async () => { + s3Mock.on(HeadObjectCommand, { Bucket: "my-folder", Key: "file.txt" }).resolves({}); + + expect(await noBucketStorage.fileExists("my-folder", "file.txt")).toBe(true); + }); + + it("should use folderName as bucket and fileName as key for removeFile", async () => { + s3Mock.on(DeleteObjectCommand).resolves({}); + + await noBucketStorage.removeFile("my-folder", "file.txt"); + + const calls = s3Mock.commandCalls(DeleteObjectCommand); + expect(calls[0].args[0].input).toEqual({ Bucket: "my-folder", Key: "file.txt" }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b3eb8f4b..23fffbdb5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2172,6 +2172,9 @@ importers: '@types/request-ip': specifier: ^0.0.41 version: 0.0.41 + aws-sdk-client-mock: + specifier: ^4.1.0 + version: 4.1.0 chokidar-cli: specifier: ^3.0.0 version: 3.0.0 @@ -8002,17 +8005,34 @@ packages: '@sinonjs/commons@2.0.0': resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sinonjs/fake-timers@10.0.2': resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==} + '@sinonjs/fake-timers@11.2.2': + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + + '@sinonjs/fake-timers@15.1.1': + resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + '@sinonjs/fake-timers@6.0.1': resolution: {integrity: sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==} '@sinonjs/samsam@5.3.1': resolution: {integrity: sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==} + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@sinonjs/text-encoding@0.7.2': resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} + deprecated: 'Deprecated: no longer maintained, as we are not depending on it' + + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + deprecated: 'Deprecated: no longer maintained, as we are not depending on it' '@slorber/react-helmet-async@1.3.0': resolution: {integrity: sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==} @@ -9082,6 +9102,12 @@ packages: '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/sinon@17.0.4': + resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} @@ -9660,6 +9686,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-sdk-client-mock@4.1.0: + resolution: {integrity: sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==} + axios@0.30.2: resolution: {integrity: sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==} @@ -11084,6 +11113,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -13417,6 +13450,9 @@ packages: just-extend@4.2.1: resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -14436,6 +14472,9 @@ packages: nise@4.1.0: resolution: {integrity: sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==} + nise@6.1.2: + resolution: {integrity: sha512-zPM6UobDUDnhGcaWYjzig0tZCv4tXfF/1fnV58mfzL7pXSEwDLG0lXreuZ3u19O2ABghlYO8tFL1m1hrKUz/nw==} + no-case@2.3.2: resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} @@ -16361,6 +16400,9 @@ packages: signedsource@1.0.0: resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + sinon@18.0.1: + resolution: {integrity: sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==} + sinon@9.2.4: resolution: {integrity: sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==} deprecated: 16.1.1 @@ -17170,6 +17212,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -25269,10 +25315,22 @@ snapshots: dependencies: type-detect: 4.0.8 + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + '@sinonjs/fake-timers@10.0.2': dependencies: '@sinonjs/commons': 2.0.0 + '@sinonjs/fake-timers@11.2.2': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/fake-timers@15.1.1': + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers@6.0.1': dependencies: '@sinonjs/commons': 1.8.6 @@ -25285,9 +25343,16 @@ snapshots: type-detect: 4.0.8 optional: true + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@sinonjs/text-encoding@0.7.2': optional: true + '@sinonjs/text-encoding@0.7.3': {} + '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.6 @@ -26666,6 +26731,12 @@ snapshots: '@types/shimmer@1.2.0': {} + '@types/sinon@17.0.4': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/sockjs@0.3.36': dependencies: '@types/node': 24.11.0 @@ -27393,6 +27464,12 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 + aws-sdk-client-mock@4.1.0: + dependencies: + '@types/sinon': 17.0.4 + sinon: 18.0.1 + tslib: 2.8.1 + axios@0.30.2: dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -29021,6 +29098,8 @@ snapshots: diff@4.0.2: {} + diff@5.2.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -32071,6 +32150,8 @@ snapshots: just-extend@4.2.1: optional: true + just-extend@6.2.0: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -33579,6 +33660,14 @@ snapshots: path-to-regexp: 1.9.0 optional: true + nise@6.1.2: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.1.1 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 6.2.0 + path-to-regexp: 8.3.0 + no-case@2.3.2: dependencies: lower-case: 1.1.4 @@ -35828,6 +35917,15 @@ snapshots: signedsource@1.0.0: {} + sinon@18.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.2.2 + '@sinonjs/samsam': 8.0.3 + diff: 5.2.2 + nise: 6.1.2 + supports-color: 7.2.0 + sinon@9.2.4: dependencies: '@sinonjs/commons': 1.8.6 @@ -36742,6 +36840,8 @@ snapshots: type-detect@4.0.8: {} + type-detect@4.1.0: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} From 57a5526f5d59edfebe13d2142c956718d2218523 Mon Sep 17 00:00:00 2001 From: Abel Castro Date: Wed, 11 Mar 2026 10:29:41 +0100 Subject: [PATCH 2/2] Add test for S3 folder marker filtering in listFiles S3 can return the folder itself as an object (e.g. "folderName/") in ListObjectsV2 responses. This adds a regression test to verify that the folder marker is excluded from the returned file list. --- .../backends/s3/blob-storage-s3.storage.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts b/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts index c9b09e7db1..809b06e8b5 100644 --- a/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts +++ b/packages/api/cms-api/src/blob-storage/backends/s3/blob-storage-s3.storage.spec.ts @@ -193,6 +193,15 @@ describe("BlobStorageS3Storage", () => { expect(calls[1].args[0].input.ContinuationToken).toBe("token-abc"); }); + it("should skip the S3 folder marker object", async () => { + s3Mock.on(ListObjectsV2Command).resolves({ + Contents: [{ Key: "listing/" }, { Key: "listing/a.txt" }, { Key: "listing/b.txt" }], + }); + + const files = await storage.listFiles("listing"); + expect(files).toEqual(["a.txt", "b.txt"]); + }); + it("should skip objects without a Key", async () => { s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: "folder/valid.txt" }, {}, { Key: "folder/also-valid.txt" }],