LibreChat/api/test/services/Files/S3/crud.test.js
Rene Heijdens 5d2b7fa4d5
🪣 fix: Proper Key Extraction from S3 URL (#11241)
*  feat: Enhance S3 URL handling and add comprehensive tests for CRUD operations

* 🔒 fix: Improve S3 URL key extraction with enhanced logging and additional test cases

* chore: removed some duplicate testcases and fixed incorrect apostrophes

* fix: Log error for malformed URLs

* test: Add additional test case for extracting keys from S3 URLs

* fix: Enhance S3 URL key extraction logic and improve error handling with additional test cases

* test: Add test case for stripping bucket from custom endpoint URLs with forcePathStyle enabled

* refactor: Update S3 path style handling and enhance environment configuration for S3-compatible services

* refactor: Remove S3_FORCE_PATH_STYLE dependency and streamline S3 URL key extraction logic

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-02-21 15:07:16 -05:00

845 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('fs');
const fetch = require('node-fetch');
const { Readable } = require('stream');
const { FileSources } = require('librechat-data-provider');
const {
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
// Mock dependencies
jest.mock('fs');
jest.mock('node-fetch');
jest.mock('@aws-sdk/s3-request-presigner');
jest.mock('@aws-sdk/client-s3');
jest.mock('@librechat/api', () => ({
initializeS3: jest.fn(),
deleteRagFile: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
const { initializeS3, deleteRagFile } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
// Set env vars before requiring crud so module-level constants pick them up
process.env.AWS_BUCKET_NAME = 'test-bucket';
process.env.S3_URL_EXPIRY_SECONDS = '120';
const {
saveBufferToS3,
saveURLToS3,
getS3URL,
deleteFileFromS3,
uploadFileToS3,
getS3FileStream,
refreshS3FileUrls,
refreshS3Url,
needsRefresh,
getNewS3URL,
extractKeyFromS3Url,
} = require('~/server/services/Files/S3/crud');
describe('S3 CRUD Operations', () => {
let mockS3Client;
beforeEach(() => {
jest.clearAllMocks();
// Setup mock S3 client
mockS3Client = {
send: jest.fn(),
};
initializeS3.mockReturnValue(mockS3Client);
});
afterEach(() => {
delete process.env.S3_URL_EXPIRY_SECONDS;
delete process.env.S3_REFRESH_EXPIRY_MS;
delete process.env.AWS_BUCKET_NAME;
});
describe('saveBufferToS3', () => {
it('should upload a buffer to S3 and return a signed URL', async () => {
const mockBuffer = Buffer.from('test data');
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc';
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
basePath: 'images',
});
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand));
expect(result).toBe(mockSignedUrl);
});
it('should use default basePath if not provided', async () => {
const mockBuffer = Buffer.from('test data');
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc';
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
await saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should handle S3 upload errors', async () => {
const mockBuffer = Buffer.from('test data');
const error = new Error('S3 upload failed');
mockS3Client.send.mockRejectedValue(error);
await expect(
saveBufferToS3({
userId: 'user123',
buffer: mockBuffer,
fileName: 'test.jpg',
}),
).rejects.toThrow('S3 upload failed');
expect(logger.error).toHaveBeenCalledWith(
'[saveBufferToS3] Error uploading buffer to S3:',
'S3 upload failed',
);
});
});
describe('getS3URL', () => {
it('should return a signed URL for a file', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
basePath: 'documents',
});
expect(result).toBe(mockSignedUrl);
expect(getSignedUrl).toHaveBeenCalledWith(
mockS3Client,
expect.any(GetObjectCommand),
expect.objectContaining({ expiresIn: 120 }),
);
});
it('should add custom filename to Content-Disposition header', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
customFilename: 'custom-name.pdf',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should add custom content type', async () => {
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz';
getSignedUrl.mockResolvedValue(mockSignedUrl);
await getS3URL({
userId: 'user123',
fileName: 'file.pdf',
contentType: 'application/pdf',
});
expect(getSignedUrl).toHaveBeenCalled();
});
it('should handle errors when getting signed URL', async () => {
const error = new Error('Failed to sign URL');
getSignedUrl.mockRejectedValue(error);
await expect(
getS3URL({
userId: 'user123',
fileName: 'file.pdf',
}),
).rejects.toThrow('Failed to sign URL');
expect(logger.error).toHaveBeenCalledWith(
'[getS3URL] Error getting signed URL from S3:',
'Failed to sign URL',
);
});
});
describe('saveURLToS3', () => {
it('should fetch a file from URL and save to S3', async () => {
const mockBuffer = Buffer.from('downloaded data');
const mockResponse = {
buffer: jest.fn().mockResolvedValue(mockBuffer),
};
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/downloaded.jpg?signature=abc';
fetch.mockResolvedValue(mockResponse);
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await saveURLToS3({
userId: 'user123',
URL: 'https://example.com/image.jpg',
fileName: 'downloaded.jpg',
});
expect(fetch).toHaveBeenCalledWith('https://example.com/image.jpg');
expect(mockS3Client.send).toHaveBeenCalled();
expect(result).toBe(mockSignedUrl);
});
it('should handle fetch errors', async () => {
const error = new Error('Network error');
fetch.mockRejectedValue(error);
await expect(
saveURLToS3({
userId: 'user123',
URL: 'https://example.com/image.jpg',
fileName: 'downloaded.jpg',
}),
).rejects.toThrow('Network error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('deleteFileFromS3', () => {
const mockReq = {
user: { id: 'user123' },
};
it('should delete a file from S3', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg',
file_id: 'file123',
};
// Mock HeadObject to verify file exists
mockS3Client.send
.mockResolvedValueOnce({}) // First HeadObject - exists
.mockResolvedValueOnce({}) // DeleteObject
.mockRejectedValueOnce({ name: 'NotFound' }); // Second HeadObject - deleted
await deleteFileFromS3(mockReq, mockFile);
expect(deleteRagFile).toHaveBeenCalledWith({ userId: 'user123', file: mockFile });
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(HeadObjectCommand));
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(DeleteObjectCommand));
});
it('should handle file not found gracefully', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/nonexistent.jpg',
file_id: 'file123',
};
mockS3Client.send.mockRejectedValue({ name: 'NotFound' });
await deleteFileFromS3(mockReq, mockFile);
expect(logger.warn).toHaveBeenCalled();
});
it('should throw error if user ID does not match', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/different-user/file.jpg',
file_id: 'file123',
};
await expect(deleteFileFromS3(mockReq, mockFile)).rejects.toThrow('User ID mismatch');
expect(logger.error).toHaveBeenCalled();
});
it('should handle NoSuchKey error', async () => {
const mockFile = {
filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg',
file_id: 'file123',
};
mockS3Client.send
.mockResolvedValueOnce({}) // HeadObject - exists
.mockRejectedValueOnce({ code: 'NoSuchKey' }); // DeleteObject fails
await deleteFileFromS3(mockReq, mockFile);
expect(logger.debug).toHaveBeenCalled();
});
});
describe('uploadFileToS3', () => {
const mockReq = {
user: { id: 'user123' },
};
it('should upload a file from disk to S3', async () => {
const mockFile = {
path: '/tmp/upload.jpg',
originalname: 'photo.jpg',
};
const mockStats = { size: 1024 };
const mockSignedUrl =
'https://s3.amazonaws.com/test-bucket/images/user123/file123__photo.jpg?signature=xyz';
fs.promises = { stat: jest.fn().mockResolvedValue(mockStats) };
fs.createReadStream = jest.fn().mockReturnValue(new Readable());
mockS3Client.send.mockResolvedValue({});
getSignedUrl.mockResolvedValue(mockSignedUrl);
const result = await uploadFileToS3({
req: mockReq,
file: mockFile,
file_id: 'file123',
basePath: 'images',
});
expect(result).toEqual({
filepath: mockSignedUrl,
bytes: 1024,
});
expect(fs.createReadStream).toHaveBeenCalledWith('/tmp/upload.jpg');
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand));
});
it('should handle upload errors and clean up temp file', async () => {
const mockFile = {
path: '/tmp/upload.jpg',
originalname: 'photo.jpg',
};
const error = new Error('Upload failed');
fs.promises = {
stat: jest.fn().mockResolvedValue({ size: 1024 }),
unlink: jest.fn().mockResolvedValue(),
};
fs.createReadStream = jest.fn().mockReturnValue(new Readable());
mockS3Client.send.mockRejectedValue(error);
await expect(
uploadFileToS3({
req: mockReq,
file: mockFile,
file_id: 'file123',
}),
).rejects.toThrow('Upload failed');
expect(logger.error).toHaveBeenCalledWith(
'[uploadFileToS3] Error streaming file to S3:',
error,
);
});
});
describe('getS3FileStream', () => {
it('should return a readable stream for a file', async () => {
const mockStream = new Readable();
const mockResponse = { Body: mockStream };
mockS3Client.send.mockResolvedValue(mockResponse);
const result = await getS3FileStream(
{},
'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf',
);
expect(result).toBe(mockStream);
expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(GetObjectCommand));
});
it('should handle errors when retrieving stream', async () => {
const error = new Error('Stream error');
mockS3Client.send.mockRejectedValue(error);
await expect(getS3FileStream({}, 'images/user123/file.pdf')).rejects.toThrow('Stream error');
expect(logger.error).toHaveBeenCalled();
});
});
describe('needsRefresh', () => {
it('should return false for non-signed URLs', () => {
const url = 'https://example.com/proxy/file.jpg';
const result = needsRefresh(url, 3600);
expect(result).toBe(false);
});
it('should return true for expired signed URLs', () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000); // 1 hour ago
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`;
const result = needsRefresh(url, 60);
expect(result).toBe(true);
});
it('should return false for URLs that are not close to expiration', () => {
const now = new Date();
const recent = new Date(now.getTime() - 10 * 1000); // 10 seconds ago
const dateStr = recent
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`;
const result = needsRefresh(url, 60);
expect(result).toBe(false);
});
it('should use custom refresh expiry when S3_REFRESH_EXPIRY_MS is set', () => {
process.env.S3_REFRESH_EXPIRY_MS = '30000'; // 30 seconds
const now = new Date();
const recent = new Date(now.getTime() - 31 * 1000); // 31 seconds ago
const dateStr = recent
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`;
// Need to reload the module to pick up the env var change
jest.resetModules();
const { needsRefresh: needsRefreshReloaded } = require('~/server/services/Files/S3/crud');
const result = needsRefreshReloaded(url, 60);
expect(result).toBe(true);
});
it('should return true for malformed URLs', () => {
const url = 'not-a-valid-url';
const result = needsRefresh(url, 3600);
expect(result).toBe(true);
});
});
describe('getNewS3URL', () => {
it('should generate a new URL from an existing S3 URL', async () => {
const currentURL =
'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=old';
const newURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new';
getSignedUrl.mockResolvedValue(newURL);
const result = await getNewS3URL(currentURL);
expect(result).toBe(newURL);
expect(getSignedUrl).toHaveBeenCalled();
});
it('should return undefined for invalid URLs', async () => {
const result = await getNewS3URL('invalid-url');
expect(result).toBeUndefined();
});
it('should handle errors gracefully', async () => {
const currentURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg';
getSignedUrl.mockRejectedValue(new Error('Failed'));
const result = await getNewS3URL(currentURL);
expect(result).toBeUndefined();
expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error));
});
it('should construct GetObjectCommand with correct key (no bucket name duplication)', async () => {
const currentURL =
'https://s3.amazonaws.com/my-bucket/images/user123/file.jpg?X-Amz-Signature=old';
getSignedUrl.mockResolvedValue(
'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new',
);
await getNewS3URL(currentURL);
expect(GetObjectCommand).toHaveBeenCalledWith(
expect.objectContaining({ Key: 'images/user123/file.jpg' }),
);
});
});
describe('refreshS3FileUrls', () => {
it('should refresh expired URLs for multiple files', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const files = [
{
file_id: 'file1',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
{
file_id: 'file2',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file2.jpg?X-Amz-Signature=def&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
];
const newURL1 = 'https://s3.amazonaws.com/bucket/images/user123/file1.jpg?signature=new1';
const newURL2 = 'https://s3.amazonaws.com/bucket/images/user123/file2.jpg?signature=new2';
getSignedUrl.mockResolvedValueOnce(newURL1).mockResolvedValueOnce(newURL2);
const mockBatchUpdate = jest.fn().mockResolvedValue();
const result = await refreshS3FileUrls(files, mockBatchUpdate, 60);
expect(result[0].filepath).toBe(newURL1);
expect(result[1].filepath).toBe(newURL2);
expect(mockBatchUpdate).toHaveBeenCalledWith([
{ file_id: 'file1', filepath: newURL1 },
{ file_id: 'file2', filepath: newURL2 },
]);
});
it('should skip non-S3 files', async () => {
const files = [
{
file_id: 'file1',
source: 'local',
filepath: '/local/path/file.jpg',
},
];
const mockBatchUpdate = jest.fn();
const result = await refreshS3FileUrls(files, mockBatchUpdate);
expect(result).toEqual(files);
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
it('should handle empty or invalid input', async () => {
const mockBatchUpdate = jest.fn();
const result1 = await refreshS3FileUrls(null, mockBatchUpdate);
expect(result1).toBe(null);
const result2 = await refreshS3FileUrls([], mockBatchUpdate);
expect(result2).toEqual([]);
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
it('should handle errors for individual files gracefully', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const files = [
{
file_id: 'file1',
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
},
];
getSignedUrl.mockRejectedValue(new Error('Failed to refresh'));
const mockBatchUpdate = jest.fn();
await refreshS3FileUrls(files, mockBatchUpdate, 60);
expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error));
expect(mockBatchUpdate).not.toHaveBeenCalled();
});
});
describe('refreshS3Url', () => {
it('should refresh an expired S3 URL', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const fileObj = {
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
};
const newURL = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg?signature=new';
getSignedUrl.mockResolvedValue(newURL);
const result = await refreshS3Url(fileObj, 60);
expect(result).toBe(newURL);
});
it('should return original URL if not expired', async () => {
const fileObj = {
source: FileSources.s3,
filepath: 'https://example.com/proxy/file.jpg',
};
const result = await refreshS3Url(fileObj, 3600);
expect(result).toBe(fileObj.filepath);
expect(getSignedUrl).not.toHaveBeenCalled();
});
it('should return empty string for null input', async () => {
const result = await refreshS3Url(null);
expect(result).toBe('');
});
it('should return original URL for non-S3 files', async () => {
const fileObj = {
source: 'local',
filepath: '/local/path/file.jpg',
};
const result = await refreshS3Url(fileObj);
expect(result).toBe(fileObj.filepath);
});
it('should handle errors and return original URL', async () => {
const now = new Date();
const past = new Date(now.getTime() - 3600 * 1000);
const dateStr = past
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const fileObj = {
source: FileSources.s3,
filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`,
};
getSignedUrl.mockRejectedValue(new Error('Refresh failed'));
const result = await refreshS3Url(fileObj, 60);
expect(result).toBe(fileObj.filepath);
expect(logger.error).toHaveBeenCalled();
});
});
describe('extractKeyFromS3Url', () => {
it('should extract key from a full S3 URL', () => {
const url = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('images/user123/file.jpg');
});
it('should extract key from a signed S3 URL with query parameters', () => {
const url =
'https://s3.amazonaws.com/test-bucket/documents/user456/report.pdf?X-Amz-Signature=abc123&X-Amz-Date=20260107';
const result = extractKeyFromS3Url(url);
expect(result).toBe('documents/user456/report.pdf');
});
it('should extract key from S3 URL with different domain format', () => {
const url = 'https://test-bucket.s3.amazonaws.com/uploads/user789/image.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('uploads/user789/image.png');
});
it('should return key as-is if already properly formatted (3+ parts, no http)', () => {
const key = 'images/user123/file.jpg';
const result = extractKeyFromS3Url(key);
expect(result).toBe('images/user123/file.jpg');
});
it('should handle key with leading slash by removing it', () => {
const key = '/images/user123/file.jpg';
const result = extractKeyFromS3Url(key);
expect(result).toBe('images/user123/file.jpg');
});
it('should handle simple key without slashes', () => {
const key = 'simple-file.txt';
const result = extractKeyFromS3Url(key);
expect(result).toBe('simple-file.txt');
});
it('should handle key with only two parts', () => {
const key = 'folder/file.txt';
const result = extractKeyFromS3Url(key);
expect(result).toBe('folder/file.txt');
});
it('should throw error for empty input', () => {
expect(() => extractKeyFromS3Url('')).toThrow('Invalid input: URL or key is empty');
});
it('should throw error for null input', () => {
expect(() => extractKeyFromS3Url(null)).toThrow('Invalid input: URL or key is empty');
});
it('should throw error for undefined input', () => {
expect(() => extractKeyFromS3Url(undefined)).toThrow('Invalid input: URL or key is empty');
});
it('should handle URLs with encoded characters', () => {
const url = 'https://s3.amazonaws.com/test-bucket/images/user123/my%20file%20name.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('images/user123/my%20file%20name.jpg');
});
it('should handle deep nested paths', () => {
const url = 'https://s3.amazonaws.com/bucket/a/b/c/d/e/f/file.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('a/b/c/d/e/f/file.jpg');
});
it('should log debug message when extracting from URL', () => {
const url = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg';
extractKeyFromS3Url(url);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] fileUrlOrKey:'),
);
});
it('should log fallback debug message for non-URL input', () => {
const key = 'simple-file.txt';
extractKeyFromS3Url(key);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] FALLBACK'),
);
});
it('should handle valid URLs that contain only a bucket', () => {
const url = 'https://s3.amazonaws.com/test-bucket/';
const result = extractKeyFromS3Url(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: https://s3.amazonaws.com/test-bucket/',
),
);
expect(result).toBe('');
});
it('should handle invalid URLs that contain only a bucket', () => {
const url = 'https://s3.amazonaws.com/test-bucket';
const result = extractKeyFromS3Url(url);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'[extractKeyFromS3Url] Unable to extract key from path-style URL: https://s3.amazonaws.com/test-bucket',
),
);
expect(result).toBe('');
});
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html
// Path-style requests
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access
// https://s3.region-code.amazonaws.com/bucket-name/key-name
it('should handle formatted according to Path-style regional endpoint', () => {
const url = 'https://s3.us-west-2.amazonaws.com/amzn-s3-demo-bucket1/dogs/puppy.jpg';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.jpg');
});
// virtual host style
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access
// https://bucket-name.s3.region-code.amazonaws.com/key-name
it('should handle formatted according to Virtual-hostedstyle Regional endpoint', () => {
const url = 'https://amzn-s3-demo-bucket1.s3.us-west-2.amazonaws.com/dogs/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.png');
});
// Legacy endpoints
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingBackwardsCompatibility
// s3Region
// https://bucket-name.s3-region-code.amazonaws.com
it('should handle formatted according to s3Region', () => {
const url = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('puppy.png');
const testcase2 = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/cats/kitten.png';
const result2 = extractKeyFromS3Url(testcase2);
expect(result2).toBe('cats/kitten.png');
});
// Legacy global endpoint
// bucket-name.s3.amazonaws.com
it('should handle formatted according to Legacy global endpoint', () => {
const url = 'https://amzn-s3-demo-bucket1.s3.amazonaws.com/dogs/puppy.png';
const result = extractKeyFromS3Url(url);
expect(result).toBe('dogs/puppy.png');
});
it('should handle malformed URL and log error', () => {
const malformedUrl = 'https://invalid url with spaces.com/key';
const result = extractKeyFromS3Url(malformedUrl);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] Error parsing URL:'),
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(malformedUrl));
expect(result).toBe(malformedUrl);
});
it('should return empty string for regional path-style URL with only bucket (no key)', () => {
const url = 'https://s3.us-west-2.amazonaws.com/my-bucket';
const result = extractKeyFromS3Url(url);
expect(result).toBe('');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[extractKeyFromS3Url] Unable to extract key from path-style URL:'),
);
});
it('should not log error when given a plain S3 key (non-URL input)', () => {
extractKeyFromS3Url('images/user123/file.jpg');
expect(logger.error).not.toHaveBeenCalled();
});
it('should strip bucket from custom endpoint URLs (MinIO, R2, etc.) using bucketName', () => {
// bucketName is the module-level const 'test-bucket', set before require at top of file
expect(
extractKeyFromS3Url('https://minio.example.com/test-bucket/images/user123/file.jpg'),
).toBe('images/user123/file.jpg');
expect(
extractKeyFromS3Url(
'https://abc123.r2.cloudflarestorage.com/test-bucket/images/user123/avatar.png',
),
).toBe('images/user123/avatar.png');
});
});
});