const request = require('supertest'); const express = require('express'); // Mock s3Service const mockGetPresignedUploadUrl = jest.fn(); const mockVerifyUpload = jest.fn(); const mockGetPresignedDownloadUrl = jest.fn(); const mockIsEnabled = jest.fn(); jest.mock('../../../services/s3Service', () => ({ isEnabled: mockIsEnabled, getPresignedUploadUrl: mockGetPresignedUploadUrl, verifyUpload: mockVerifyUpload, getPresignedDownloadUrl: mockGetPresignedDownloadUrl })); // Mock S3OwnershipService const mockCanAccessFile = jest.fn(); jest.mock('../../../services/s3OwnershipService', () => ({ canAccessFile: mockCanAccessFile })); // Mock auth middleware jest.mock('../../../middleware/auth', () => ({ authenticateToken: (req, res, next) => { if (req.headers.authorization === 'Bearer valid-token') { req.user = { id: 'user-123' }; next(); } else { res.status(401).json({ error: 'No token provided' }); } } })); // Mock rate limiter jest.mock('../../../middleware/rateLimiter', () => ({ uploadPresignLimiter: (req, res, next) => next() })); // Mock logger jest.mock('../../../utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); const uploadRoutes = require('../../../routes/upload'); // Set up Express app for testing const app = express(); app.use(express.json()); app.use('/upload', uploadRoutes); // Error handler app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); describe('Upload Routes', () => { beforeEach(() => { jest.clearAllMocks(); mockIsEnabled.mockReturnValue(true); }); describe('POST /upload/presign', () => { const validRequest = { uploadType: 'item', contentType: 'image/jpeg', fileName: 'photo.jpg', fileSize: 1024 * 1024 }; const mockPresignResponse = { uploadUrl: 'https://presigned-url.s3.amazonaws.com', key: 'items/uuid.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg', expiresAt: new Date() }; it('should return presigned URL for valid request', async () => { mockGetPresignedUploadUrl.mockResolvedValue(mockPresignResponse); const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send(validRequest); expect(response.status).toBe(200); expect(response.body).toEqual(expect.objectContaining({ uploadUrl: mockPresignResponse.uploadUrl, key: mockPresignResponse.key, publicUrl: mockPresignResponse.publicUrl })); expect(mockGetPresignedUploadUrl).toHaveBeenCalledWith( 'item', 'image/jpeg', 'photo.jpg', 1024 * 1024 ); }); it('should require authentication', async () => { const response = await request(app) .post('/upload/presign') .send(validRequest); expect(response.status).toBe(401); }); it('should return 503 when S3 is disabled', async () => { mockIsEnabled.mockReturnValue(false); const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send(validRequest); expect(response.status).toBe(503); expect(response.body.error).toBe('File upload service is not available'); }); it('should return 400 when uploadType is missing', async () => { const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, uploadType: undefined }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 when contentType is missing', async () => { const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, contentType: undefined }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 when fileName is missing', async () => { const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, fileName: undefined }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 when fileSize is missing', async () => { const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, fileSize: undefined }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 for invalid upload type', async () => { mockGetPresignedUploadUrl.mockRejectedValue(new Error('Invalid upload type: invalid')); const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, uploadType: 'invalid' }); expect(response.status).toBe(400); expect(response.body.error).toContain('Invalid'); }); it('should return 400 for invalid content type', async () => { mockGetPresignedUploadUrl.mockRejectedValue(new Error('Invalid content type: application/pdf')); const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, contentType: 'application/pdf' }); expect(response.status).toBe(400); expect(response.body.error).toContain('Invalid'); }); it('should return 400 for file too large', async () => { mockGetPresignedUploadUrl.mockRejectedValue(new Error('Invalid: File too large')); const response = await request(app) .post('/upload/presign') .set('Authorization', 'Bearer valid-token') .send({ ...validRequest, fileSize: 100 * 1024 * 1024 }); expect(response.status).toBe(400); }); }); describe('POST /upload/presign-batch', () => { const validBatchRequest = { uploadType: 'item', files: [ { contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: 1024 }, { contentType: 'image/png', fileName: 'photo2.png', fileSize: 2048 } ] }; const mockPresignResponse = { uploadUrl: 'https://presigned-url.s3.amazonaws.com', key: 'items/uuid.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg', expiresAt: new Date() }; it('should return presigned URLs for multiple files', async () => { mockGetPresignedUploadUrl.mockResolvedValue(mockPresignResponse); const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send(validBatchRequest); expect(response.status).toBe(200); expect(response.body.uploads).toHaveLength(2); expect(mockGetPresignedUploadUrl).toHaveBeenCalledTimes(2); }); it('should require authentication', async () => { const response = await request(app) .post('/upload/presign-batch') .send(validBatchRequest); expect(response.status).toBe(401); }); it('should return 503 when S3 is disabled', async () => { mockIsEnabled.mockReturnValue(false); const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send(validBatchRequest); expect(response.status).toBe(503); }); it('should return 400 when uploadType is missing', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ files: validBatchRequest.files }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 when files is not an array', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: 'not-an-array' }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing required fields'); }); it('should return 400 when files array is empty', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: [] }); expect(response.status).toBe(400); expect(response.body.error).toBe('No files specified'); }); it('should return 400 when exceeding max batch size (20)', async () => { const tooManyFiles = Array(21).fill({ contentType: 'image/jpeg', fileName: 'photo.jpg', fileSize: 1024 }); const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: tooManyFiles }); expect(response.status).toBe(400); expect(response.body.error).toContain('Maximum'); }); it('should return 400 when file is missing contentType', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: [{ fileName: 'photo.jpg', fileSize: 1024 }] }); expect(response.status).toBe(400); expect(response.body.error).toContain('contentType'); }); it('should return 400 when file is missing fileName', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: [{ contentType: 'image/jpeg', fileSize: 1024 }] }); expect(response.status).toBe(400); expect(response.body.error).toContain('fileName'); }); it('should return 400 when file is missing fileSize', async () => { const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: [{ contentType: 'image/jpeg', fileName: 'photo.jpg' }] }); expect(response.status).toBe(400); expect(response.body.error).toContain('fileSize'); }); it('should accept exactly 20 files', async () => { mockGetPresignedUploadUrl.mockResolvedValue(mockPresignResponse); const maxFiles = Array(20).fill({ contentType: 'image/jpeg', fileName: 'photo.jpg', fileSize: 1024 }); const response = await request(app) .post('/upload/presign-batch') .set('Authorization', 'Bearer valid-token') .send({ uploadType: 'item', files: maxFiles }); expect(response.status).toBe(200); expect(response.body.uploads).toHaveLength(20); }); }); describe('POST /upload/confirm', () => { const validConfirmRequest = { keys: ['items/uuid1.jpg', 'items/uuid2.jpg'] }; it('should confirm uploaded files', async () => { mockVerifyUpload.mockResolvedValue(true); const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send(validConfirmRequest); expect(response.status).toBe(200); expect(response.body.confirmed).toEqual(validConfirmRequest.keys); expect(response.body.total).toBe(2); }); it('should return only confirmed keys', async () => { mockVerifyUpload .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send(validConfirmRequest); expect(response.status).toBe(200); expect(response.body.confirmed).toHaveLength(1); expect(response.body.confirmed[0]).toBe('items/uuid1.jpg'); expect(response.body.total).toBe(2); }); it('should require authentication', async () => { const response = await request(app) .post('/upload/confirm') .send(validConfirmRequest); expect(response.status).toBe(401); }); it('should return 503 when S3 is disabled', async () => { mockIsEnabled.mockReturnValue(false); const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send(validConfirmRequest); expect(response.status).toBe(503); }); it('should return 400 when keys is missing', async () => { const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send({}); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing keys array'); }); it('should return 400 when keys is not an array', async () => { const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send({ keys: 'not-an-array' }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing keys array'); }); it('should return 400 when keys array is empty', async () => { const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send({ keys: [] }); expect(response.status).toBe(400); expect(response.body.error).toBe('No keys specified'); }); it('should handle all files not found', async () => { mockVerifyUpload.mockResolvedValue(false); const response = await request(app) .post('/upload/confirm') .set('Authorization', 'Bearer valid-token') .send(validConfirmRequest); expect(response.status).toBe(200); expect(response.body.confirmed).toHaveLength(0); expect(response.body.total).toBe(2); }); }); describe('GET /upload/signed-url/:key(*)', () => { const mockSignedUrl = 'https://bucket.s3.amazonaws.com/messages/uuid.jpg?signature=abc'; beforeEach(() => { mockGetPresignedDownloadUrl.mockResolvedValue(mockSignedUrl); mockCanAccessFile.mockResolvedValue({ authorized: true }); }); it('should return signed URL for authorized private content (messages)', async () => { const response = await request(app) .get('/upload/signed-url/messages/550e8400-e29b-41d4-a716-446655440000.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(200); expect(response.body.url).toBe(mockSignedUrl); expect(response.body.expiresIn).toBe(3600); expect(mockCanAccessFile).toHaveBeenCalledWith( 'messages/550e8400-e29b-41d4-a716-446655440000.jpg', 'user-123' ); expect(mockGetPresignedDownloadUrl).toHaveBeenCalledWith( 'messages/550e8400-e29b-41d4-a716-446655440000.jpg' ); }); it('should return signed URL for authorized condition-check content', async () => { const response = await request(app) .get('/upload/signed-url/condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(200); expect(response.body.url).toBe(mockSignedUrl); expect(mockCanAccessFile).toHaveBeenCalledWith( 'condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg', 'user-123' ); }); it('should require authentication', async () => { const response = await request(app) .get('/upload/signed-url/messages/uuid.jpg'); expect(response.status).toBe(401); expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled(); }); it('should return 503 when S3 is disabled', async () => { mockIsEnabled.mockReturnValue(false); const response = await request(app) .get('/upload/signed-url/messages/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(503); }); it('should return 400 for public folder paths (items)', async () => { const response = await request(app) .get('/upload/signed-url/items/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(400); expect(response.body.error).toBe('Signed URLs only for private content'); expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled(); }); it('should return 400 for public folder paths (profiles)', async () => { const response = await request(app) .get('/upload/signed-url/profiles/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(400); expect(response.body.error).toBe('Signed URLs only for private content'); }); it('should return 400 for public folder paths (forum)', async () => { const response = await request(app) .get('/upload/signed-url/forum/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(400); expect(response.body.error).toBe('Signed URLs only for private content'); }); it('should return 403 when user is not authorized to access file', async () => { mockCanAccessFile.mockResolvedValue({ authorized: false, reason: 'Not a participant in this message' }); const response = await request(app) .get('/upload/signed-url/messages/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(403); expect(response.body.error).toBe('Access denied'); expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled(); }); it('should handle URL-encoded keys', async () => { const response = await request(app) .get('/upload/signed-url/messages%2Fuuid.jpg') .set('Authorization', 'Bearer valid-token'); // The key should be decoded expect(mockCanAccessFile).toHaveBeenCalledWith( expect.stringContaining('messages'), 'user-123' ); }); it('should handle S3 service errors gracefully', async () => { mockGetPresignedDownloadUrl.mockRejectedValue(new Error('S3 error')); const response = await request(app) .get('/upload/signed-url/messages/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(500); }); it('should handle ownership service errors gracefully', async () => { mockCanAccessFile.mockRejectedValue(new Error('Database error')); const response = await request(app) .get('/upload/signed-url/messages/uuid.jpg') .set('Authorization', 'Bearer valid-token'); expect(response.status).toBe(500); }); }); });