@flusys/nestjs-storage 6.0.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/controllers/file-manager.controller.spec.js +135 -0
- package/cjs/controllers/folder.controller.spec.js +86 -0
- package/cjs/controllers/storage-config.controller.spec.js +85 -0
- package/cjs/controllers/upload.controller.spec.js +132 -0
- package/cjs/docs/storage-swagger.config.spec.js +72 -0
- package/cjs/entities/index.spec.js +18 -0
- package/cjs/middlewares/file-serve.middleware.spec.js +211 -0
- package/cjs/modules/storage.module.spec.js +184 -0
- package/cjs/providers/azure-provider.optional.spec.js +228 -0
- package/cjs/providers/local-provider.spec.js +239 -0
- package/cjs/providers/s3-provider.optional.spec.js +243 -0
- package/cjs/providers/sftp-provider.optional.spec.js +217 -0
- package/cjs/providers/storage-factory.service.spec.js +253 -0
- package/cjs/providers/storage-provider.registry.spec.js +67 -0
- package/cjs/services/file-manager.service.spec.js +549 -0
- package/cjs/services/folder.service.spec.js +163 -0
- package/cjs/services/storage-config.service.spec.js +257 -0
- package/cjs/services/storage-datasource.provider.spec.js +217 -0
- package/cjs/services/storage-provider-config.service.spec.js +243 -0
- package/cjs/services/upload.service.spec.js +372 -0
- package/cjs/utils/file-validator.util.spec.js +259 -0
- package/cjs/utils/image-compressor.util.spec.js +166 -0
- package/fesm/controllers/file-manager.controller.spec.js +131 -0
- package/fesm/controllers/folder.controller.spec.js +82 -0
- package/fesm/controllers/storage-config.controller.spec.js +81 -0
- package/fesm/controllers/upload.controller.spec.js +128 -0
- package/fesm/docs/storage-swagger.config.spec.js +68 -0
- package/fesm/entities/index.spec.js +14 -0
- package/fesm/middlewares/file-serve.middleware.spec.js +207 -0
- package/fesm/modules/storage.module.spec.js +180 -0
- package/fesm/providers/azure-provider.optional.spec.js +224 -0
- package/fesm/providers/local-provider.spec.js +194 -0
- package/fesm/providers/s3-provider.optional.spec.js +239 -0
- package/fesm/providers/sftp-provider.optional.spec.js +213 -0
- package/fesm/providers/storage-factory.service.spec.js +249 -0
- package/fesm/providers/storage-provider.registry.spec.js +63 -0
- package/fesm/services/file-manager.service.spec.js +545 -0
- package/fesm/services/folder.service.spec.js +159 -0
- package/fesm/services/storage-config.service.spec.js +253 -0
- package/fesm/services/storage-datasource.provider.spec.js +172 -0
- package/fesm/services/storage-provider-config.service.spec.js +239 -0
- package/fesm/services/upload.service.spec.js +368 -0
- package/fesm/utils/file-validator.util.spec.js +255 -0
- package/fesm/utils/image-compressor.util.spec.js +162 -0
- package/package.json +3 -3
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
const mockCreateIfNotExists = jest.fn();
|
|
15
|
+
const mockBlockBlobUpload = jest.fn();
|
|
16
|
+
const mockDeleteIfExists = jest.fn();
|
|
17
|
+
const mockContainerExists = jest.fn();
|
|
18
|
+
function buildBlockBlobClient() {
|
|
19
|
+
return {
|
|
20
|
+
upload: mockBlockBlobUpload,
|
|
21
|
+
deleteIfExists: mockDeleteIfExists,
|
|
22
|
+
url: 'https://account.blob.core.windows.net/container/blob-key.jpg',
|
|
23
|
+
credential: 'shared-key-credential'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const mockGetBlockBlobClient = jest.fn().mockImplementation(()=>buildBlockBlobClient());
|
|
27
|
+
const mockGetContainerClient = jest.fn().mockImplementation(()=>({
|
|
28
|
+
createIfNotExists: mockCreateIfNotExists,
|
|
29
|
+
getBlockBlobClient: mockGetBlockBlobClient,
|
|
30
|
+
exists: mockContainerExists
|
|
31
|
+
}));
|
|
32
|
+
const mockFromConnectionString = jest.fn().mockImplementation(()=>({
|
|
33
|
+
getContainerClient: mockGetContainerClient
|
|
34
|
+
}));
|
|
35
|
+
const mockBlobServiceClientCtor = jest.fn().mockImplementation(()=>({
|
|
36
|
+
getContainerClient: mockGetContainerClient
|
|
37
|
+
}));
|
|
38
|
+
mockBlobServiceClientCtor.fromConnectionString = mockFromConnectionString;
|
|
39
|
+
const mockGenerateBlobSASQueryParameters = jest.fn().mockReturnValue({
|
|
40
|
+
toString: ()=>'sv=2022&sig=abc123'
|
|
41
|
+
});
|
|
42
|
+
let MockStorageSharedKeyCredential = class MockStorageSharedKeyCredential {
|
|
43
|
+
constructor(accountName, accountKey){
|
|
44
|
+
_define_property(this, "accountName", void 0);
|
|
45
|
+
_define_property(this, "accountKey", void 0);
|
|
46
|
+
this.accountName = accountName;
|
|
47
|
+
this.accountKey = accountKey;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
let MockBlobSASPermissions = class MockBlobSASPermissions {
|
|
51
|
+
static parse(input) {
|
|
52
|
+
return {
|
|
53
|
+
permissions: input
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
jest.mock('@azure/storage-blob', ()=>({
|
|
58
|
+
BlobServiceClient: mockBlobServiceClientCtor,
|
|
59
|
+
StorageSharedKeyCredential: MockStorageSharedKeyCredential,
|
|
60
|
+
BlobSASPermissions: MockBlobSASPermissions,
|
|
61
|
+
generateBlobSASQueryParameters: (...args)=>mockGenerateBlobSASQueryParameters(...args)
|
|
62
|
+
}));
|
|
63
|
+
jest.mock('../utils/image-compressor.util', ()=>({
|
|
64
|
+
ImageCompressor: {
|
|
65
|
+
compress: jest.fn()
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
68
|
+
import { AzureProvider } from './azure-provider.optional';
|
|
69
|
+
import { ImageCompressor } from '../utils/image-compressor.util';
|
|
70
|
+
function buildMulterFile(overrides = {}) {
|
|
71
|
+
return {
|
|
72
|
+
fieldname: 'file',
|
|
73
|
+
originalname: 'photo.jpg',
|
|
74
|
+
encoding: '7bit',
|
|
75
|
+
mimetype: 'image/jpeg',
|
|
76
|
+
buffer: Buffer.from('file-bytes'),
|
|
77
|
+
size: 10,
|
|
78
|
+
stream: undefined,
|
|
79
|
+
destination: '',
|
|
80
|
+
filename: '',
|
|
81
|
+
path: '',
|
|
82
|
+
...overrides
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
describe('AzureProvider', ()=>{
|
|
86
|
+
let provider;
|
|
87
|
+
beforeEach(async ()=>{
|
|
88
|
+
jest.clearAllMocks();
|
|
89
|
+
mockCreateIfNotExists.mockResolvedValue(undefined);
|
|
90
|
+
mockBlockBlobUpload.mockResolvedValue(undefined);
|
|
91
|
+
mockDeleteIfExists.mockResolvedValue(undefined);
|
|
92
|
+
mockContainerExists.mockResolvedValue(true);
|
|
93
|
+
mockGetBlockBlobClient.mockImplementation(()=>buildBlockBlobClient());
|
|
94
|
+
mockGetContainerClient.mockImplementation(()=>({
|
|
95
|
+
createIfNotExists: mockCreateIfNotExists,
|
|
96
|
+
getBlockBlobClient: mockGetBlockBlobClient,
|
|
97
|
+
exists: mockContainerExists
|
|
98
|
+
}));
|
|
99
|
+
provider = new AzureProvider();
|
|
100
|
+
await provider.initialize({
|
|
101
|
+
accountName: 'myaccount',
|
|
102
|
+
accountKey: 'key123',
|
|
103
|
+
containerName: 'my-container'
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('initialize', ()=>{
|
|
107
|
+
it('builds a BlobServiceClient using an account key credential when no connection string is given', ()=>{
|
|
108
|
+
expect(mockBlobServiceClientCtor).toHaveBeenCalledWith('https://myaccount.blob.core.windows.net', expect.any(MockStorageSharedKeyCredential));
|
|
109
|
+
expect(mockCreateIfNotExists).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
it('builds a BlobServiceClient from a connection string when provided', async ()=>{
|
|
112
|
+
jest.clearAllMocks();
|
|
113
|
+
mockCreateIfNotExists.mockResolvedValue(undefined);
|
|
114
|
+
const p = new AzureProvider();
|
|
115
|
+
await p.initialize({
|
|
116
|
+
accountName: 'acct2',
|
|
117
|
+
accountKey: 'key2',
|
|
118
|
+
containerName: 'container2',
|
|
119
|
+
connectionString: 'DefaultEndpointsProtocol=https;AccountName=acct2;AccountKey=key2'
|
|
120
|
+
});
|
|
121
|
+
expect(mockFromConnectionString).toHaveBeenCalledWith('DefaultEndpointsProtocol=https;AccountName=acct2;AccountKey=key2');
|
|
122
|
+
});
|
|
123
|
+
it('throws InternalServerErrorException when the Azure SDK cannot be initialized', async ()=>{
|
|
124
|
+
mockBlobServiceClientCtor.mockImplementationOnce(()=>{
|
|
125
|
+
throw new Error('boom');
|
|
126
|
+
});
|
|
127
|
+
const brokenProvider = new AzureProvider();
|
|
128
|
+
await expect(brokenProvider.initialize({
|
|
129
|
+
accountName: 'a',
|
|
130
|
+
accountKey: 'b',
|
|
131
|
+
containerName: 'c'
|
|
132
|
+
})).rejects.toThrow('Azure Storage SDK not found');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('uploadFile', ()=>{
|
|
136
|
+
it('uploads the blob and returns file metadata', async ()=>{
|
|
137
|
+
const file = buildMulterFile();
|
|
138
|
+
const result = await provider.uploadFile(file, {});
|
|
139
|
+
expect(mockBlockBlobUpload).toHaveBeenCalledWith(file.buffer, file.buffer.length, expect.objectContaining({
|
|
140
|
+
blobHTTPHeaders: {
|
|
141
|
+
blobContentType: 'image/jpeg'
|
|
142
|
+
}
|
|
143
|
+
}));
|
|
144
|
+
expect(result.name).toMatch(/photo\.jpg$/);
|
|
145
|
+
expect(result.key).toBe(result.name);
|
|
146
|
+
});
|
|
147
|
+
it('prefixes blob name with folderPath when provided', async ()=>{
|
|
148
|
+
const file = buildMulterFile();
|
|
149
|
+
const result = await provider.uploadFile(file, {
|
|
150
|
+
folderPath: 'docs'
|
|
151
|
+
});
|
|
152
|
+
expect(result.key).toMatch(/^docs\//);
|
|
153
|
+
});
|
|
154
|
+
it('compresses image files when options.compress is true', async ()=>{
|
|
155
|
+
ImageCompressor.compress.mockResolvedValue({
|
|
156
|
+
buffer: Buffer.from('small'),
|
|
157
|
+
format: 'image/webp'
|
|
158
|
+
});
|
|
159
|
+
const file = buildMulterFile();
|
|
160
|
+
const result = await provider.uploadFile(file, {
|
|
161
|
+
compress: true
|
|
162
|
+
});
|
|
163
|
+
expect(ImageCompressor.compress).toHaveBeenCalled();
|
|
164
|
+
expect(result.contentType).toBe('image/webp');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('uploadMultipleFiles', ()=>{
|
|
168
|
+
it('uploads all files', async ()=>{
|
|
169
|
+
const files = [
|
|
170
|
+
buildMulterFile({
|
|
171
|
+
originalname: 'a.jpg'
|
|
172
|
+
}),
|
|
173
|
+
buildMulterFile({
|
|
174
|
+
originalname: 'b.jpg'
|
|
175
|
+
})
|
|
176
|
+
];
|
|
177
|
+
const results = await provider.uploadMultipleFiles(files, {});
|
|
178
|
+
expect(results).toHaveLength(2);
|
|
179
|
+
expect(mockBlockBlobUpload).toHaveBeenCalledTimes(2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe('deleteFile', ()=>{
|
|
183
|
+
it('calls deleteIfExists on the block blob client for the given key', async ()=>{
|
|
184
|
+
await provider.deleteFile('blob-key.jpg');
|
|
185
|
+
expect(mockGetBlockBlobClient).toHaveBeenCalledWith('blob-key.jpg');
|
|
186
|
+
expect(mockDeleteIfExists).toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
describe('deleteMultipleFiles', ()=>{
|
|
190
|
+
it('deletes every provided key', async ()=>{
|
|
191
|
+
await provider.deleteMultipleFiles([
|
|
192
|
+
'a.jpg',
|
|
193
|
+
'b.jpg'
|
|
194
|
+
]);
|
|
195
|
+
expect(mockDeleteIfExists).toHaveBeenCalledTimes(2);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe('generatePresignedUrl', ()=>{
|
|
199
|
+
it('generates a SAS URL appended with the query string', async ()=>{
|
|
200
|
+
const url = await provider.generatePresignedUrl('blob-key.jpg', 1800);
|
|
201
|
+
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalledWith(expect.objectContaining({
|
|
202
|
+
containerName: 'my-container',
|
|
203
|
+
blobName: 'blob-key.jpg'
|
|
204
|
+
}), 'shared-key-credential');
|
|
205
|
+
expect(url).toBe('https://account.blob.core.windows.net/container/blob-key.jpg?sv=2022&sig=abc123');
|
|
206
|
+
});
|
|
207
|
+
it('defaults the expiry window to 3600 seconds', async ()=>{
|
|
208
|
+
await provider.generatePresignedUrl('blob-key.jpg');
|
|
209
|
+
const call = mockGenerateBlobSASQueryParameters.mock.calls[0][0];
|
|
210
|
+
const diffSeconds = (call.expiresOn.getTime() - call.startsOn.getTime()) / 1000;
|
|
211
|
+
expect(diffSeconds).toBeCloseTo(3600, 0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe('healthCheck', ()=>{
|
|
215
|
+
it('returns true when the container exists', async ()=>{
|
|
216
|
+
mockContainerExists.mockResolvedValue(true);
|
|
217
|
+
await expect(provider.healthCheck()).resolves.toBe(true);
|
|
218
|
+
});
|
|
219
|
+
it('returns false when checking existence throws', async ()=>{
|
|
220
|
+
mockContainerExists.mockRejectedValue(new Error('network error'));
|
|
221
|
+
await expect(provider.healthCheck()).resolves.toBe(false);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const mockMkdir = jest.fn();
|
|
2
|
+
const mockWriteFile = jest.fn();
|
|
3
|
+
const mockAccess = jest.fn();
|
|
4
|
+
const mockUnlink = jest.fn();
|
|
5
|
+
jest.mock('fs/promises', ()=>({
|
|
6
|
+
mkdir: (...args)=>mockMkdir(...args),
|
|
7
|
+
writeFile: (...args)=>mockWriteFile(...args),
|
|
8
|
+
access: (...args)=>mockAccess(...args),
|
|
9
|
+
unlink: (...args)=>mockUnlink(...args)
|
|
10
|
+
}));
|
|
11
|
+
jest.mock('../utils/image-compressor.util', ()=>({
|
|
12
|
+
ImageCompressor: {
|
|
13
|
+
compress: jest.fn()
|
|
14
|
+
}
|
|
15
|
+
}));
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { LocalProvider } from './local-provider';
|
|
18
|
+
import { ImageCompressor } from '../utils/image-compressor.util';
|
|
19
|
+
function buildMulterFile(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
fieldname: 'file',
|
|
22
|
+
originalname: 'photo.jpg',
|
|
23
|
+
encoding: '7bit',
|
|
24
|
+
mimetype: 'image/jpeg',
|
|
25
|
+
buffer: Buffer.from('file-bytes'),
|
|
26
|
+
size: 10,
|
|
27
|
+
stream: undefined,
|
|
28
|
+
destination: '',
|
|
29
|
+
filename: '',
|
|
30
|
+
path: '',
|
|
31
|
+
...overrides
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
describe('LocalProvider', ()=>{
|
|
35
|
+
let provider;
|
|
36
|
+
beforeEach(async ()=>{
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
39
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
40
|
+
mockAccess.mockResolvedValue(undefined);
|
|
41
|
+
mockUnlink.mockResolvedValue(undefined);
|
|
42
|
+
provider = new LocalProvider();
|
|
43
|
+
await provider.initialize({
|
|
44
|
+
basePath: './uploads-test',
|
|
45
|
+
baseUrl: 'http://localhost:2002'
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('initialize', ()=>{
|
|
49
|
+
it('resolves the base path and ensures the directory exists', async ()=>{
|
|
50
|
+
expect(mockMkdir).toHaveBeenCalledWith(path.resolve('./uploads-test'), expect.objectContaining({
|
|
51
|
+
recursive: true
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
54
|
+
it('defaults basePath to ./uploads when not provided', async ()=>{
|
|
55
|
+
mockMkdir.mockClear();
|
|
56
|
+
const defaultProvider = new LocalProvider();
|
|
57
|
+
await defaultProvider.initialize({});
|
|
58
|
+
expect(mockMkdir).toHaveBeenCalledWith(path.resolve('./uploads'), expect.objectContaining({
|
|
59
|
+
recursive: true
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('uploadFile', ()=>{
|
|
64
|
+
it('writes the file to disk and returns metadata with a self-contained key', async ()=>{
|
|
65
|
+
const file = buildMulterFile();
|
|
66
|
+
const result = await provider.uploadFile(file, {});
|
|
67
|
+
expect(mockWriteFile).toHaveBeenCalled();
|
|
68
|
+
expect(result.name).toMatch(/photo\.jpg$/);
|
|
69
|
+
expect(result.key).toContain('uploads-test');
|
|
70
|
+
expect(result.size).toBe(file.buffer.length);
|
|
71
|
+
expect(result.contentType).toBe('image/jpeg');
|
|
72
|
+
});
|
|
73
|
+
it('nests the file under folderPath when provided', async ()=>{
|
|
74
|
+
const file = buildMulterFile();
|
|
75
|
+
const result = await provider.uploadFile(file, {
|
|
76
|
+
folderPath: 'avatars'
|
|
77
|
+
});
|
|
78
|
+
expect(result.key).toContain('avatars');
|
|
79
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('avatars'), expect.objectContaining({
|
|
80
|
+
recursive: true
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
it('compresses image files when options.compress is true', async ()=>{
|
|
84
|
+
ImageCompressor.compress.mockResolvedValue({
|
|
85
|
+
buffer: Buffer.from('compressed'),
|
|
86
|
+
format: 'image/webp'
|
|
87
|
+
});
|
|
88
|
+
const file = buildMulterFile();
|
|
89
|
+
const result = await provider.uploadFile(file, {
|
|
90
|
+
compress: true,
|
|
91
|
+
maxWidth: 800,
|
|
92
|
+
maxHeight: 600,
|
|
93
|
+
quality: 70
|
|
94
|
+
});
|
|
95
|
+
expect(ImageCompressor.compress).toHaveBeenCalledWith(file.buffer, 'image/jpeg', expect.objectContaining({
|
|
96
|
+
maxWidth: 800,
|
|
97
|
+
maxHeight: 600,
|
|
98
|
+
quality: 70
|
|
99
|
+
}));
|
|
100
|
+
expect(result.contentType).toBe('image/webp');
|
|
101
|
+
expect(result.size).toBe(Buffer.from('compressed').length);
|
|
102
|
+
});
|
|
103
|
+
it('does not compress non-image mimetypes even when compress=true', async ()=>{
|
|
104
|
+
const file = buildMulterFile({
|
|
105
|
+
mimetype: 'application/pdf',
|
|
106
|
+
originalname: 'doc.pdf'
|
|
107
|
+
});
|
|
108
|
+
await provider.uploadFile(file, {
|
|
109
|
+
compress: true
|
|
110
|
+
});
|
|
111
|
+
expect(ImageCompressor.compress).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it('rejects a folderPath that attempts to escape the base directory via path traversal', async ()=>{
|
|
114
|
+
const file = buildMulterFile();
|
|
115
|
+
await expect(provider.uploadFile(file, {
|
|
116
|
+
folderPath: '../../../etc'
|
|
117
|
+
})).rejects.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('uploadMultipleFiles', ()=>{
|
|
121
|
+
it('uploads all files and returns their metadata', async ()=>{
|
|
122
|
+
const files = [
|
|
123
|
+
buildMulterFile({
|
|
124
|
+
originalname: 'a.jpg'
|
|
125
|
+
}),
|
|
126
|
+
buildMulterFile({
|
|
127
|
+
originalname: 'b.jpg'
|
|
128
|
+
})
|
|
129
|
+
];
|
|
130
|
+
const results = await provider.uploadMultipleFiles(files, {});
|
|
131
|
+
expect(results).toHaveLength(2);
|
|
132
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('deleteFile', ()=>{
|
|
136
|
+
it('deletes a file that exists at the resolved key path', async ()=>{
|
|
137
|
+
const key = path.join('uploads-test', 'some-uuid-photo.jpg');
|
|
138
|
+
await provider.deleteFile(key);
|
|
139
|
+
expect(mockUnlink).toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
it('falls back to basePath-prefixed lookup when the direct key path does not exist', async ()=>{
|
|
142
|
+
mockAccess.mockRejectedValueOnce(new Error('ENOENT')).mockResolvedValueOnce(undefined);
|
|
143
|
+
await provider.deleteFile('uploads-test/some-uuid-photo.jpg');
|
|
144
|
+
expect(mockUnlink).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
it('silently returns (no throw, no unlink) when the file does not exist at either location', async ()=>{
|
|
147
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
148
|
+
await expect(provider.deleteFile('uploads-test/missing-file.jpg')).resolves.toBeUndefined();
|
|
149
|
+
expect(mockUnlink).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
it('rejects an empty key', async ()=>{
|
|
152
|
+
await expect(provider.deleteFile('')).rejects.toThrow();
|
|
153
|
+
});
|
|
154
|
+
it('rejects a key containing a null byte', async ()=>{
|
|
155
|
+
await expect(provider.deleteFile('evil\0.txt')).rejects.toThrow();
|
|
156
|
+
});
|
|
157
|
+
it('re-throws path traversal security errors rather than silently swallowing them', async ()=>{
|
|
158
|
+
await expect(provider.deleteFile('../../../etc/passwd')).rejects.toThrow(/Invalid/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('deleteMultipleFiles', ()=>{
|
|
162
|
+
it('deletes every provided key', async ()=>{
|
|
163
|
+
await provider.deleteMultipleFiles([
|
|
164
|
+
'uploads-test/a.jpg',
|
|
165
|
+
'uploads-test/b.jpg'
|
|
166
|
+
]);
|
|
167
|
+
expect(mockUnlink).toHaveBeenCalledTimes(2);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('generatePresignedUrl', ()=>{
|
|
171
|
+
it('builds a full URL using the configured baseUrl', async ()=>{
|
|
172
|
+
const url = await provider.generatePresignedUrl('uploads-test/photo.jpg');
|
|
173
|
+
expect(url).toBe('http://localhost:2002/storage/upload/file/uploads-test/photo.jpg');
|
|
174
|
+
});
|
|
175
|
+
it('returns a relative path when no baseUrl was configured', async ()=>{
|
|
176
|
+
const noUrlProvider = new LocalProvider();
|
|
177
|
+
await noUrlProvider.initialize({
|
|
178
|
+
basePath: './uploads-test2'
|
|
179
|
+
});
|
|
180
|
+
const url = await noUrlProvider.generatePresignedUrl('uploads-test2/photo.jpg');
|
|
181
|
+
expect(url).toBe('/storage/upload/file/uploads-test2/photo.jpg');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe('healthCheck', ()=>{
|
|
185
|
+
it('returns true when the base path is accessible', async ()=>{
|
|
186
|
+
mockAccess.mockResolvedValue(undefined);
|
|
187
|
+
await expect(provider.healthCheck()).resolves.toBe(true);
|
|
188
|
+
});
|
|
189
|
+
it('returns false when the base path is not accessible', async ()=>{
|
|
190
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
191
|
+
await expect(provider.healthCheck()).resolves.toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
const mockS3ClientSend = jest.fn();
|
|
15
|
+
const mockS3ClientCtor = jest.fn().mockImplementation(()=>({
|
|
16
|
+
send: mockS3ClientSend
|
|
17
|
+
}));
|
|
18
|
+
let MockDeleteObjectCommand = class MockDeleteObjectCommand {
|
|
19
|
+
constructor(input){
|
|
20
|
+
_define_property(this, "input", void 0);
|
|
21
|
+
this.input = input;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
let MockDeleteObjectsCommand = class MockDeleteObjectsCommand {
|
|
25
|
+
constructor(input){
|
|
26
|
+
_define_property(this, "input", void 0);
|
|
27
|
+
this.input = input;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
let MockGetObjectCommand = class MockGetObjectCommand {
|
|
31
|
+
constructor(input){
|
|
32
|
+
_define_property(this, "input", void 0);
|
|
33
|
+
this.input = input;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
let MockHeadBucketCommand = class MockHeadBucketCommand {
|
|
37
|
+
constructor(input){
|
|
38
|
+
_define_property(this, "input", void 0);
|
|
39
|
+
this.input = input;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
jest.mock('@aws-sdk/client-s3', ()=>({
|
|
43
|
+
S3Client: mockS3ClientCtor,
|
|
44
|
+
DeleteObjectCommand: MockDeleteObjectCommand,
|
|
45
|
+
DeleteObjectsCommand: MockDeleteObjectsCommand,
|
|
46
|
+
GetObjectCommand: MockGetObjectCommand,
|
|
47
|
+
HeadBucketCommand: MockHeadBucketCommand
|
|
48
|
+
}));
|
|
49
|
+
const mockUploadDone = jest.fn();
|
|
50
|
+
const mockUploadCtor = jest.fn().mockImplementation(()=>({
|
|
51
|
+
done: mockUploadDone
|
|
52
|
+
}));
|
|
53
|
+
jest.mock('@aws-sdk/lib-storage', ()=>({
|
|
54
|
+
Upload: mockUploadCtor
|
|
55
|
+
}));
|
|
56
|
+
const mockGetSignedUrl = jest.fn();
|
|
57
|
+
jest.mock('@aws-sdk/s3-request-presigner', ()=>({
|
|
58
|
+
getSignedUrl: (...args)=>mockGetSignedUrl(...args)
|
|
59
|
+
}));
|
|
60
|
+
jest.mock('../utils/image-compressor.util', ()=>({
|
|
61
|
+
ImageCompressor: {
|
|
62
|
+
compress: jest.fn()
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
import { S3Provider } from './s3-provider.optional';
|
|
66
|
+
import { ImageCompressor } from '../utils/image-compressor.util';
|
|
67
|
+
function buildMulterFile(overrides = {}) {
|
|
68
|
+
return {
|
|
69
|
+
fieldname: 'file',
|
|
70
|
+
originalname: 'photo.jpg',
|
|
71
|
+
encoding: '7bit',
|
|
72
|
+
mimetype: 'image/jpeg',
|
|
73
|
+
buffer: Buffer.from('file-bytes'),
|
|
74
|
+
size: 10,
|
|
75
|
+
stream: undefined,
|
|
76
|
+
destination: '',
|
|
77
|
+
filename: '',
|
|
78
|
+
path: '',
|
|
79
|
+
...overrides
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
describe('S3Provider', ()=>{
|
|
83
|
+
let provider;
|
|
84
|
+
beforeEach(async ()=>{
|
|
85
|
+
jest.clearAllMocks();
|
|
86
|
+
mockUploadDone.mockResolvedValue(undefined);
|
|
87
|
+
provider = new S3Provider();
|
|
88
|
+
await provider.initialize({
|
|
89
|
+
region: 'us-east-1',
|
|
90
|
+
bucket: 'my-bucket'
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('initialize', ()=>{
|
|
94
|
+
it('creates an S3Client with region/endpoint/credentials', async ()=>{
|
|
95
|
+
jest.clearAllMocks();
|
|
96
|
+
const p = new S3Provider();
|
|
97
|
+
await p.initialize({
|
|
98
|
+
region: 'eu-west-1',
|
|
99
|
+
bucket: 'bucket-2',
|
|
100
|
+
accessKeyId: 'AKIA...',
|
|
101
|
+
secretAccessKey: 'secret',
|
|
102
|
+
endpoint: 'https://s3.custom.example.com'
|
|
103
|
+
});
|
|
104
|
+
expect(mockS3ClientCtor).toHaveBeenCalledWith(expect.objectContaining({
|
|
105
|
+
region: 'eu-west-1',
|
|
106
|
+
endpoint: 'https://s3.custom.example.com',
|
|
107
|
+
credentials: {
|
|
108
|
+
accessKeyId: 'AKIA...',
|
|
109
|
+
secretAccessKey: 'secret'
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
});
|
|
113
|
+
it('omits credentials when access key / secret are not provided', async ()=>{
|
|
114
|
+
jest.clearAllMocks();
|
|
115
|
+
const p = new S3Provider();
|
|
116
|
+
await p.initialize({
|
|
117
|
+
region: 'us-east-1',
|
|
118
|
+
bucket: 'bucket-3'
|
|
119
|
+
});
|
|
120
|
+
expect(mockS3ClientCtor).toHaveBeenCalledWith(expect.objectContaining({
|
|
121
|
+
credentials: undefined
|
|
122
|
+
}));
|
|
123
|
+
});
|
|
124
|
+
it('throws InternalServerErrorException when the AWS SDK cannot be initialized', async ()=>{
|
|
125
|
+
mockS3ClientCtor.mockImplementationOnce(()=>{
|
|
126
|
+
throw new Error('module not found');
|
|
127
|
+
});
|
|
128
|
+
const brokenProvider = new S3Provider();
|
|
129
|
+
await expect(brokenProvider.initialize({
|
|
130
|
+
region: 'us-east-1',
|
|
131
|
+
bucket: 'x'
|
|
132
|
+
})).rejects.toThrow('AWS SDK not found');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('uploadFile', ()=>{
|
|
136
|
+
it('uploads via the S3 Upload helper and returns file metadata', async ()=>{
|
|
137
|
+
const file = buildMulterFile();
|
|
138
|
+
const result = await provider.uploadFile(file, {});
|
|
139
|
+
expect(mockUploadCtor).toHaveBeenCalledWith(expect.objectContaining({
|
|
140
|
+
params: expect.objectContaining({
|
|
141
|
+
Bucket: 'my-bucket',
|
|
142
|
+
ContentType: 'image/jpeg'
|
|
143
|
+
})
|
|
144
|
+
}));
|
|
145
|
+
expect(mockUploadDone).toHaveBeenCalled();
|
|
146
|
+
expect(result.name).toMatch(/photo\.jpg$/);
|
|
147
|
+
expect(result.key).toBe(result.name);
|
|
148
|
+
});
|
|
149
|
+
it('prefixes the key with folderPath when provided', async ()=>{
|
|
150
|
+
const file = buildMulterFile();
|
|
151
|
+
const result = await provider.uploadFile(file, {
|
|
152
|
+
folderPath: 'avatars'
|
|
153
|
+
});
|
|
154
|
+
expect(result.key).toMatch(/^avatars\//);
|
|
155
|
+
});
|
|
156
|
+
it('compresses image files before upload when options.compress is true', async ()=>{
|
|
157
|
+
ImageCompressor.compress.mockResolvedValue({
|
|
158
|
+
buffer: Buffer.from('compressed'),
|
|
159
|
+
format: 'image/webp'
|
|
160
|
+
});
|
|
161
|
+
const file = buildMulterFile();
|
|
162
|
+
const result = await provider.uploadFile(file, {
|
|
163
|
+
compress: true
|
|
164
|
+
});
|
|
165
|
+
expect(ImageCompressor.compress).toHaveBeenCalled();
|
|
166
|
+
expect(result.contentType).toBe('image/webp');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('uploadMultipleFiles', ()=>{
|
|
170
|
+
it('uploads each file and returns metadata for all', async ()=>{
|
|
171
|
+
const files = [
|
|
172
|
+
buildMulterFile({
|
|
173
|
+
originalname: 'a.jpg'
|
|
174
|
+
}),
|
|
175
|
+
buildMulterFile({
|
|
176
|
+
originalname: 'b.jpg'
|
|
177
|
+
})
|
|
178
|
+
];
|
|
179
|
+
const results = await provider.uploadMultipleFiles(files, {});
|
|
180
|
+
expect(results).toHaveLength(2);
|
|
181
|
+
expect(mockUploadCtor).toHaveBeenCalledTimes(2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe('deleteFile', ()=>{
|
|
185
|
+
it('sends a DeleteObjectCommand with bucket and key', async ()=>{
|
|
186
|
+
await provider.deleteFile('some/key.jpg');
|
|
187
|
+
expect(mockS3ClientSend).toHaveBeenCalledWith(expect.any(MockDeleteObjectCommand));
|
|
188
|
+
const sentCommand = mockS3ClientSend.mock.calls[0][0];
|
|
189
|
+
expect(sentCommand.input).toEqual({
|
|
190
|
+
Bucket: 'my-bucket',
|
|
191
|
+
Key: 'some/key.jpg'
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('deleteMultipleFiles', ()=>{
|
|
196
|
+
it('sends a DeleteObjectsCommand containing all keys', async ()=>{
|
|
197
|
+
await provider.deleteMultipleFiles([
|
|
198
|
+
'a.jpg',
|
|
199
|
+
'b.jpg'
|
|
200
|
+
]);
|
|
201
|
+
const sentCommand = mockS3ClientSend.mock.calls[0][0];
|
|
202
|
+
expect(sentCommand.input.Delete.Objects).toEqual([
|
|
203
|
+
{
|
|
204
|
+
Key: 'a.jpg'
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
Key: 'b.jpg'
|
|
208
|
+
}
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe('generatePresignedUrl', ()=>{
|
|
213
|
+
it('delegates to getSignedUrl with the configured expiry', async ()=>{
|
|
214
|
+
mockGetSignedUrl.mockResolvedValue('https://signed.example.com/some/key.jpg');
|
|
215
|
+
const url = await provider.generatePresignedUrl('some/key.jpg', 900);
|
|
216
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.any(MockGetObjectCommand), {
|
|
217
|
+
expiresIn: 900
|
|
218
|
+
});
|
|
219
|
+
expect(url).toBe('https://signed.example.com/some/key.jpg');
|
|
220
|
+
});
|
|
221
|
+
it('defaults expiry to 3600 seconds when not provided', async ()=>{
|
|
222
|
+
mockGetSignedUrl.mockResolvedValue('https://signed.example.com/x');
|
|
223
|
+
await provider.generatePresignedUrl('x');
|
|
224
|
+
expect(mockGetSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
|
|
225
|
+
expiresIn: 3600
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
describe('healthCheck', ()=>{
|
|
230
|
+
it('returns true when HeadBucketCommand succeeds', async ()=>{
|
|
231
|
+
mockS3ClientSend.mockResolvedValue({});
|
|
232
|
+
await expect(provider.healthCheck()).resolves.toBe(true);
|
|
233
|
+
});
|
|
234
|
+
it('returns false when HeadBucketCommand rejects (bucket missing/unauthorized)', async ()=>{
|
|
235
|
+
mockS3ClientSend.mockRejectedValue(new Error('Forbidden'));
|
|
236
|
+
await expect(provider.healthCheck()).resolves.toBe(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|