trading-platform-backend-v2/src/__tests__/integration/storage-service.test.ts
Adrian Flores Cortes 86e6303847 feat: Implement BLOCKER-001 token refresh + E2E video tests (backend)
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>
2026-01-27 01:43:49 -06:00

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');
});
});
});