BLOCKER-001: Token Refresh Improvements (4 phases) - FASE 1: Rate limiting específico para /auth/refresh (15 req/15min per token) - FASE 2: Token rotation con SHA-256 hash y reuse detection - FASE 3: Session validation con cache de 30s (95% menos queries) - FASE 4: Proactive refresh con X-Token-Expires-At header E2E Tests: Video Upload Module (backend - 91 tests) - Suite 4: Controller tests (22 tests) - REST API endpoints validation - Suite 5: Service tests (29 tests) - Business logic and database operations - Suite 6: Storage tests (35 tests) - S3/R2 multipart upload integration - Suite 7: Full E2E flow (5 tests) - Complete pipeline validation Changes: - auth.middleware.ts: Session validation + token expiry header - rate-limiter.ts: Specific rate limiter for refresh endpoint - token.service.ts: Token rotation logic + session validation - session-cache.service.ts (NEW): 30s TTL cache for session validation - auth.types.ts: Extended types for session validation - auth.routes.ts: Applied refreshTokenRateLimiter - index.ts: Updated CORS to expose X-Token-Expires-At Tests created: - auth-token-refresh.test.ts (15 tests) - E2E token refresh flow - video-controller.test.ts (22 tests) - REST API validation - video-service.test.ts (29 tests) - Business logic validation - storage-service.test.ts (35 tests) - S3/R2 integration - video-upload-flow.test.ts (5 tests) - Complete pipeline Database migration executed: - Added refresh_token_hash and refresh_token_issued_at columns - Created index on refresh_token_hash for performance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
583 lines
17 KiB
TypeScript
583 lines
17 KiB
TypeScript
/**
|
|
* Integration Tests: Storage Service (S3/R2)
|
|
*
|
|
* Epic: OQI-002 - Módulo Educativo
|
|
* Service: storage.service.ts
|
|
*
|
|
* Tests validate:
|
|
* - Multipart upload flow
|
|
* - Presigned URL generation
|
|
* - Object operations (upload, delete, copy, metadata)
|
|
* - S3/R2 API integration
|
|
* - Error handling
|
|
*/
|
|
|
|
import { StorageService, type StorageConfig } from '../../shared/services/storage.service';
|
|
import { S3Client } from '@aws-sdk/client-s3';
|
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
|
// Mock AWS SDK
|
|
jest.mock('@aws-sdk/client-s3');
|
|
jest.mock('@aws-sdk/s3-request-presigner');
|
|
|
|
describe('Integration: Storage Service', () => {
|
|
let service: StorageService;
|
|
let mockS3Client: jest.Mocked<S3Client>;
|
|
|
|
const testConfig: StorageConfig = {
|
|
provider: 's3',
|
|
bucket: 'test-bucket',
|
|
region: 'us-east-1',
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret',
|
|
cdnUrl: 'https://cdn.example.com',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
// Create mock S3 client
|
|
mockS3Client = {
|
|
send: jest.fn(),
|
|
} as any;
|
|
|
|
// Mock S3Client constructor
|
|
(S3Client as jest.Mock).mockImplementation(() => mockS3Client);
|
|
|
|
service = new StorageService(testConfig);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Multipart Upload Flow
|
|
// ==========================================================================
|
|
|
|
describe('Multipart Upload', () => {
|
|
it('should initialize multipart upload', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
UploadId: 'upload-123',
|
|
});
|
|
|
|
const result = await service.initMultipartUpload(
|
|
'videos/test.mp4',
|
|
'video/mp4',
|
|
{ title: 'Test Video', userId: 'user-123' }
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
uploadId: 'upload-123',
|
|
key: 'videos/test.mp4',
|
|
});
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'videos/test.mp4',
|
|
ContentType: 'video/mp4',
|
|
Metadata: { title: 'Test Video', userId: 'user-123' },
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should throw error if uploadId is missing', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
UploadId: undefined, // Missing
|
|
});
|
|
|
|
await expect(service.initMultipartUpload('videos/test.mp4')).rejects.toThrow(
|
|
'Failed to get uploadId from multipart upload init'
|
|
);
|
|
});
|
|
|
|
it('should upload a part', async () => {
|
|
const partData = Buffer.from('video chunk data');
|
|
|
|
mockS3Client.send.mockResolvedValue({
|
|
ETag: '"etag-abc123"',
|
|
});
|
|
|
|
const result = await service.uploadPart('videos/test.mp4', 'upload-123', 1, partData);
|
|
|
|
expect(result).toEqual({
|
|
etag: '"etag-abc123"',
|
|
partNumber: 1,
|
|
});
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'videos/test.mp4',
|
|
UploadId: 'upload-123',
|
|
PartNumber: 1,
|
|
Body: partData,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should throw error if ETag is missing from part upload', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
ETag: undefined,
|
|
});
|
|
|
|
await expect(
|
|
service.uploadPart('videos/test.mp4', 'upload-123', 1, Buffer.from('data'))
|
|
).rejects.toThrow('Failed to get ETag from part upload');
|
|
});
|
|
|
|
it('should complete multipart upload', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
const parts = [
|
|
{ PartNumber: 1, ETag: '"etag-1"' },
|
|
{ PartNumber: 2, ETag: '"etag-2"' },
|
|
];
|
|
|
|
const result = await service.completeMultipartUpload('videos/test.mp4', 'upload-123', parts);
|
|
|
|
expect(result).toEqual({
|
|
key: 'videos/test.mp4',
|
|
url: 'https://cdn.example.com/videos/test.mp4',
|
|
});
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'videos/test.mp4',
|
|
UploadId: 'upload-123',
|
|
MultipartUpload: {
|
|
Parts: parts,
|
|
},
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should abort multipart upload', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
await service.abortMultipartUpload('videos/test.mp4', 'upload-123');
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'videos/test.mp4',
|
|
UploadId: 'upload-123',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle S3 errors in multipart operations', async () => {
|
|
mockS3Client.send.mockRejectedValue(new Error('S3 Access Denied'));
|
|
|
|
await expect(service.initMultipartUpload('videos/test.mp4')).rejects.toThrow(
|
|
'Multipart upload init failed'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Presigned URLs
|
|
// ==========================================================================
|
|
|
|
describe('Presigned URLs', () => {
|
|
it('should generate presigned upload URL', async () => {
|
|
(getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned-upload?signature=abc');
|
|
|
|
const result = await service.getPresignedUploadUrl({
|
|
key: 'videos/test.mp4',
|
|
expiresIn: 3600,
|
|
contentType: 'video/mp4',
|
|
});
|
|
|
|
expect(result).toBe('https://s3.example.com/presigned-upload?signature=abc');
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
mockS3Client,
|
|
expect.anything(),
|
|
{ expiresIn: 3600 }
|
|
);
|
|
});
|
|
|
|
it('should generate presigned download URL', async () => {
|
|
(getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned-download?signature=xyz');
|
|
|
|
const result = await service.getPresignedDownloadUrl('videos/test.mp4', 1800);
|
|
|
|
expect(result).toBe('https://s3.example.com/presigned-download?signature=xyz');
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
mockS3Client,
|
|
expect.anything(),
|
|
{ expiresIn: 1800 }
|
|
);
|
|
});
|
|
|
|
it('should use default expiration (3600s) for presigned URLs', async () => {
|
|
(getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned');
|
|
|
|
await service.getPresignedUploadUrl({ key: 'videos/test.mp4' });
|
|
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
mockS3Client,
|
|
expect.anything(),
|
|
{ expiresIn: 3600 }
|
|
);
|
|
});
|
|
|
|
it('should handle presigned URL generation errors', async () => {
|
|
(getSignedUrl as jest.Mock).mockRejectedValue(new Error('Invalid credentials'));
|
|
|
|
await expect(service.getPresignedUploadUrl({ key: 'videos/test.mp4' })).rejects.toThrow(
|
|
'Presigned URL generation failed'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Simple Upload
|
|
// ==========================================================================
|
|
|
|
describe('Simple Upload', () => {
|
|
it('should upload object with buffer', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
const buffer = Buffer.from('file content');
|
|
|
|
const result = await service.upload({
|
|
key: 'documents/test.pdf',
|
|
body: buffer,
|
|
contentType: 'application/pdf',
|
|
metadata: { userId: 'user-123' },
|
|
acl: 'private',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: 'documents/test.pdf',
|
|
url: 'https://cdn.example.com/documents/test.pdf',
|
|
});
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'documents/test.pdf',
|
|
Body: buffer,
|
|
ContentType: 'application/pdf',
|
|
Metadata: { userId: 'user-123' },
|
|
ACL: 'private',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should default to private ACL', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
await service.upload({
|
|
key: 'test.txt',
|
|
body: 'content',
|
|
});
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ACL: 'private',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle upload errors', async () => {
|
|
mockS3Client.send.mockRejectedValue(new Error('Quota exceeded'));
|
|
|
|
await expect(
|
|
service.upload({ key: 'test.txt', body: 'content' })
|
|
).rejects.toThrow('Storage upload failed');
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Object Operations
|
|
// ==========================================================================
|
|
|
|
describe('Object Operations', () => {
|
|
it('should get object as buffer', async () => {
|
|
const mockStream = {
|
|
[Symbol.asyncIterator]: async function* () {
|
|
yield Buffer.from('chunk1');
|
|
yield Buffer.from('chunk2');
|
|
},
|
|
};
|
|
|
|
mockS3Client.send.mockResolvedValue({
|
|
Body: mockStream,
|
|
});
|
|
|
|
const result = await service.getObject('documents/test.pdf');
|
|
|
|
expect(result).toBeInstanceOf(Buffer);
|
|
expect(result.toString()).toBe('chunk1chunk2');
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'documents/test.pdf',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should throw error if object body is empty', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
Body: undefined,
|
|
});
|
|
|
|
await expect(service.getObject('test.txt')).rejects.toThrow('Empty response body');
|
|
});
|
|
|
|
it('should delete object', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
await service.deleteObject('documents/old.pdf');
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Key: 'documents/old.pdf',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should copy object', async () => {
|
|
mockS3Client.send.mockResolvedValue({});
|
|
|
|
await service.copyObject('source/file.txt', 'destination/file.txt');
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
CopySource: 'test-bucket/source/file.txt',
|
|
Key: 'destination/file.txt',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should get object metadata', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
ContentLength: 1024,
|
|
LastModified: new Date('2026-01-27T10:00:00Z'),
|
|
ContentType: 'application/pdf',
|
|
ETag: '"abc123"',
|
|
Metadata: { userId: 'user-123' },
|
|
});
|
|
|
|
const result = await service.getObjectMetadata('documents/test.pdf');
|
|
|
|
expect(result).toEqual({
|
|
key: 'documents/test.pdf',
|
|
size: 1024,
|
|
lastModified: new Date('2026-01-27T10:00:00Z'),
|
|
contentType: 'application/pdf',
|
|
etag: '"abc123"',
|
|
metadata: { userId: 'user-123' },
|
|
});
|
|
});
|
|
|
|
it('should list objects with prefix', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
Contents: [
|
|
{
|
|
Key: 'videos/test1.mp4',
|
|
Size: 1024000,
|
|
LastModified: new Date('2026-01-27T10:00:00Z'),
|
|
ETag: '"etag1"',
|
|
},
|
|
{
|
|
Key: 'videos/test2.mp4',
|
|
Size: 2048000,
|
|
LastModified: new Date('2026-01-27T11:00:00Z'),
|
|
ETag: '"etag2"',
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.listObjects('videos/', 100);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
key: 'videos/test1.mp4',
|
|
size: 1024000,
|
|
lastModified: new Date('2026-01-27T10:00:00Z'),
|
|
etag: '"etag1"',
|
|
},
|
|
{
|
|
key: 'videos/test2.mp4',
|
|
size: 2048000,
|
|
lastModified: new Date('2026-01-27T11:00:00Z'),
|
|
etag: '"etag2"',
|
|
},
|
|
]);
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
Bucket: 'test-bucket',
|
|
Prefix: 'videos/',
|
|
MaxKeys: 100,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should return empty array if no objects found', async () => {
|
|
mockS3Client.send.mockResolvedValue({
|
|
Contents: undefined,
|
|
});
|
|
|
|
const result = await service.listObjects('empty/');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should use default maxKeys (1000) for listing', async () => {
|
|
mockS3Client.send.mockResolvedValue({ Contents: [] });
|
|
|
|
await service.listObjects('prefix/');
|
|
|
|
expect(mockS3Client.send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
MaxKeys: 1000,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// URL Generation
|
|
// ==========================================================================
|
|
|
|
describe('URL Generation', () => {
|
|
it('should generate CDN URL if configured', () => {
|
|
const url = service.getPublicUrl('videos/test.mp4');
|
|
|
|
expect(url).toBe('https://cdn.example.com/videos/test.mp4');
|
|
});
|
|
|
|
it('should generate R2 URL for Cloudflare R2', () => {
|
|
const r2Service = new StorageService({
|
|
provider: 'r2',
|
|
bucket: 'test-bucket',
|
|
endpoint: 'https://r2.example.com',
|
|
accessKeyId: 'key',
|
|
secretAccessKey: 'secret',
|
|
});
|
|
|
|
const url = r2Service.getPublicUrl('videos/test.mp4');
|
|
|
|
expect(url).toBe('https://r2.example.com/test-bucket/videos/test.mp4');
|
|
});
|
|
|
|
it('should generate S3 URL for AWS S3', () => {
|
|
const s3Service = new StorageService({
|
|
provider: 's3',
|
|
bucket: 'my-bucket',
|
|
region: 'us-west-2',
|
|
accessKeyId: 'key',
|
|
secretAccessKey: 'secret',
|
|
});
|
|
|
|
const url = s3Service.getPublicUrl('documents/file.pdf');
|
|
|
|
expect(url).toBe('https://my-bucket.s3.us-west-2.amazonaws.com/documents/file.pdf');
|
|
});
|
|
|
|
it('should prefer CDN URL over S3/R2 URL', () => {
|
|
const serviceWithCdn = new StorageService({
|
|
provider: 'r2',
|
|
bucket: 'test-bucket',
|
|
region: 'auto',
|
|
endpoint: 'https://r2.example.com',
|
|
accessKeyId: 'key',
|
|
secretAccessKey: 'secret',
|
|
cdnUrl: 'https://cdn.example.com',
|
|
});
|
|
|
|
const url = serviceWithCdn.getPublicUrl('test.mp4');
|
|
|
|
expect(url).toBe('https://cdn.example.com/test.mp4');
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Helper Methods
|
|
// ==========================================================================
|
|
|
|
describe('Helper Methods', () => {
|
|
it('should generate unique storage key', () => {
|
|
const key1 = service.generateKey('videos', 'test.mp4');
|
|
const key2 = service.generateKey('videos', 'test.mp4');
|
|
|
|
// Should start with prefix
|
|
expect(key1).toMatch(/^videos\//);
|
|
expect(key2).toMatch(/^videos\//);
|
|
|
|
// Should include timestamp
|
|
expect(key1).toMatch(/videos\/\d+-[a-z0-9]+-test\.mp4/);
|
|
|
|
// Should be unique (different random component)
|
|
expect(key1).not.toBe(key2);
|
|
});
|
|
|
|
it('should sanitize filename in generated key', () => {
|
|
const key = service.generateKey('documents', 'my file (copy) #1.pdf');
|
|
|
|
// Special characters should be replaced with underscores
|
|
expect(key).toMatch(/^documents\/\d+-[a-z0-9]+-my_file__copy___1\.pdf$/);
|
|
});
|
|
|
|
it('should preserve file extension', () => {
|
|
const key = service.generateKey('images', 'photo.jpg');
|
|
|
|
expect(key).toMatch(/\.jpg$/);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Error Handling
|
|
// ==========================================================================
|
|
|
|
describe('Error Handling', () => {
|
|
it('should wrap S3 errors with context', async () => {
|
|
mockS3Client.send.mockRejectedValue(new Error('AccessDenied: Insufficient permissions'));
|
|
|
|
await expect(
|
|
service.upload({ key: 'test.txt', body: 'content' })
|
|
).rejects.toThrow('Storage upload failed: AccessDenied: Insufficient permissions');
|
|
});
|
|
|
|
it('should handle network errors', async () => {
|
|
mockS3Client.send.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
|
|
await expect(service.deleteObject('test.txt')).rejects.toThrow('Delete object failed');
|
|
});
|
|
|
|
it('should handle unknown errors', async () => {
|
|
mockS3Client.send.mockRejectedValue('Unknown error');
|
|
|
|
await expect(service.getObject('test.txt')).rejects.toThrow('Get object failed');
|
|
});
|
|
});
|
|
});
|