| import type { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types'; | |
| import type { Request, Response } from 'express'; | |
| import { mock } from 'jest-mock-extended'; | |
| import { JsonWebTokenError } from 'jsonwebtoken'; | |
| import type { BinaryDataService } from 'n8n-core'; | |
| import { FileNotFoundError } from 'n8n-core'; | |
| import type { Readable } from 'node:stream'; | |
| import { BinaryDataController } from '../binary-data.controller'; | |
| describe('BinaryDataController', () => { | |
| const request = mock<Request>(); | |
| const response = mock<Response>(); | |
| const binaryDataService = mock<BinaryDataService>(); | |
| const controller = new BinaryDataController(binaryDataService); | |
| beforeEach(() => { | |
| jest.resetAllMocks(); | |
| response.status.mockReturnThis(); | |
| }); | |
| describe('get', () => { | |
| it('should return 400 if binary data mode is missing', async () => { | |
| const query = { id: '123', action: 'view' } as BinaryDataQueryDto; | |
| await controller.get(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Malformed binary data ID'); | |
| }); | |
| it('should return 400 if binary data mode is invalid', async () => { | |
| const query = { id: 'invalidMode:123', action: 'view' } as BinaryDataQueryDto; | |
| await controller.get(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Invalid binary data mode'); | |
| }); | |
| it('should return 404 if file is not found', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'view', | |
| mimeType: 'image/jpeg', | |
| } as BinaryDataQueryDto; | |
| binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found')); | |
| await controller.get(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(404); | |
| expect(response.end).toHaveBeenCalled(); | |
| }); | |
| it('should set Content-Type and Content-Length from query if provided', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'view', | |
| fileName: 'test.txt', | |
| mimeType: 'text/plain', | |
| } as BinaryDataQueryDto; | |
| binaryDataService.getAsStream.mockResolvedValue(mock()); | |
| await controller.get(request, response, query); | |
| expect(binaryDataService.getMetadata).toHaveBeenCalledWith(query.id); | |
| expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); | |
| expect(response.setHeader).not.toHaveBeenCalledWith('Content-Length'); | |
| expect(response.setHeader).not.toHaveBeenCalledWith('Content-Disposition'); | |
| }); | |
| it('should set Content-Type and Content-Length from metadata if not provided', async () => { | |
| const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto; | |
| binaryDataService.getMetadata.mockResolvedValue({ | |
| fileName: 'test.txt', | |
| mimeType: 'text/plain', | |
| fileSize: 100, | |
| }); | |
| binaryDataService.getAsStream.mockResolvedValue(mock()); | |
| await controller.get(request, response, query); | |
| expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123'); | |
| expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); | |
| expect(response.setHeader).toHaveBeenCalledWith('Content-Length', 100); | |
| expect(response.setHeader).not.toHaveBeenCalledWith('Content-Disposition'); | |
| }); | |
| it('should set Content-Disposition for download action', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'download', | |
| fileName: 'test.txt', | |
| } as BinaryDataQueryDto; | |
| binaryDataService.getAsStream.mockResolvedValue(mock()); | |
| await controller.get(request, response, query); | |
| expect(response.setHeader).toHaveBeenCalledWith( | |
| 'Content-Disposition', | |
| 'attachment; filename="test.txt"', | |
| ); | |
| }); | |
| it('should not allow viewing of html files', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'view', | |
| fileName: 'test.html', | |
| mimeType: 'text/html', | |
| } as BinaryDataQueryDto; | |
| binaryDataService.getAsStream.mockResolvedValue(mock()); | |
| await controller.get(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.setHeader).not.toHaveBeenCalled(); | |
| }); | |
| it('should allow viewing of jpeg files, and handle mime-type casing', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'view', | |
| fileName: 'test.jpg', | |
| mimeType: 'image/Jpeg', | |
| } as BinaryDataQueryDto; | |
| binaryDataService.getAsStream.mockResolvedValue(mock()); | |
| await controller.get(request, response, query); | |
| expect(response.status).not.toHaveBeenCalledWith(400); | |
| expect(response.setHeader).toHaveBeenCalledWith('Content-Type', query.mimeType); | |
| }); | |
| it('should return the file stream on success', async () => { | |
| const query = { | |
| id: 'filesystem:123', | |
| action: 'view', | |
| mimeType: 'image/jpeg', | |
| } as BinaryDataQueryDto; | |
| const stream = mock<Readable>(); | |
| binaryDataService.getAsStream.mockResolvedValue(stream); | |
| const result = await controller.get(request, response, query); | |
| expect(result).toBe(stream); | |
| expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123'); | |
| }); | |
| describe('with malicious binary data IDs', () => { | |
| it.each([ | |
| ['filesystem:'], | |
| ['filesystem-v2:'], | |
| ['filesystem:/'], | |
| ['filesystem-v2:/'], | |
| ['filesystem://'], | |
| ['filesystem-v2://'], | |
| ])('should return 400 for ID "%s"', async (maliciousId) => { | |
| const query = { id: maliciousId, action: 'download' } as BinaryDataQueryDto; | |
| await controller.get(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Malformed binary data ID'); | |
| }); | |
| }); | |
| }); | |
| describe('getSigned', () => { | |
| const query = { token: '12344' } as BinaryDataSignedQueryDto; | |
| it('should return 400 if binary data mode is missing', async () => { | |
| binaryDataService.validateSignedToken.mockReturnValueOnce('123'); | |
| await controller.getSigned(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Malformed binary data ID'); | |
| }); | |
| it('should return 400 if binary data mode is invalid', async () => { | |
| binaryDataService.validateSignedToken.mockReturnValueOnce('invalid:123'); | |
| await controller.getSigned(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Invalid binary data mode'); | |
| }); | |
| it('should return 400 if token is invalid', async () => { | |
| binaryDataService.validateSignedToken.mockImplementation(() => { | |
| throw new JsonWebTokenError('Invalid token'); | |
| }); | |
| await controller.getSigned(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(400); | |
| expect(response.end).toHaveBeenCalledWith('Invalid token'); | |
| }); | |
| it('should return 404 if file is not found', async () => { | |
| binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123'); | |
| binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found')); | |
| await controller.getSigned(request, response, query); | |
| expect(response.status).toHaveBeenCalledWith(404); | |
| expect(response.end).toHaveBeenCalled(); | |
| }); | |
| it('should return the file stream on a valid signed token', async () => { | |
| binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123'); | |
| const stream = mock<Readable>(); | |
| binaryDataService.getAsStream.mockResolvedValue(stream); | |
| const result = await controller.getSigned(request, response, query); | |
| expect(result).toBe(stream); | |
| expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123'); | |
| }); | |
| }); | |
| }); | |