minio.service.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
  2. import { MinioService } from '@d8d/server/modules/files/minio.service';
  3. import { Client } from 'minio';
  4. import { logger } from '@d8d/server/utils/logger';
  5. // Mock dependencies
  6. vi.mock('minio');
  7. vi.mock('@d8d/server/utils/logger');
  8. // Mock process.env using vi.stubEnv for proper isolation
  9. beforeEach(() => {
  10. vi.stubEnv('MINIO_HOST', 'localhost');
  11. vi.stubEnv('MINIO_PORT', '9000');
  12. vi.stubEnv('MINIO_USE_SSL', 'false');
  13. vi.stubEnv('MINIO_ACCESS_KEY', 'minioadmin');
  14. vi.stubEnv('MINIO_SECRET_KEY', 'minioadmin');
  15. vi.stubEnv('MINIO_BUCKET_NAME', 'test-bucket');
  16. });
  17. afterEach(() => {
  18. vi.unstubAllEnvs();
  19. });
  20. describe('MinioService', () => {
  21. let minioService: MinioService;
  22. let mockClient: Client;
  23. beforeEach(() => {
  24. mockClient = new Client({} as any);
  25. (Client as any).mockClear();
  26. (Client as any).mockImplementation(() => mockClient);
  27. minioService = new MinioService();
  28. });
  29. afterEach(() => {
  30. vi.clearAllMocks();
  31. });
  32. describe('constructor', () => {
  33. it('should initialize with correct configuration', () => {
  34. expect(Client).toHaveBeenCalledWith({
  35. endPoint: 'localhost',
  36. port: 9000,
  37. useSSL: false,
  38. accessKey: 'minioadmin',
  39. secretKey: 'minioadmin'
  40. });
  41. expect(minioService.bucketName).toBe('test-bucket');
  42. });
  43. });
  44. describe('setPublicReadPolicy', () => {
  45. it('should set public read policy successfully', async () => {
  46. const mockPolicy = JSON.stringify({
  47. Version: '2012-10-17',
  48. Statement: [
  49. {
  50. Effect: 'Allow',
  51. Principal: { AWS: '*' },
  52. Action: ['s3:GetObject'],
  53. Resource: ['arn:aws:s3:::test-bucket/*']
  54. },
  55. {
  56. Effect: 'Allow',
  57. Principal: { AWS: '*' },
  58. Action: ['s3:ListBucket'],
  59. Resource: ['arn:aws:s3:::test-bucket']
  60. }
  61. ]
  62. });
  63. vi.mocked(mockClient.setBucketPolicy).mockResolvedValue(undefined);
  64. await minioService.setPublicReadPolicy();
  65. expect(mockClient.setBucketPolicy).toHaveBeenCalledWith('test-bucket', mockPolicy);
  66. expect(logger.db).toHaveBeenCalledWith('Bucket policy set to public read for: test-bucket');
  67. });
  68. it('should handle errors when setting policy', async () => {
  69. const error = new Error('Policy error');
  70. vi.mocked(mockClient.setBucketPolicy).mockRejectedValue(error);
  71. await expect(minioService.setPublicReadPolicy()).rejects.toThrow(error);
  72. expect(logger.error).toHaveBeenCalledWith('Failed to set bucket policy for test-bucket:', error);
  73. });
  74. });
  75. describe('ensureBucketExists', () => {
  76. it('should create bucket if not exists', async () => {
  77. vi.mocked(mockClient.bucketExists).mockResolvedValue(false);
  78. vi.mocked(mockClient.makeBucket).mockResolvedValue(undefined);
  79. vi.spyOn(minioService, 'setPublicReadPolicy').mockResolvedValue(undefined);
  80. const result = await minioService.ensureBucketExists();
  81. expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
  82. expect(mockClient.makeBucket).toHaveBeenCalledWith('test-bucket');
  83. expect(minioService.setPublicReadPolicy).toHaveBeenCalledWith('test-bucket');
  84. expect(result).toBe(true);
  85. expect(logger.db).toHaveBeenCalledWith('Created new bucket: test-bucket');
  86. });
  87. it('should return true if bucket already exists', async () => {
  88. vi.mocked(mockClient.bucketExists).mockResolvedValue(true);
  89. const result = await minioService.ensureBucketExists();
  90. expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
  91. expect(mockClient.makeBucket).not.toHaveBeenCalled();
  92. expect(result).toBe(true);
  93. });
  94. it('should handle errors during bucket check', async () => {
  95. const error = new Error('Bucket check failed');
  96. vi.mocked(mockClient.bucketExists).mockRejectedValue(error);
  97. await expect(minioService.ensureBucketExists()).rejects.toThrow(error);
  98. expect(logger.error).toHaveBeenCalledWith('Failed to ensure bucket exists: test-bucket', error);
  99. });
  100. });
  101. describe('generateUploadPolicy', () => {
  102. it('should generate upload policy successfully', async () => {
  103. const fileKey = 'test-file.txt';
  104. const mockPolicy = {
  105. setBucket: vi.fn(),
  106. setKey: vi.fn(),
  107. setExpires: vi.fn()
  108. };
  109. const mockFormData = {
  110. 'x-amz-algorithm': 'AWS4-HMAC-SHA256',
  111. 'x-amz-credential': 'credential',
  112. 'x-amz-date': '20250101T120000Z',
  113. policy: 'policy-string',
  114. 'x-amz-signature': 'signature'
  115. };
  116. vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true);
  117. vi.mocked(mockClient.newPostPolicy).mockReturnValue(mockPolicy as any);
  118. vi.mocked(mockClient.presignedPostPolicy).mockResolvedValue({
  119. postURL: 'https://minio.example.com',
  120. formData: mockFormData
  121. });
  122. const result = await minioService.generateUploadPolicy(fileKey);
  123. expect(minioService.ensureBucketExists).toHaveBeenCalled();
  124. expect(mockClient.newPostPolicy).toHaveBeenCalled();
  125. expect(mockPolicy.setBucket).toHaveBeenCalledWith('test-bucket');
  126. expect(mockPolicy.setKey).toHaveBeenCalledWith(fileKey);
  127. expect(mockPolicy.setExpires).toHaveBeenCalledWith(expect.any(Date));
  128. expect(mockClient.presignedPostPolicy).toHaveBeenCalledWith(mockPolicy);
  129. expect(result).toEqual({
  130. 'x-amz-algorithm': 'AWS4-HMAC-SHA256',
  131. 'x-amz-credential': 'credential',
  132. 'x-amz-date': '20250101T120000Z',
  133. 'x-amz-security-token': undefined,
  134. policy: 'policy-string',
  135. 'x-amz-signature': 'signature',
  136. host: 'https://minio.example.com',
  137. key: fileKey,
  138. bucket: 'test-bucket'
  139. });
  140. });
  141. });
  142. describe('getFileUrl', () => {
  143. it('should generate correct file URL without SSL', () => {
  144. const url = minioService.getFileUrl('test-bucket', 'file.txt');
  145. expect(url).toBe('http://localhost:9000/test-bucket/file.txt');
  146. });
  147. it('should generate correct file URL with SSL', async () => {
  148. // Create new instance with SSL by temporarily overriding env vars
  149. vi.stubEnv('MINIO_USE_SSL', 'true');
  150. vi.stubEnv('MINIO_PORT', '443');
  151. const sslService = new MinioService();
  152. const url = sslService.getFileUrl('test-bucket', 'file.txt');
  153. expect(url).toBe('https://localhost:443/test-bucket/file.txt');
  154. });
  155. });
  156. describe('getPresignedFileUrl', () => {
  157. it('should generate presigned URL successfully', async () => {
  158. const mockUrl = 'https://minio.example.com/presigned-url';
  159. vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl);
  160. const result = await minioService.getPresignedFileUrl('test-bucket', 'file.txt', 3600);
  161. expect(mockClient.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'file.txt', 3600);
  162. expect(result).toBe(mockUrl);
  163. expect(logger.db).toHaveBeenCalledWith(
  164. 'Generated presigned URL for test-bucket/file.txt, expires in 3600s'
  165. );
  166. });
  167. it('should handle errors during URL generation', async () => {
  168. const error = new Error('URL generation failed');
  169. vi.mocked(mockClient.presignedGetObject).mockRejectedValue(error);
  170. await expect(minioService.getPresignedFileUrl('test-bucket', 'file.txt')).rejects.toThrow(error);
  171. expect(logger.error).toHaveBeenCalledWith(
  172. 'Failed to generate presigned URL for test-bucket/file.txt:',
  173. error
  174. );
  175. });
  176. });
  177. describe('getPresignedFileDownloadUrl', () => {
  178. it('should generate download URL with content disposition', async () => {
  179. const mockUrl = 'https://minio.example.com/download-url';
  180. vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl);
  181. const result = await minioService.getPresignedFileDownloadUrl(
  182. 'test-bucket',
  183. 'file.txt',
  184. '测试文件.txt',
  185. 1800
  186. );
  187. expect(mockClient.presignedGetObject).toHaveBeenCalledWith(
  188. 'test-bucket',
  189. 'file.txt',
  190. 1800,
  191. {
  192. 'response-content-disposition': 'attachment; filename="%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt"',
  193. 'response-content-type': 'application/octet-stream'
  194. }
  195. );
  196. expect(result).toBe(mockUrl);
  197. expect(logger.db).toHaveBeenCalledWith(
  198. 'Generated presigned download URL for test-bucket/file.txt, filename: 测试文件.txt'
  199. );
  200. });
  201. });
  202. describe('createMultipartUpload', () => {
  203. it('should create multipart upload successfully', async () => {
  204. const mockUploadId = 'upload-123';
  205. vi.mocked(mockClient.initiateNewMultipartUpload).mockResolvedValue(mockUploadId);
  206. const result = await minioService.createMultipartUpload('test-bucket', 'large-file.zip');
  207. expect(mockClient.initiateNewMultipartUpload).toHaveBeenCalledWith(
  208. 'test-bucket',
  209. 'large-file.zip',
  210. {}
  211. );
  212. expect(result).toBe(mockUploadId);
  213. expect(logger.db).toHaveBeenCalledWith(
  214. 'Created multipart upload for large-file.zip with ID: upload-123'
  215. );
  216. });
  217. it('should handle errors during multipart upload creation', async () => {
  218. const error = new Error('Upload creation failed');
  219. vi.mocked(mockClient.initiateNewMultipartUpload).mockRejectedValue(error);
  220. await expect(minioService.createMultipartUpload('test-bucket', 'file.zip')).rejects.toThrow(error);
  221. expect(logger.error).toHaveBeenCalledWith(
  222. 'Failed to create multipart upload for file.zip:',
  223. error
  224. );
  225. });
  226. });
  227. describe('generateMultipartUploadUrls', () => {
  228. it('should generate multipart upload URLs', async () => {
  229. const mockUrls = ['url1', 'url2', 'url3'];
  230. vi.mocked(mockClient.presignedUrl)
  231. .mockResolvedValueOnce('url1')
  232. .mockResolvedValueOnce('url2')
  233. .mockResolvedValueOnce('url3');
  234. const result = await minioService.generateMultipartUploadUrls(
  235. 'test-bucket',
  236. 'large-file.zip',
  237. 'upload-123',
  238. 3
  239. );
  240. expect(mockClient.presignedUrl).toHaveBeenCalledTimes(3);
  241. expect(mockClient.presignedUrl).toHaveBeenNthCalledWith(
  242. 1,
  243. 'put',
  244. 'test-bucket',
  245. 'large-file.zip',
  246. 3600,
  247. { uploadId: 'upload-123', partNumber: '1' }
  248. );
  249. expect(result).toEqual(mockUrls);
  250. });
  251. });
  252. describe('completeMultipartUpload', () => {
  253. it('should complete multipart upload successfully', async () => {
  254. const parts = [
  255. { ETag: 'etag1', PartNumber: 1 },
  256. { ETag: 'etag2', PartNumber: 2 }
  257. ];
  258. const mockStat = { size: 2048 };
  259. vi.mocked(mockClient.completeMultipartUpload).mockResolvedValue({ etag: 'etag123', versionId: null });
  260. vi.mocked(mockClient.statObject).mockResolvedValue(mockStat as any);
  261. const result = await minioService.completeMultipartUpload(
  262. 'test-bucket',
  263. 'large-file.zip',
  264. 'upload-123',
  265. parts
  266. );
  267. expect(mockClient.completeMultipartUpload).toHaveBeenCalledWith(
  268. 'test-bucket',
  269. 'large-file.zip',
  270. 'upload-123',
  271. [{ part: 1, etag: 'etag1' }, { part: 2, etag: 'etag2' }]
  272. );
  273. expect(mockClient.statObject).toHaveBeenCalledWith('test-bucket', 'large-file.zip');
  274. expect(result).toEqual({ size: 2048 });
  275. expect(logger.db).toHaveBeenCalledWith(
  276. 'Completed multipart upload for large-file.zip with ID: upload-123'
  277. );
  278. });
  279. it('should handle errors during completion', async () => {
  280. const error = new Error('Completion failed');
  281. vi.mocked(mockClient.completeMultipartUpload).mockRejectedValue(error);
  282. await expect(minioService.completeMultipartUpload(
  283. 'test-bucket',
  284. 'file.zip',
  285. 'upload-123',
  286. [{ ETag: 'etag1', PartNumber: 1 }]
  287. )).rejects.toThrow(error);
  288. expect(logger.error).toHaveBeenCalledWith(
  289. 'Failed to complete multipart upload for file.zip:',
  290. error
  291. );
  292. });
  293. });
  294. describe('createObject', () => {
  295. it('should create object successfully', async () => {
  296. const fileContent = Buffer.from('test content');
  297. const mockUrl = 'http://localhost:9000/test-bucket/file.txt';
  298. vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true);
  299. vi.mocked(mockClient.putObject).mockResolvedValue({ etag: 'etag123', versionId: null });
  300. vi.spyOn(minioService, 'getFileUrl').mockReturnValue(mockUrl);
  301. const result = await minioService.createObject(
  302. 'test-bucket',
  303. 'file.txt',
  304. fileContent,
  305. 'text/plain'
  306. );
  307. expect(minioService.ensureBucketExists).toHaveBeenCalledWith('test-bucket');
  308. expect(mockClient.putObject).toHaveBeenCalledWith(
  309. 'test-bucket',
  310. 'file.txt',
  311. fileContent,
  312. fileContent.length,
  313. { 'Content-Type': 'text/plain' }
  314. );
  315. expect(result).toBe(mockUrl);
  316. expect(logger.db).toHaveBeenCalledWith('Created object: test-bucket/file.txt');
  317. });
  318. });
  319. describe('objectExists', () => {
  320. it('should return true when object exists', async () => {
  321. vi.mocked(mockClient.statObject).mockResolvedValue({} as any);
  322. const result = await minioService.objectExists('test-bucket', 'file.txt');
  323. expect(result).toBe(true);
  324. });
  325. it('should return false when object not found', async () => {
  326. const error = new Error('Object not found');
  327. vi.mocked(mockClient.statObject).mockRejectedValue(error);
  328. const result = await minioService.objectExists('test-bucket', 'nonexistent.txt');
  329. expect(result).toBe(false);
  330. });
  331. it('should rethrow other errors', async () => {
  332. const error = new Error('Permission denied');
  333. vi.mocked(mockClient.statObject).mockRejectedValue(error);
  334. await expect(minioService.objectExists('test-bucket', 'file.txt')).rejects.toThrow(error);
  335. expect(logger.error).toHaveBeenCalledWith(
  336. 'Error checking existence of object test-bucket/file.txt:',
  337. error
  338. );
  339. });
  340. });
  341. describe('deleteObject', () => {
  342. it('should delete object successfully', async () => {
  343. vi.mocked(mockClient.removeObject).mockResolvedValue(undefined);
  344. await minioService.deleteObject('test-bucket', 'file.txt');
  345. expect(mockClient.removeObject).toHaveBeenCalledWith('test-bucket', 'file.txt');
  346. expect(logger.db).toHaveBeenCalledWith('Deleted object: test-bucket/file.txt');
  347. });
  348. it('should handle errors during deletion', async () => {
  349. const error = new Error('Deletion failed');
  350. vi.mocked(mockClient.removeObject).mockRejectedValue(error);
  351. await expect(minioService.deleteObject('test-bucket', 'file.txt')).rejects.toThrow(error);
  352. expect(logger.error).toHaveBeenCalledWith(
  353. 'Failed to delete object test-bucket/file.txt:',
  354. error
  355. );
  356. });
  357. });
  358. });