583 lines
19 KiB
JavaScript
583 lines
19 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|