diff --git a/packages/nodes-base/nodes/S3/GenericFunctions.ts b/packages/nodes-base/nodes/S3/GenericFunctions.ts index 34fe6242e0..38f38ddbc9 100644 --- a/packages/nodes-base/nodes/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/S3/GenericFunctions.ts @@ -51,7 +51,7 @@ export async function s3ApiRequest( } } - endpoint.pathname = path; + endpoint.pathname = `${endpoint.pathname === '/' ? '' : endpoint.pathname}${path}`; // Sign AWS API request with the user credentials const signOpts = { @@ -59,7 +59,7 @@ export async function s3ApiRequest( region: region || credentials.region, host: endpoint.host, method, - path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, + path: `${endpoint.pathname}?${queryToString(query).replace(/\+/g, '%2B')}`, service: 's3', body, } as Request; diff --git a/packages/nodes-base/nodes/S3/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/S3/__tests__/GenericFunctions.test.ts new file mode 100644 index 0000000000..98463d5e2d --- /dev/null +++ b/packages/nodes-base/nodes/S3/__tests__/GenericFunctions.test.ts @@ -0,0 +1,164 @@ +import { sign } from 'aws4'; +import { parseString } from 'xml2js'; + +import { + s3ApiRequest, + s3ApiRequestREST, + s3ApiRequestSOAP, + s3ApiRequestSOAPAllItems, +} from '../GenericFunctions'; + +jest.mock('aws4'); +jest.mock('xml2js'); + +describe('S3 Node Generic Functions', () => { + let mockContext: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + getNode: jest.fn().mockReturnValue({ name: 'S3' }), + getCredentials: jest.fn().mockResolvedValue({ + endpoint: 'https://s3.amazonaws.com', + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + region: 'us-east-1', + }), + helpers: { + request: jest.fn(), + }, + }; + }); + + describe('s3ApiRequest', () => { + it('should throw error if endpoint does not start with http', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + endpoint: 'invalid-endpoint', + }); + + await expect(s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/')).rejects.toThrow( + 'HTTP(S) Scheme is required', + ); + }); + + it('should handle force path style', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + endpoint: 'https://s3.amazonaws.com', + forcePathStyle: true, + }); + + mockContext.helpers.request.mockResolvedValueOnce('success'); + + await s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/test.txt'); + + expect(sign).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/test-bucket/test.txt?', + }), + expect.any(Object), + ); + }); + + it('should handle supabase url', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + endpoint: 'https://someurl.supabase.co/storage/v1/s3', + region: 'eu-west-2', + forcePathStyle: true, + }); + + mockContext.helpers.request.mockResolvedValueOnce('success'); + + await s3ApiRequest.call(mockContext, 'test-bucket', 'GET', '/test.txt'); + + expect(sign).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/storage/v1/s3/test-bucket/test.txt?', + }), + expect.any(Object), + ); + }); + }); + + describe('s3ApiRequestREST', () => { + it('should parse JSON response', async () => { + const mockResponse = JSON.stringify({ key: 'value' }); + mockContext.helpers.request.mockResolvedValueOnce(mockResponse); + + const result = await s3ApiRequestREST.call(mockContext, 'test-bucket', 'GET', '/'); + + expect(result).toEqual({ key: 'value' }); + }); + + it('should return raw response on parse error', async () => { + const mockResponse = 'invalid-json'; + mockContext.helpers.request.mockResolvedValueOnce(mockResponse); + + const result = await s3ApiRequestREST.call(mockContext, 'test-bucket', 'GET', '/'); + + expect(result).toBe('invalid-json'); + }); + }); + + describe('s3ApiRequestSOAP', () => { + it('should parse XML response', async () => { + const mockXmlResponse = 'value'; + const mockParsedResponse = { root: { key: 'value' } }; + + mockContext.helpers.request.mockResolvedValueOnce(mockXmlResponse); + (parseString as jest.Mock).mockImplementation((_, __, callback) => + callback(null, mockParsedResponse), + ); + + const result = await s3ApiRequestSOAP.call(mockContext, 'test-bucket', 'GET', '/'); + + expect(result).toEqual(mockParsedResponse); + }); + + it('should handle XML parsing errors', async () => { + const mockError = new Error('XML Parse Error'); + mockContext.helpers.request.mockResolvedValueOnce('xml'); + (parseString as jest.Mock).mockImplementation((_, __, callback) => callback(mockError)); + + const result = await s3ApiRequestSOAP.call(mockContext, 'test-bucket', 'GET', '/'); + + expect(result).toEqual(mockError); + }); + }); + + describe('s3ApiRequestSOAPAllItems', () => { + it('should handle pagination with continuation token', async () => { + const firstResponse = { + ListBucketResult: { + Contents: [{ Key: 'file1.txt' }], + IsTruncated: 'true', + NextContinuationToken: 'token123', + }, + }; + const secondResponse = { + ListBucketResult: { + Contents: [{ Key: 'file2.txt' }], + IsTruncated: 'false', + }, + }; + + mockContext.helpers.request + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + (parseString as jest.Mock) + .mockImplementationOnce((_, __, callback) => callback(null, firstResponse)) + .mockImplementationOnce((_, __, callback) => callback(null, secondResponse)); + + const result = await s3ApiRequestSOAPAllItems.call( + mockContext, + 'ListBucketResult.Contents', + 'test-bucket', + 'GET', + '/', + ); + + expect(result).toHaveLength(2); + expect(result).toEqual([{ Key: 'file1.txt' }, { Key: 'file2.txt' }]); + }); + }); +});