Skip to content

Commit 2cac314

Browse files
committed
trim & test key after generation & assmble endpoint accordingly
More tests and changes to make coderabbit happy
1 parent bf88785 commit 2cac314

File tree

2 files changed

+126
-5
lines changed

2 files changed

+126
-5
lines changed

index.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,10 @@ class S3Adapter {
146146
const candidate = this._generateKey(filename, contentType, options);
147147
key_without_prefix =
148148
candidate && typeof candidate.then === 'function' ? await candidate : candidate;
149-
if (typeof key_without_prefix !== 'string' || key_without_prefix.length === 0) {
149+
if (typeof key_without_prefix !== 'string' || key_without_prefix.trim().length === 0) {
150150
throw new Error('generateKey must return a non-empty string');
151151
}
152+
key_without_prefix = key_without_prefix.trim();
152153
}
153154

154155
const params = {
@@ -185,8 +186,27 @@ class S3Adapter {
185186
await this.createBucket();
186187
const command = new PutObjectCommand(params);
187188
await this._s3Client.send(command);
188-
const endpoint = this._endpoint || `https://${this._bucket}.s3.${this._region}.amazonaws.com`;
189-
const location = `${endpoint}/${params.Key}`;
189+
190+
let locationBase;
191+
if (this._endpoint) {
192+
try {
193+
const u = new URL(this._endpoint);
194+
const origin = `${u.protocol}//${u.host}`;
195+
const basePath = (u.pathname || '').replace(/\/$/, '');
196+
const hasBucketInHostOrPath =
197+
u.hostname.startsWith(`${this._bucket}.`) ||
198+
basePath.split('/').includes(this._bucket);
199+
const pathWithBucket = hasBucketInHostOrPath ? basePath : `${basePath}/${this._bucket}`;
200+
locationBase = `${origin}${pathWithBucket}`;
201+
} catch {
202+
// Fallback for non-URL endpoints (assume hostname)
203+
locationBase = `https://${String(this._endpoint).replace(/\/$/, '')}/${this._bucket}`;
204+
}
205+
} else {
206+
const regionPart = this._region ? `.s3.${this._region}` : '.s3';
207+
locationBase = `https://${this._bucket}${regionPart}.amazonaws.com`;
208+
}
209+
const location = `${locationBase}/${params.Key}`;
190210

191211
let url;
192212
if (config?.mount && config?.applicationId) { // if config has required properties for getFileLocation

spec/test.spec.js

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,10 @@ describe('S3Adapter tests', () => {
894894
{ mount: 'http://example.com', applicationId: 'test123' }
895895
);
896896

897+
expect(s3.getFileLocation).toHaveBeenCalledWith(
898+
jasmine.objectContaining({ mount: 'http://example.com', applicationId: 'test123' }),
899+
'file.txt'
900+
);
897901
expect(result).toEqual({
898902
location: jasmine.any(String),
899903
name: 'file.txt',
@@ -929,13 +933,14 @@ describe('S3Adapter tests', () => {
929933
s3ClientMock.send.and.returnValue(Promise.resolve({}));
930934
s3._s3Client = s3ClientMock;
931935

932-
await s3.createFile('file.txt', 'hello world', 'text/utf8', {});
936+
const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {});
933937

934938
expect(s3ClientMock.send).toHaveBeenCalledTimes(2);
935939
const commands = s3ClientMock.send.calls.all();
936940
const commandArg = commands[1].args[0];
937941
expect(commandArg).toBeInstanceOf(PutObjectCommand);
938942
expect(commandArg.input.Key).toBe('async-file.txt');
943+
expect(out.name).toBe('async-file.txt');
939944
});
940945

941946
it('should handle generateKey that returns a Promise', async () => {
@@ -949,13 +954,14 @@ describe('S3Adapter tests', () => {
949954
s3ClientMock.send.and.returnValue(Promise.resolve({}));
950955
s3._s3Client = s3ClientMock;
951956

952-
await s3.createFile('file.txt', 'hello world', 'text/utf8', {});
957+
const out = await s3.createFile('file.txt', 'hello world', 'text/utf8', {});
953958

954959
expect(s3ClientMock.send).toHaveBeenCalledTimes(2);
955960
const commands = s3ClientMock.send.calls.all();
956961
const commandArg = commands[1].args[0];
957962
expect(commandArg).toBeInstanceOf(PutObjectCommand);
958963
expect(commandArg.input.Key).toBe('promise-file.txt');
964+
expect(out.name).toBe('promise-file.txt');
959965
});
960966

961967
it('should validate generateKey returns a non-empty string', async () => {
@@ -984,6 +990,19 @@ describe('S3Adapter tests', () => {
984990
).toBeRejectedWithError('generateKey must return a non-empty string');
985991
});
986992

993+
it('should reject when generateKey returns only whitespace', async () => {
994+
const options = {
995+
bucket: 'bucket-1',
996+
generateKey: () => ' '
997+
};
998+
const s3 = new S3Adapter(options);
999+
s3._s3Client = s3ClientMock;
1000+
1001+
await expectAsync(
1002+
s3.createFile('file.txt', 'hello world', 'text/utf8', {})
1003+
).toBeRejectedWithError('generateKey must return a non-empty string');
1004+
});
1005+
9871006
it('should validate async generateKey returns a string', async () => {
9881007
const options = {
9891008
bucket: 'bucket-1',
@@ -998,6 +1017,88 @@ describe('S3Adapter tests', () => {
9981017
});
9991018
});
10001019

1020+
describe('URL construction with custom endpoints', () => {
1021+
let s3ClientMock;
1022+
1023+
beforeEach(() => {
1024+
s3ClientMock = jasmine.createSpyObj('S3Client', ['send']);
1025+
s3ClientMock.send.and.returnValue(Promise.resolve({}));
1026+
});
1027+
1028+
it('should handle endpoint with path and query correctly', async () => {
1029+
const s3 = new S3Adapter({
1030+
bucket: 'test-bucket',
1031+
s3overrides: {
1032+
endpoint: 'https://example.com:8080/path?foo=bar'
1033+
}
1034+
});
1035+
s3._s3Client = s3ClientMock;
1036+
1037+
const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');
1038+
1039+
// Should construct proper URL without breaking query parameters
1040+
expect(result.location).toBe('https://example.com:8080/path/test-bucket/test.txt');
1041+
});
1042+
1043+
it('should handle path-style endpoint without bucket in hostname', async () => {
1044+
const s3 = new S3Adapter({
1045+
bucket: 'test-bucket',
1046+
s3overrides: {
1047+
endpoint: 'https://minio.example.com'
1048+
}
1049+
});
1050+
s3._s3Client = s3ClientMock;
1051+
1052+
const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');
1053+
1054+
// Should add bucket to path for path-style
1055+
expect(result.location).toBe('https://minio.example.com/test-bucket/test.txt');
1056+
});
1057+
1058+
it('should handle virtual-hosted-style endpoint with bucket in hostname', async () => {
1059+
const s3 = new S3Adapter({
1060+
bucket: 'test-bucket',
1061+
s3overrides: {
1062+
endpoint: 'https://test-bucket.s3.example.com'
1063+
}
1064+
});
1065+
s3._s3Client = s3ClientMock;
1066+
1067+
const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');
1068+
1069+
// Should not duplicate bucket when it's already in hostname
1070+
expect(result.location).toBe('https://test-bucket.s3.example.com/test.txt');
1071+
});
1072+
1073+
it('should fallback for malformed endpoint', async () => {
1074+
const s3 = new S3Adapter({
1075+
bucket: 'test-bucket',
1076+
s3overrides: {
1077+
endpoint: 'not-a-valid-url'
1078+
}
1079+
});
1080+
s3._s3Client = s3ClientMock;
1081+
1082+
const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');
1083+
1084+
// Should fallback to safe construction
1085+
expect(result.location).toBe('https://not-a-valid-url/test-bucket/test.txt');
1086+
});
1087+
1088+
it('should use default AWS endpoint when no custom endpoint', async () => {
1089+
const s3 = new S3Adapter({
1090+
bucket: 'test-bucket',
1091+
region: 'us-west-2'
1092+
});
1093+
s3._s3Client = s3ClientMock;
1094+
1095+
const result = await s3.createFile('test.txt', 'hello world', 'text/utf8');
1096+
1097+
// Should use standard AWS S3 URL
1098+
expect(result.location).toBe('https://test-bucket.s3.us-west-2.amazonaws.com/test.txt');
1099+
});
1100+
});
1101+
10011102
describe('handleFileStream', () => {
10021103
const filename = 'file.txt';
10031104
let s3;

0 commit comments

Comments
 (0)