From 3172564c4f1f112c44bf8216e24db7a374a89b3f Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 16 Jan 2025 18:29:39 -0500 Subject: [PATCH 01/16] Revise createFile logic to preserve key & location - Add error handling for key generation - Standardize return object with location, url, and filename - Add support for optional config parameter - Return s3 response explicitly as a separate variable --- index.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 9b94ddf..10f4d92 100644 --- a/index.js +++ b/index.js @@ -140,16 +140,23 @@ class S3Adapter { // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response - async createFile(filename, data, contentType, options = {}) { + async createFile(filename, data, contentType, options = {}, config= {}) { + + let key_without_prefix = filename; + if (this._generateKey instanceof Function) { + try { + key_without_prefix = this._generateKey(filename); + }catch(e){ + throw new Error(e); // throw error if generateKey function fails + } + } + const params = { Bucket: this._bucket, - Key: this._bucketPrefix + filename, + Key: this._bucketPrefix + key_without_prefix, Body: data, }; - - if (this._generateKey instanceof Function) { - params.Key = this._bucketPrefix + this._generateKey(filename); - } + if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; @@ -181,7 +188,14 @@ class S3Adapter { const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; const location = `${endpoint}/${params.Key}`; - return Object.assign(response || {}, { Location: location }); + const url = await this.getFileLocation(config, key_without_prefix); + + return { + location: location, // actual upload location, used for tests + url: url, // optionally signed url (can be returned to client) + filename: key_without_prefix, // filename in storage + s3_response: response // raw s3 response + }; } async deleteFile(filename) { From be61240b27f2527952e9c36e324580dcf9464101 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 16 Jan 2025 18:42:17 -0500 Subject: [PATCH 02/16] Change to name to be consistent with parse server createFile --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 10f4d92..d6e07af 100644 --- a/index.js +++ b/index.js @@ -193,7 +193,7 @@ class S3Adapter { return { location: location, // actual upload location, used for tests url: url, // optionally signed url (can be returned to client) - filename: key_without_prefix, // filename in storage + name: key_without_prefix, // filename in storage, consistent with other adapters s3_response: response // raw s3 response }; } From d8c1e157c5300d03afd761a237d3d1d937dc6db1 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 16 Jan 2025 18:55:36 -0500 Subject: [PATCH 03/16] Update test.spec.js --- spec/test.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/test.spec.js b/spec/test.spec.js index 5402460..ddd233c 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -729,7 +729,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.indexOf(fileName) > 13).toBe(true); }); promises.push(response); @@ -740,7 +740,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'foo/randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.substring(1)).toEqual(options.bucketPrefix + fileName); }); promises.push(response); @@ -750,7 +750,7 @@ describe('S3Adapter tests', () => { const s3 = getMockS3Adapter(options); const fileName = 'foo/randomFileName.txt'; const response = s3.createFile(fileName, 'hello world', 'text/utf8').then(value => { - const url = new URL(value.Location); + const url = new URL(value.location); expect(url.pathname.indexOf('foo/')).toEqual(6); expect(url.pathname.indexOf('random') > 13).toBe(true); }); From f3f685bbc67615026718a10f43a718509ac19883 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 16 Jan 2025 19:22:52 -0500 Subject: [PATCH 04/16] Update index.js --- index.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index d6e07af..6619b51 100644 --- a/index.js +++ b/index.js @@ -188,13 +188,16 @@ class S3Adapter { const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; const location = `${endpoint}/${params.Key}`; - const url = await this.getFileLocation(config, key_without_prefix); + let url; + if (Object.keys(config).length != 0) { // if config is passed, we can generate a presigned url here + url = await this.getFileLocation(config, key_without_prefix); + } return { location: location, // actual upload location, used for tests - url: url, // optionally signed url (can be returned to client) name: key_without_prefix, // filename in storage, consistent with other adapters - s3_response: response // raw s3 response + s3_response: response, // raw s3 response + ...url? {url: url} : {} // url (optionally presigned) or non-direct access url }; } From fb9c42edbc7b3d1c859f6123097dda421a2f8b70 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 17 Jan 2025 12:57:59 -0500 Subject: [PATCH 05/16] Correction to make codecov happy --- lib/optionsFromArguments.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/optionsFromArguments.js b/lib/optionsFromArguments.js index f56c9bc..b05c1fc 100644 --- a/lib/optionsFromArguments.js +++ b/lib/optionsFromArguments.js @@ -42,12 +42,12 @@ const optionsFromArguments = function optionsFromArguments(args) { } else if (args.length === 2) { options.bucket = stringOrOptions; if (typeof args[1] !== 'object') { - throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); + throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); } otherOptions = args[1]; } else if (args.length > 2) { if (typeof args[1] !== 'string' || typeof args[2] !== 'string') { - throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); + throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); } options.accessKey = args[0]; options.secretKey = args[1]; @@ -81,7 +81,7 @@ const optionsFromArguments = function optionsFromArguments(args) { options.bucket = s3overrides.params.Bucket; } } else if (args.length > 2) { - throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); + throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); } options = fromOptionsDictionaryOrDefault(options, 's3overrides', s3overrides); From 394e175b21bd36c0a80ca35a7b046d25302599e9 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:03:46 -0500 Subject: [PATCH 06/16] Remove and add some spaces to make lint happy --- index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 6619b51..6d36ef8 100644 --- a/index.js +++ b/index.js @@ -140,8 +140,8 @@ class S3Adapter { // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response - async createFile(filename, data, contentType, options = {}, config= {}) { - + async createFile(filename, data, contentType, options = {}, config = {}) { + let key_without_prefix = filename; if (this._generateKey instanceof Function) { try { @@ -150,13 +150,13 @@ class S3Adapter { throw new Error(e); // throw error if generateKey function fails } } - + const params = { Bucket: this._bucket, Key: this._bucketPrefix + key_without_prefix, Body: data, }; - + if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; @@ -196,8 +196,8 @@ class S3Adapter { return { location: location, // actual upload location, used for tests name: key_without_prefix, // filename in storage, consistent with other adapters - s3_response: response, // raw s3 response - ...url? {url: url} : {} // url (optionally presigned) or non-direct access url + s3_response: response, // raw s3 response + ...url ? { url: url } : {} // url (optionally presigned) or non-direct access url }; } From 9d760beb47bb715486b20787470e8896ef3f47db Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:23:25 -0500 Subject: [PATCH 07/16] Allow generate key to access certain createfile traits --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 6d36ef8..862cf35 100644 --- a/index.js +++ b/index.js @@ -145,7 +145,7 @@ class S3Adapter { let key_without_prefix = filename; if (this._generateKey instanceof Function) { try { - key_without_prefix = this._generateKey(filename); + key_without_prefix = this._generateKey(filename, contentType, options); }catch(e){ throw new Error(e); // throw error if generateKey function fails } From 84e11a51c60292bb53d679fbafa6fa33461bc18c Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Feb 2025 14:12:27 -0500 Subject: [PATCH 08/16] Add tests for url when config is provided and error on generatekey --- spec/test.spec.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/spec/test.spec.js b/spec/test.spec.js index ddd233c..b878198 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -867,6 +867,46 @@ describe('S3Adapter tests', () => { expect(commandArg).toBeInstanceOf(PutObjectCommand); expect(commandArg.input.ACL).toBeUndefined(); }); + + it('should return url when config is provided', async () => { + const options = { + bucket: 'bucket-1', + presignedUrl: true + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + // Mock getFileLocation to return a presigned URL + spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt')); + + const result = await s3.createFile( + 'file.txt', + 'hello world', + 'text/utf8', + {}, + { mount: 'http://example.com', applicationId: 'test123' } + ); + + expect(result.url).toBe('https://presigned-url.com/file.txt'); + expect(result.location).toBeDefined(); + expect(result.name).toBe('file.txt'); + expect(result.s3_response).toBeDefined(); + }); + + it('should handle generateKey function errors', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => { + throw new Error('Generate key failed'); + } + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('Generate key failed'); + }); }); describe('handleFileStream', () => { From 7afb0a266ab0f49e515037c3c1d5030ca5430feb Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Feb 2025 14:21:52 -0500 Subject: [PATCH 09/16] Change spaces and adjust tests --- index.js | 6 +++--- spec/test.spec.js | 25 +++++++++++++------------ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/index.js b/index.js index 862cf35..90a0d90 100644 --- a/index.js +++ b/index.js @@ -141,7 +141,7 @@ class S3Adapter { // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response async createFile(filename, data, contentType, options = {}, config = {}) { - + let key_without_prefix = filename; if (this._generateKey instanceof Function) { try { @@ -150,13 +150,13 @@ class S3Adapter { throw new Error(e); // throw error if generateKey function fails } } - + const params = { Bucket: this._bucket, Key: this._bucketPrefix + key_without_prefix, Body: data, }; - + if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; diff --git a/spec/test.spec.js b/spec/test.spec.js index b878198..313494a 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -875,34 +875,35 @@ describe('S3Adapter tests', () => { }; const s3 = new S3Adapter(options); s3._s3Client = s3ClientMock; - // Mock getFileLocation to return a presigned URL spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt')); - + const result = await s3.createFile( - 'file.txt', - 'hello world', - 'text/utf8', + 'file.txt', + 'hello world', + 'text/utf8', {}, { mount: 'http://example.com', applicationId: 'test123' } ); - - expect(result.url).toBe('https://presigned-url.com/file.txt'); - expect(result.location).toBeDefined(); - expect(result.name).toBe('file.txt'); - expect(result.s3_response).toBeDefined(); + + expect(result).toEqual({ + location: jasmine.any(String), + name: 'file.txt', + s3_response: jasmine.any(Object), + url: 'https://presigned-url.com/file.txt' + }); }); it('should handle generateKey function errors', async () => { const options = { bucket: 'bucket-1', generateKey: () => { - throw new Error('Generate key failed'); + throw 'Generate key failed'; } }; const s3 = new S3Adapter(options); s3._s3Client = s3ClientMock; - + await expectAsync( s3.createFile('file.txt', 'hello world', 'text/utf8', {}) ).toBeRejectedWithError('Generate key failed'); From f5616d95b6e2e0caafb7f5e1ca2a6809521ca537 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Sat, 1 Feb 2025 14:31:00 -0500 Subject: [PATCH 10/16] Also mock s3 response for good measure --- spec/test.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/test.spec.js b/spec/test.spec.js index 313494a..4ef3101 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -874,7 +874,15 @@ describe('S3Adapter tests', () => { presignedUrl: true }; const s3 = new S3Adapter(options); + + const mockS3Response = { + ETag: '"mock-etag"', + VersionId: 'mock-version', + Location: 'mock-location' + }; + s3ClientMock.send.and.returnValue(Promise.resolve(mockS3Response)); s3._s3Client = s3ClientMock; + // Mock getFileLocation to return a presigned URL spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt')); From 3ba5566a36c5ca9bac098959ffbbf6caa6dc9b13 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Thu, 6 Feb 2025 20:24:36 -0500 Subject: [PATCH 11/16] Remove another two spaces + change liboptions error back to quotes --- index.js | 2 +- lib/optionsFromArguments.js | 6 +++--- spec/test.spec.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 90a0d90..5e3e8f8 100644 --- a/index.js +++ b/index.js @@ -156,7 +156,7 @@ class S3Adapter { Key: this._bucketPrefix + key_without_prefix, Body: data, }; - + if (this._fileAcl) { if (this._fileAcl === 'none') { delete params.ACL; diff --git a/lib/optionsFromArguments.js b/lib/optionsFromArguments.js index b05c1fc..f56c9bc 100644 --- a/lib/optionsFromArguments.js +++ b/lib/optionsFromArguments.js @@ -42,12 +42,12 @@ const optionsFromArguments = function optionsFromArguments(args) { } else if (args.length === 2) { options.bucket = stringOrOptions; if (typeof args[1] !== 'object') { - throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); + throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); } otherOptions = args[1]; } else if (args.length > 2) { if (typeof args[1] !== 'string' || typeof args[2] !== 'string') { - throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); + throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); } options.accessKey = args[0]; options.secretKey = args[1]; @@ -81,7 +81,7 @@ const optionsFromArguments = function optionsFromArguments(args) { options.bucket = s3overrides.params.Bucket; } } else if (args.length > 2) { - throw new Error('Failed to configure S3Adapter. Arguments don\'t make sense'); + throw new Error("Failed to configure S3Adapter. Arguments don't make sense"); } options = fromOptionsDictionaryOrDefault(options, 's3overrides', s3overrides); diff --git a/spec/test.spec.js b/spec/test.spec.js index 4ef3101..28e1603 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -882,7 +882,7 @@ describe('S3Adapter tests', () => { }; s3ClientMock.send.and.returnValue(Promise.resolve(mockS3Response)); s3._s3Client = s3ClientMock; - + // Mock getFileLocation to return a presigned URL spyOn(s3, 'getFileLocation').and.returnValue(Promise.resolve('https://presigned-url.com/file.txt')); From 227c7cfe4e0890d7e0c82f26c2bdc13bae4e97c2 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:33:12 -0400 Subject: [PATCH 12/16] Revise config check and don't throw error response to prevent leaking sensitive info --- index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 5e3e8f8..368ca3d 100644 --- a/index.js +++ b/index.js @@ -146,8 +146,8 @@ class S3Adapter { if (this._generateKey instanceof Function) { try { key_without_prefix = this._generateKey(filename, contentType, options); - }catch(e){ - throw new Error(e); // throw error if generateKey function fails + } catch { + throw new Error('Key generation failed'); } } @@ -189,7 +189,7 @@ class S3Adapter { const location = `${endpoint}/${params.Key}`; let url; - if (Object.keys(config).length != 0) { // if config is passed, we can generate a presigned url here + if (config && typeof config === 'object' && Object.keys(config).length > 0) { // if config is passed, we can generate a presigned url here url = await this.getFileLocation(config, key_without_prefix); } From 6a3b0cfb3bd66a8d356d703e4d5b938239001751 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:45:57 -0400 Subject: [PATCH 13/16] Directly run generate key to preserve error trace --- index.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/index.js b/index.js index 368ca3d..d0bea59 100644 --- a/index.js +++ b/index.js @@ -144,11 +144,7 @@ class S3Adapter { let key_without_prefix = filename; if (this._generateKey instanceof Function) { - try { - key_without_prefix = this._generateKey(filename, contentType, options); - } catch { - throw new Error('Key generation failed'); - } + key_without_prefix = this._generateKey(filename, contentType, options); } const params = { From b7d124f3045c2864e8b34478cecf77368b2c68ce Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:49:49 -0400 Subject: [PATCH 14/16] Remove s3_response & specifically check config for needed vars --- index.js | 5 ++--- spec/test.spec.js | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index d0bea59..97edeb4 100644 --- a/index.js +++ b/index.js @@ -180,19 +180,18 @@ class S3Adapter { } await this.createBucket(); const command = new PutObjectCommand(params); - const response = await this._s3Client.send(command); + await this._s3Client.send(command); const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; const location = `${endpoint}/${params.Key}`; let url; - if (config && typeof config === 'object' && Object.keys(config).length > 0) { // if config is passed, we can generate a presigned url here + if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation url = await this.getFileLocation(config, key_without_prefix); } return { location: location, // actual upload location, used for tests name: key_without_prefix, // filename in storage, consistent with other adapters - s3_response: response, // raw s3 response ...url ? { url: url } : {} // url (optionally presigned) or non-direct access url }; } diff --git a/spec/test.spec.js b/spec/test.spec.js index 28e1603..06065e5 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -897,7 +897,6 @@ describe('S3Adapter tests', () => { expect(result).toEqual({ location: jasmine.any(String), name: 'file.txt', - s3_response: jasmine.any(Object), url: 'https://presigned-url.com/file.txt' }); }); @@ -914,7 +913,7 @@ describe('S3Adapter tests', () => { await expectAsync( s3.createFile('file.txt', 'hello world', 'text/utf8', {}) - ).toBeRejectedWithError('Generate key failed'); + ).toBeRejectedWith('Generate key failed'); }); }); From bf8878552a7388daa8c9e71ecda70cab29694cd5 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:57:01 -0400 Subject: [PATCH 15/16] Allow async key generation and revise test --- index.js | 10 ++++-- spec/test.spec.js | 85 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 97edeb4..2ff8dfa 100644 --- a/index.js +++ b/index.js @@ -141,10 +141,14 @@ class S3Adapter { // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response async createFile(filename, data, contentType, options = {}, config = {}) { - let key_without_prefix = filename; - if (this._generateKey instanceof Function) { - key_without_prefix = this._generateKey(filename, contentType, options); + if (typeof this._generateKey === 'function') { + const candidate = this._generateKey(filename, contentType, options); + key_without_prefix = + candidate && typeof candidate.then === 'function' ? await candidate : candidate; + if (typeof key_without_prefix !== 'string' || key_without_prefix.length === 0) { + throw new Error('generateKey must return a non-empty string'); + } } const params = { diff --git a/spec/test.spec.js b/spec/test.spec.js index 06065e5..c2560e1 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -905,7 +905,7 @@ describe('S3Adapter tests', () => { const options = { bucket: 'bucket-1', generateKey: () => { - throw 'Generate key failed'; + throw new Error('Generate key failed'); } }; const s3 = new S3Adapter(options); @@ -913,7 +913,88 @@ describe('S3Adapter tests', () => { await expectAsync( s3.createFile('file.txt', 'hello world', 'text/utf8', {}) - ).toBeRejectedWith('Generate key failed'); + ).toBeRejectedWithError('Generate key failed'); + }); + + it('should handle async generateKey function', async () => { + const options = { + bucket: 'bucket-1', + generateKey: async (filename) => { + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + return `async-${filename}`; + } + }; + const s3 = new S3Adapter(options); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + s3._s3Client = s3ClientMock; + + await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + + expect(s3ClientMock.send).toHaveBeenCalledTimes(2); + const commands = s3ClientMock.send.calls.all(); + const commandArg = commands[1].args[0]; + expect(commandArg).toBeInstanceOf(PutObjectCommand); + expect(commandArg.input.Key).toBe('async-file.txt'); + }); + + it('should handle generateKey that returns a Promise', async () => { + const options = { + bucket: 'bucket-1', + generateKey: (filename) => { + return Promise.resolve(`promise-${filename}`); + } + }; + const s3 = new S3Adapter(options); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + s3._s3Client = s3ClientMock; + + await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + + expect(s3ClientMock.send).toHaveBeenCalledTimes(2); + const commands = s3ClientMock.send.calls.all(); + const commandArg = commands[1].args[0]; + expect(commandArg).toBeInstanceOf(PutObjectCommand); + expect(commandArg.input.Key).toBe('promise-file.txt'); + }); + + it('should validate generateKey returns a non-empty string', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => '' + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + + it('should validate generateKey returns a string (not number)', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => 12345 + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + + it('should validate async generateKey returns a string', async () => { + const options = { + bucket: 'bucket-1', + generateKey: async () => null + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); }); }); From 2cac3148ec96379a596c246350a44667abb88b82 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:40:29 -0400 Subject: [PATCH 16/16] trim & test key after generation & assmble endpoint accordingly More tests and changes to make coderabbit happy --- index.js | 26 ++++++++++-- spec/test.spec.js | 105 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 2ff8dfa..f61eef8 100644 --- a/index.js +++ b/index.js @@ -146,9 +146,10 @@ class S3Adapter { const candidate = this._generateKey(filename, contentType, options); key_without_prefix = candidate && typeof candidate.then === 'function' ? await candidate : candidate; - if (typeof key_without_prefix !== 'string' || key_without_prefix.length === 0) { + if (typeof key_without_prefix !== 'string' || key_without_prefix.trim().length === 0) { throw new Error('generateKey must return a non-empty string'); } + key_without_prefix = key_without_prefix.trim(); } const params = { @@ -185,8 +186,27 @@ class S3Adapter { await this.createBucket(); const command = new PutObjectCommand(params); await this._s3Client.send(command); - const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`; - const location = `${endpoint}/${params.Key}`; + + let locationBase; + if (this._endpoint) { + try { + const u = new URL(this._endpoint); + const origin = `${u.protocol}//${u.host}`; + const basePath = (u.pathname || '').replace(/\/$/, ''); + const hasBucketInHostOrPath = + u.hostname.startsWith(`${this._bucket}.`) || + basePath.split('/').includes(this._bucket); + const pathWithBucket = hasBucketInHostOrPath ? basePath : `${basePath}/${this._bucket}`; + locationBase = `${origin}${pathWithBucket}`; + } catch { + // Fallback for non-URL endpoints (assume hostname) + locationBase = `https://${String(this._endpoint).replace(/\/$/, '')}/${this._bucket}`; + } + } else { + const regionPart = this._region ? `.s3.${this._region}` : '.s3'; + locationBase = `https://${this._bucket}${regionPart}.amazonaws.com`; + } + const location = `${locationBase}/${params.Key}`; let url; if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation diff --git a/spec/test.spec.js b/spec/test.spec.js index c2560e1..d37e5c2 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -894,6 +894,10 @@ describe('S3Adapter tests', () => { { mount: 'http://example.com', applicationId: 'test123' } ); + expect(s3.getFileLocation).toHaveBeenCalledWith( + jasmine.objectContaining({ mount: 'http://example.com', applicationId: 'test123' }), + 'file.txt' + ); expect(result).toEqual({ location: jasmine.any(String), name: 'file.txt', @@ -929,13 +933,14 @@ describe('S3Adapter tests', () => { s3ClientMock.send.and.returnValue(Promise.resolve({})); s3._s3Client = s3ClientMock; - await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); expect(s3ClientMock.send).toHaveBeenCalledTimes(2); const commands = s3ClientMock.send.calls.all(); const commandArg = commands[1].args[0]; expect(commandArg).toBeInstanceOf(PutObjectCommand); expect(commandArg.input.Key).toBe('async-file.txt'); + expect(out.name).toBe('async-file.txt'); }); it('should handle generateKey that returns a Promise', async () => { @@ -949,13 +954,14 @@ describe('S3Adapter tests', () => { s3ClientMock.send.and.returnValue(Promise.resolve({})); s3._s3Client = s3ClientMock; - await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); + const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {}); expect(s3ClientMock.send).toHaveBeenCalledTimes(2); const commands = s3ClientMock.send.calls.all(); const commandArg = commands[1].args[0]; expect(commandArg).toBeInstanceOf(PutObjectCommand); expect(commandArg.input.Key).toBe('promise-file.txt'); + expect(out.name).toBe('promise-file.txt'); }); it('should validate generateKey returns a non-empty string', async () => { @@ -984,6 +990,19 @@ describe('S3Adapter tests', () => { ).toBeRejectedWithError('generateKey must return a non-empty string'); }); + it('should reject when generateKey returns only whitespace', async () => { + const options = { + bucket: 'bucket-1', + generateKey: () => ' ' + }; + const s3 = new S3Adapter(options); + s3._s3Client = s3ClientMock; + + await expectAsync( + s3.createFile('file.txt', 'hello world', 'text/utf8', {}) + ).toBeRejectedWithError('generateKey must return a non-empty string'); + }); + it('should validate async generateKey returns a string', async () => { const options = { bucket: 'bucket-1', @@ -998,6 +1017,88 @@ describe('S3Adapter tests', () => { }); }); + describe('URL construction with custom endpoints', () => { + let s3ClientMock; + + beforeEach(() => { + s3ClientMock = jasmine.createSpyObj('S3Client', ['send']); + s3ClientMock.send.and.returnValue(Promise.resolve({})); + }); + + it('should handle endpoint with path and query correctly', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://example.com:8080/path?foo=bar' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should construct proper URL without breaking query parameters + expect(result.location).toBe('https://example.com:8080/path/test-bucket/test.txt'); + }); + + it('should handle path-style endpoint without bucket in hostname', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://minio.example.com' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should add bucket to path for path-style + expect(result.location).toBe('https://minio.example.com/test-bucket/test.txt'); + }); + + it('should handle virtual-hosted-style endpoint with bucket in hostname', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'https://test-bucket.s3.example.com' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should not duplicate bucket when it's already in hostname + expect(result.location).toBe('https://test-bucket.s3.example.com/test.txt'); + }); + + it('should fallback for malformed endpoint', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + s3overrides: { + endpoint: 'not-a-valid-url' + } + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should fallback to safe construction + expect(result.location).toBe('https://not-a-valid-url/test-bucket/test.txt'); + }); + + it('should use default AWS endpoint when no custom endpoint', async () => { + const s3 = new S3Adapter({ + bucket: 'test-bucket', + region: 'us-west-2' + }); + s3._s3Client = s3ClientMock; + + const result = await s3.createFile('test.txt', 'hello world', 'text/utf8'); + + // Should use standard AWS S3 URL + expect(result.location).toBe('https://test-bucket.s3.us-west-2.amazonaws.com/test.txt'); + }); + }); + describe('handleFileStream', () => { const filename = 'file.txt'; let s3;