@flusys/nestjs-storage 6.0.1 → 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,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _filevalidatorutil = require("./file-validator.util");
|
|
6
|
+
const JPEG_BYTES = Buffer.from([
|
|
7
|
+
0xff,
|
|
8
|
+
0xd8,
|
|
9
|
+
0xff,
|
|
10
|
+
0xe0,
|
|
11
|
+
0x00,
|
|
12
|
+
0x10
|
|
13
|
+
]);
|
|
14
|
+
const PNG_BYTES = Buffer.from([
|
|
15
|
+
0x89,
|
|
16
|
+
0x50,
|
|
17
|
+
0x4e,
|
|
18
|
+
0x47,
|
|
19
|
+
0x0d,
|
|
20
|
+
0x0a,
|
|
21
|
+
0x1a,
|
|
22
|
+
0x0a,
|
|
23
|
+
0x00,
|
|
24
|
+
0x00
|
|
25
|
+
]);
|
|
26
|
+
const PDF_BYTES = Buffer.from([
|
|
27
|
+
0x25,
|
|
28
|
+
0x50,
|
|
29
|
+
0x44,
|
|
30
|
+
0x46,
|
|
31
|
+
0x2d,
|
|
32
|
+
0x31,
|
|
33
|
+
0x2e,
|
|
34
|
+
0x34
|
|
35
|
+
]);
|
|
36
|
+
const ZIP_BYTES = Buffer.from([
|
|
37
|
+
0x50,
|
|
38
|
+
0x4b,
|
|
39
|
+
0x03,
|
|
40
|
+
0x04,
|
|
41
|
+
0x00,
|
|
42
|
+
0x00
|
|
43
|
+
]);
|
|
44
|
+
const GIF87_BYTES = Buffer.from([
|
|
45
|
+
0x47,
|
|
46
|
+
0x49,
|
|
47
|
+
0x46,
|
|
48
|
+
0x38,
|
|
49
|
+
0x37,
|
|
50
|
+
0x61
|
|
51
|
+
]);
|
|
52
|
+
const UNKNOWN_BYTES = Buffer.from([
|
|
53
|
+
0x01,
|
|
54
|
+
0x02,
|
|
55
|
+
0x03,
|
|
56
|
+
0x04
|
|
57
|
+
]);
|
|
58
|
+
describe('FileValidator', ()=>{
|
|
59
|
+
describe('detectFileType', ()=>{
|
|
60
|
+
it('detects JPEG from magic bytes', ()=>{
|
|
61
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(JPEG_BYTES)).toBe('image/jpeg');
|
|
62
|
+
});
|
|
63
|
+
it('detects PNG from magic bytes', ()=>{
|
|
64
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(PNG_BYTES)).toBe('image/png');
|
|
65
|
+
});
|
|
66
|
+
it('detects PDF from magic bytes', ()=>{
|
|
67
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(PDF_BYTES)).toBe('application/pdf');
|
|
68
|
+
});
|
|
69
|
+
it('detects ZIP-based formats (docx/xlsx/pptx share this signature)', ()=>{
|
|
70
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(ZIP_BYTES)).toBe('application/zip');
|
|
71
|
+
});
|
|
72
|
+
it('detects legacy GIF87a signature', ()=>{
|
|
73
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(GIF87_BYTES)).toBe('image/gif');
|
|
74
|
+
});
|
|
75
|
+
it('returns null for unknown/unrecognized signatures', ()=>{
|
|
76
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(UNKNOWN_BYTES)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
it('returns null for a buffer shorter than any known signature', ()=>{
|
|
79
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(Buffer.from([
|
|
80
|
+
0xff
|
|
81
|
+
]))).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
it('returns null for an empty buffer', ()=>{
|
|
84
|
+
expect(_filevalidatorutil.FileValidator.detectFileType(Buffer.alloc(0))).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('isTextBasedType', ()=>{
|
|
88
|
+
it('recognizes plain text and structured text types', ()=>{
|
|
89
|
+
expect(_filevalidatorutil.FileValidator.isTextBasedType('text/plain')).toBe(true);
|
|
90
|
+
expect(_filevalidatorutil.FileValidator.isTextBasedType('application/json')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('rejects a non text-based type', ()=>{
|
|
93
|
+
expect(_filevalidatorutil.FileValidator.isTextBasedType('image/png')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('isDangerousTextType', ()=>{
|
|
97
|
+
it('flags text/html as dangerous', ()=>{
|
|
98
|
+
expect(_filevalidatorutil.FileValidator.isDangerousTextType('text/html')).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it('flags image/svg+xml as dangerous (can embed scripts)', ()=>{
|
|
101
|
+
expect(_filevalidatorutil.FileValidator.isDangerousTextType('image/svg+xml')).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
it('does not flag a safe type', ()=>{
|
|
104
|
+
expect(_filevalidatorutil.FileValidator.isDangerousTextType('image/png')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('mimeTypesMatch', ()=>{
|
|
108
|
+
it('matches identical types', ()=>{
|
|
109
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('image/png', 'image/png')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it('matches known aliases (jpeg <-> jpg)', ()=>{
|
|
112
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('image/jpeg', 'image/jpg')).toBe(true);
|
|
113
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('image/jpg', 'image/jpeg')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it('matches same broad category (image/* vs image/*)', ()=>{
|
|
116
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('image/png', 'image/webp')).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
it('matches ZIP-detected content against an office document declared type', ()=>{
|
|
119
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('application/zip', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('does not match unrelated categories', ()=>{
|
|
122
|
+
expect(_filevalidatorutil.FileValidator.mimeTypesMatch('image/png', 'application/pdf')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('isTypeAllowed', ()=>{
|
|
126
|
+
it('allows anything when wildcard */* is present', ()=>{
|
|
127
|
+
expect(_filevalidatorutil.FileValidator.isTypeAllowed('application/pdf', [
|
|
128
|
+
'*/*'
|
|
129
|
+
])).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it('allows category wildcard image/*', ()=>{
|
|
132
|
+
expect(_filevalidatorutil.FileValidator.isTypeAllowed('image/png', [
|
|
133
|
+
'image/*'
|
|
134
|
+
])).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
it('rejects a type outside the category wildcard', ()=>{
|
|
137
|
+
expect(_filevalidatorutil.FileValidator.isTypeAllowed('application/pdf', [
|
|
138
|
+
'image/*'
|
|
139
|
+
])).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
it('allows exact match', ()=>{
|
|
142
|
+
expect(_filevalidatorutil.FileValidator.isTypeAllowed('application/pdf', [
|
|
143
|
+
'application/pdf'
|
|
144
|
+
])).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
it('rejects when not in the explicit allow-list', ()=>{
|
|
147
|
+
expect(_filevalidatorutil.FileValidator.isTypeAllowed('application/pdf', [
|
|
148
|
+
'image/png',
|
|
149
|
+
'image/jpeg'
|
|
150
|
+
])).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('validateFileContent', ()=>{
|
|
154
|
+
it('passes when magic bytes match declared type and type is allowed', ()=>{
|
|
155
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(JPEG_BYTES, 'image/jpeg', [
|
|
156
|
+
'image/*'
|
|
157
|
+
]);
|
|
158
|
+
expect(result.valid).toBe(true);
|
|
159
|
+
expect(result.detectedType).toBe('image/jpeg');
|
|
160
|
+
});
|
|
161
|
+
it('fails when declared type does not match detected content (MIME spoofing)', ()=>{
|
|
162
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(PNG_BYTES, 'application/pdf', [
|
|
163
|
+
'*/*'
|
|
164
|
+
]);
|
|
165
|
+
expect(result.valid).toBe(false);
|
|
166
|
+
expect(result.message).toContain('does not match declared type');
|
|
167
|
+
});
|
|
168
|
+
it('fails when detected type is not in the allow-list', ()=>{
|
|
169
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(PDF_BYTES, 'application/pdf', [
|
|
170
|
+
'image/*'
|
|
171
|
+
]);
|
|
172
|
+
expect(result.valid).toBe(false);
|
|
173
|
+
expect(result.message).toContain('not allowed');
|
|
174
|
+
});
|
|
175
|
+
it('trusts declared type for safe text-based content without magic bytes', ()=>{
|
|
176
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(Buffer.from('name,age\nJohn,30'), 'text/csv', [
|
|
177
|
+
'text/*'
|
|
178
|
+
]);
|
|
179
|
+
expect(result.valid).toBe(true);
|
|
180
|
+
expect(result.detectedType).toBe('text/csv');
|
|
181
|
+
});
|
|
182
|
+
it('rejects dangerous text/html content unless explicitly allow-listed', ()=>{
|
|
183
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(Buffer.from('<html><body>hi</body></html>'), 'text/html', [
|
|
184
|
+
'*/*'
|
|
185
|
+
]);
|
|
186
|
+
expect(result.valid).toBe(false);
|
|
187
|
+
expect(result.message).toContain('potentially dangerous');
|
|
188
|
+
});
|
|
189
|
+
it('rejects image/svg+xml by default (can contain embedded scripts)', ()=>{
|
|
190
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(Buffer.from('<svg onload="alert(1)"></svg>'), 'image/svg+xml', [
|
|
191
|
+
'*/*'
|
|
192
|
+
]);
|
|
193
|
+
expect(result.valid).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
it('still fails text/html even when explicitly allow-listed, since it is not in TEXT_BASED_TYPES', ()=>{
|
|
196
|
+
// NOTE: validateUndetectedType() skips the "dangerous type" rejection when the type is
|
|
197
|
+
// explicitly allow-listed, but text/html is absent from TEXT_BASED_TYPES so it falls through
|
|
198
|
+
// to the generic "Unable to verify file type" failure. Documenting actual behavior here;
|
|
199
|
+
// see final report for this being flagged as a potential production logic quirk.
|
|
200
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(Buffer.from('<html></html>'), 'text/html', [
|
|
201
|
+
'text/html'
|
|
202
|
+
]);
|
|
203
|
+
expect(result.valid).toBe(false);
|
|
204
|
+
expect(result.message).toContain('Unable to verify file type');
|
|
205
|
+
});
|
|
206
|
+
it('fails for binary content with no recognizable signature and no text fallback', ()=>{
|
|
207
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(UNKNOWN_BYTES, 'application/octet-stream', [
|
|
208
|
+
'*/*'
|
|
209
|
+
]);
|
|
210
|
+
expect(result.valid).toBe(false);
|
|
211
|
+
expect(result.message).toContain('Unable to verify file type');
|
|
212
|
+
});
|
|
213
|
+
it('defaults allowedTypes to */* when not provided', ()=>{
|
|
214
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(JPEG_BYTES, 'image/jpeg');
|
|
215
|
+
expect(result.valid).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
it('returns a failure result instead of throwing on unexpected internal errors', ()=>{
|
|
218
|
+
// Passing a non-buffer-like value to force detectFileType internals to misbehave gracefully
|
|
219
|
+
const brokenBuffer = {
|
|
220
|
+
length: 10
|
|
221
|
+
};
|
|
222
|
+
const result = _filevalidatorutil.FileValidator.validateFileContent(brokenBuffer, 'image/jpeg', [
|
|
223
|
+
'*/*'
|
|
224
|
+
]);
|
|
225
|
+
expect(result.valid).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe('sanitizeFilename', ()=>{
|
|
229
|
+
it('strips directory components to prevent path traversal', ()=>{
|
|
230
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('../../etc/passwd')).toBe('passwd');
|
|
231
|
+
});
|
|
232
|
+
it('strips Windows-style directory components', ()=>{
|
|
233
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('..\\..\\windows\\system32\\evil.exe')).toBe('evil.exe');
|
|
234
|
+
});
|
|
235
|
+
it('removes null bytes', ()=>{
|
|
236
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('file\0.txt')).toBe('file.txt');
|
|
237
|
+
});
|
|
238
|
+
it('collapses multiple consecutive dots into one', ()=>{
|
|
239
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('archive....tar.gz')).toBe('archive.tar.gz');
|
|
240
|
+
});
|
|
241
|
+
it('replaces special/unsafe characters with underscores', ()=>{
|
|
242
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('my file (final)!.txt')).toBe('my_file__final__.txt');
|
|
243
|
+
});
|
|
244
|
+
it('truncates filenames longer than 255 characters', ()=>{
|
|
245
|
+
const longName = `${'a'.repeat(300)}.txt`;
|
|
246
|
+
const sanitized = _filevalidatorutil.FileValidator.sanitizeFilename(longName);
|
|
247
|
+
expect(sanitized.length).toBeLessThanOrEqual(255);
|
|
248
|
+
});
|
|
249
|
+
it('preserves a simple safe filename unchanged', ()=>{
|
|
250
|
+
expect(_filevalidatorutil.FileValidator.sanitizeFilename('report-2024_final.pdf')).toBe('report-2024_final.pdf');
|
|
251
|
+
});
|
|
252
|
+
it('neutralizes an absolute-path traversal attempt embedded mid-string', ()=>{
|
|
253
|
+
const malicious = '/var/www/../../etc/passwd';
|
|
254
|
+
const sanitized = _filevalidatorutil.FileValidator.sanitizeFilename(malicious);
|
|
255
|
+
expect(sanitized).not.toContain('/');
|
|
256
|
+
expect(sanitized).not.toContain('..');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _imagecompressorutil = require("./image-compressor.util");
|
|
6
|
+
const mockToBuffer = jest.fn();
|
|
7
|
+
const mockJpeg = jest.fn();
|
|
8
|
+
const mockPng = jest.fn();
|
|
9
|
+
const mockWebp = jest.fn();
|
|
10
|
+
const mockAvif = jest.fn();
|
|
11
|
+
const mockTiff = jest.fn();
|
|
12
|
+
const mockGif = jest.fn();
|
|
13
|
+
const mockJp2 = jest.fn();
|
|
14
|
+
const mockResize = jest.fn();
|
|
15
|
+
function buildChainableImage() {
|
|
16
|
+
const image = {};
|
|
17
|
+
image.resize = mockResize.mockReturnValue(image);
|
|
18
|
+
image.jpeg = mockJpeg.mockReturnValue(image);
|
|
19
|
+
image.png = mockPng.mockReturnValue(image);
|
|
20
|
+
image.webp = mockWebp.mockReturnValue(image);
|
|
21
|
+
image.avif = mockAvif.mockReturnValue(image);
|
|
22
|
+
image.tiff = mockTiff.mockReturnValue(image);
|
|
23
|
+
image.gif = mockGif.mockReturnValue(image);
|
|
24
|
+
image.jp2 = mockJp2.mockReturnValue(image);
|
|
25
|
+
image.toBuffer = mockToBuffer;
|
|
26
|
+
return image;
|
|
27
|
+
}
|
|
28
|
+
const mockSharpFactory = jest.fn();
|
|
29
|
+
jest.mock('sharp', ()=>{
|
|
30
|
+
const factory = (...args)=>mockSharpFactory(...args);
|
|
31
|
+
return {
|
|
32
|
+
__esModule: true,
|
|
33
|
+
default: factory
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
describe('ImageCompressor', ()=>{
|
|
37
|
+
beforeEach(()=>{
|
|
38
|
+
jest.clearAllMocks();
|
|
39
|
+
mockSharpFactory.mockImplementation(()=>buildChainableImage());
|
|
40
|
+
mockToBuffer.mockResolvedValue(Buffer.from('compressed-image-data'));
|
|
41
|
+
});
|
|
42
|
+
it('returns the buffer unchanged for image/svg+xml without invoking sharp', async ()=>{
|
|
43
|
+
const buffer = Buffer.from('<svg></svg>');
|
|
44
|
+
const result = await _imagecompressorutil.ImageCompressor.compress(buffer, 'image/svg+xml');
|
|
45
|
+
expect(result.buffer).toBe(buffer);
|
|
46
|
+
expect(result.format).toBe('image/svg+xml');
|
|
47
|
+
expect(mockSharpFactory).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
it('resizes using default maxWidth/maxHeight when no options are provided', async ()=>{
|
|
50
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('jpeg-bytes'), 'image/jpeg');
|
|
51
|
+
expect(mockResize).toHaveBeenCalledWith(expect.objectContaining({
|
|
52
|
+
width: 1280,
|
|
53
|
+
height: 1280,
|
|
54
|
+
fit: 'inside',
|
|
55
|
+
withoutEnlargement: true
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
it('resizes using custom maxWidth/maxHeight when provided', async ()=>{
|
|
59
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('jpeg-bytes'), 'image/jpeg', {
|
|
60
|
+
maxWidth: 500,
|
|
61
|
+
maxHeight: 600
|
|
62
|
+
});
|
|
63
|
+
expect(mockResize).toHaveBeenCalledWith(expect.objectContaining({
|
|
64
|
+
width: 500,
|
|
65
|
+
height: 600
|
|
66
|
+
}));
|
|
67
|
+
});
|
|
68
|
+
it('preserves the original format (derived from mimetype) when format is "original"', async ()=>{
|
|
69
|
+
const result = await _imagecompressorutil.ImageCompressor.compress(Buffer.from('png-bytes'), 'image/png', {
|
|
70
|
+
format: 'original'
|
|
71
|
+
});
|
|
72
|
+
expect(mockPng).toHaveBeenCalled();
|
|
73
|
+
expect(result.format).toBe('image/png');
|
|
74
|
+
});
|
|
75
|
+
it('applies jpeg-specific compression options with quality', async ()=>{
|
|
76
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/jpeg', {
|
|
77
|
+
quality: 70
|
|
78
|
+
});
|
|
79
|
+
expect(mockJpeg).toHaveBeenCalledWith(expect.objectContaining({
|
|
80
|
+
quality: 70,
|
|
81
|
+
mozjpeg: true
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
it('applies png-specific compression options', async ()=>{
|
|
85
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/png', {
|
|
86
|
+
quality: 90
|
|
87
|
+
});
|
|
88
|
+
expect(mockPng).toHaveBeenCalledWith(expect.objectContaining({
|
|
89
|
+
quality: 90,
|
|
90
|
+
colors: 256
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
it('applies webp-specific options and sets lossless=true only when quality is 100', async ()=>{
|
|
94
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/webp', {
|
|
95
|
+
quality: 100,
|
|
96
|
+
format: 'webp'
|
|
97
|
+
});
|
|
98
|
+
expect(mockWebp).toHaveBeenCalledWith(expect.objectContaining({
|
|
99
|
+
quality: 100,
|
|
100
|
+
lossless: true
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
it('applies webp-specific options with lossless=false for quality < 100', async ()=>{
|
|
104
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/webp', {
|
|
105
|
+
quality: 80,
|
|
106
|
+
format: 'webp'
|
|
107
|
+
});
|
|
108
|
+
expect(mockWebp).toHaveBeenCalledWith(expect.objectContaining({
|
|
109
|
+
quality: 80,
|
|
110
|
+
lossless: false
|
|
111
|
+
}));
|
|
112
|
+
});
|
|
113
|
+
it('applies avif-specific compression options', async ()=>{
|
|
114
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/avif', {
|
|
115
|
+
format: 'avif'
|
|
116
|
+
});
|
|
117
|
+
expect(mockAvif).toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
it('applies tiff-specific compression options', async ()=>{
|
|
120
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/tiff', {
|
|
121
|
+
format: 'tiff'
|
|
122
|
+
});
|
|
123
|
+
expect(mockTiff).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
it('applies gif-specific compression options', async ()=>{
|
|
126
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/gif', {
|
|
127
|
+
format: 'gif'
|
|
128
|
+
});
|
|
129
|
+
expect(mockGif).toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
it('applies jp2-specific compression options', async ()=>{
|
|
132
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/jp2', {
|
|
133
|
+
format: 'jp2'
|
|
134
|
+
});
|
|
135
|
+
expect(mockJp2).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
it('falls back to webp for an unrecognized target format', async ()=>{
|
|
138
|
+
const result = await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/unknownformat', {
|
|
139
|
+
format: 'original'
|
|
140
|
+
});
|
|
141
|
+
expect(mockWebp).toHaveBeenCalled();
|
|
142
|
+
expect(result.format).toBe('image/webp');
|
|
143
|
+
});
|
|
144
|
+
it('returns the compressed buffer and content-type prefixed format on success', async ()=>{
|
|
145
|
+
mockToBuffer.mockResolvedValue(Buffer.from('final-bytes'));
|
|
146
|
+
const result = await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/jpeg', {
|
|
147
|
+
format: 'jpeg'
|
|
148
|
+
});
|
|
149
|
+
expect(result.buffer).toEqual(Buffer.from('final-bytes'));
|
|
150
|
+
expect(result.format).toBe('image/jpeg');
|
|
151
|
+
});
|
|
152
|
+
it('falls back to the original buffer and mimetype when sharp processing throws', async ()=>{
|
|
153
|
+
const originalBuffer = Buffer.from('original-bytes');
|
|
154
|
+
mockToBuffer.mockRejectedValue(new Error('corrupt image'));
|
|
155
|
+
const result = await _imagecompressorutil.ImageCompressor.compress(originalBuffer, 'image/png');
|
|
156
|
+
expect(result.buffer).toBe(originalBuffer);
|
|
157
|
+
expect(result.format).toBe('image/png');
|
|
158
|
+
});
|
|
159
|
+
it('initializes sharp with failOnError=false and a density hint', async ()=>{
|
|
160
|
+
await _imagecompressorutil.ImageCompressor.compress(Buffer.from('bytes'), 'image/jpeg');
|
|
161
|
+
expect(mockSharpFactory).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
|
|
162
|
+
failOnError: false,
|
|
163
|
+
density: 300
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
3
|
+
import { FileManagerController } from './file-manager.controller';
|
|
4
|
+
function buildFile(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'file-1',
|
|
7
|
+
name: 'report.pdf',
|
|
8
|
+
key: 'files/report.pdf',
|
|
9
|
+
size: 1024,
|
|
10
|
+
contentType: 'application/pdf',
|
|
11
|
+
isActive: true,
|
|
12
|
+
readOnly: false,
|
|
13
|
+
createdAt: new Date(),
|
|
14
|
+
updatedAt: new Date(),
|
|
15
|
+
...overrides
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('FileManagerController', ()=>{
|
|
19
|
+
let controller;
|
|
20
|
+
let mockFileService;
|
|
21
|
+
beforeEach(()=>{
|
|
22
|
+
mockFileService = {
|
|
23
|
+
insert: jest.fn(),
|
|
24
|
+
findById: jest.fn(),
|
|
25
|
+
getAll: jest.fn(),
|
|
26
|
+
update: jest.fn(),
|
|
27
|
+
delete: jest.fn(),
|
|
28
|
+
getFiles: jest.fn()
|
|
29
|
+
};
|
|
30
|
+
controller = new FileManagerController(mockFileService);
|
|
31
|
+
});
|
|
32
|
+
describe('insert', ()=>{
|
|
33
|
+
it('creates a file record and returns the mapped response', async ()=>{
|
|
34
|
+
const user = buildMockUser();
|
|
35
|
+
mockFileService.insert.mockResolvedValue(buildFile());
|
|
36
|
+
const result = await controller.insert({
|
|
37
|
+
name: 'report.pdf'
|
|
38
|
+
}, user);
|
|
39
|
+
expect(mockFileService.insert).toHaveBeenCalledWith({
|
|
40
|
+
name: 'report.pdf'
|
|
41
|
+
}, user);
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
44
|
+
id: 'file-1'
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('getAll', ()=>{
|
|
49
|
+
it('paginates files and maps the response list', async ()=>{
|
|
50
|
+
mockFileService.getAll.mockResolvedValue({
|
|
51
|
+
data: [
|
|
52
|
+
buildFile()
|
|
53
|
+
],
|
|
54
|
+
total: 1
|
|
55
|
+
});
|
|
56
|
+
const result = await controller.getAll({}, buildMockUser(), '');
|
|
57
|
+
expect(mockFileService.getAll).toHaveBeenCalledWith('', {}, buildMockUser());
|
|
58
|
+
expect(result.data).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('getFiles', ()=>{
|
|
62
|
+
it('resolves signed/public URLs for the requested files using the request protocol and host', async ()=>{
|
|
63
|
+
const user = buildMockUser();
|
|
64
|
+
const req = {
|
|
65
|
+
protocol: 'https',
|
|
66
|
+
hostname: 'app.example.com',
|
|
67
|
+
get: jest.fn().mockReturnValue('app.example.com')
|
|
68
|
+
};
|
|
69
|
+
mockFileService.getFiles.mockResolvedValue([
|
|
70
|
+
{
|
|
71
|
+
id: 'file-1',
|
|
72
|
+
url: 'https://app.example.com/storage/upload/file/report.pdf'
|
|
73
|
+
}
|
|
74
|
+
]);
|
|
75
|
+
const result = await controller.getFiles([
|
|
76
|
+
{
|
|
77
|
+
id: 'file-1'
|
|
78
|
+
}
|
|
79
|
+
], req, user);
|
|
80
|
+
expect(mockFileService.getFiles).toHaveBeenCalledWith([
|
|
81
|
+
{
|
|
82
|
+
id: 'file-1'
|
|
83
|
+
}
|
|
84
|
+
], 'https', 'app.example.com', user);
|
|
85
|
+
expect(result.success).toBe(true);
|
|
86
|
+
expect(result.data).toEqual([
|
|
87
|
+
{
|
|
88
|
+
id: 'file-1',
|
|
89
|
+
url: 'https://app.example.com/storage/upload/file/report.pdf'
|
|
90
|
+
}
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
it('falls back to req.hostname when the host header is absent', async ()=>{
|
|
94
|
+
const user = buildMockUser();
|
|
95
|
+
const req = {
|
|
96
|
+
protocol: 'http',
|
|
97
|
+
hostname: 'localhost',
|
|
98
|
+
get: jest.fn().mockReturnValue(undefined)
|
|
99
|
+
};
|
|
100
|
+
mockFileService.getFiles.mockResolvedValue([]);
|
|
101
|
+
await controller.getFiles([
|
|
102
|
+
{
|
|
103
|
+
id: 'file-1'
|
|
104
|
+
}
|
|
105
|
+
], req, user);
|
|
106
|
+
expect(mockFileService.getFiles).toHaveBeenCalledWith([
|
|
107
|
+
{
|
|
108
|
+
id: 'file-1'
|
|
109
|
+
}
|
|
110
|
+
], 'http', 'localhost', user);
|
|
111
|
+
});
|
|
112
|
+
it('rejects an empty file id list without calling the service', async ()=>{
|
|
113
|
+
const user = buildMockUser();
|
|
114
|
+
const req = {
|
|
115
|
+
protocol: 'https',
|
|
116
|
+
get: jest.fn()
|
|
117
|
+
};
|
|
118
|
+
await expect(controller.getFiles([], req, user)).rejects.toThrow(BadRequestException);
|
|
119
|
+
expect(mockFileService.getFiles).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
it('rejects a null/undefined dto without calling the service', async ()=>{
|
|
122
|
+
const user = buildMockUser();
|
|
123
|
+
const req = {
|
|
124
|
+
protocol: 'https',
|
|
125
|
+
get: jest.fn()
|
|
126
|
+
};
|
|
127
|
+
await expect(controller.getFiles(undefined, req, user)).rejects.toThrow(BadRequestException);
|
|
128
|
+
expect(mockFileService.getFiles).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
2
|
+
import { FolderController } from './folder.controller';
|
|
3
|
+
function buildFolder(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'folder-1',
|
|
6
|
+
name: 'Documents',
|
|
7
|
+
parentId: null,
|
|
8
|
+
isActive: true,
|
|
9
|
+
readOnly: false,
|
|
10
|
+
createdAt: new Date(),
|
|
11
|
+
updatedAt: new Date(),
|
|
12
|
+
...overrides
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe('FolderController', ()=>{
|
|
16
|
+
let controller;
|
|
17
|
+
let mockFolderService;
|
|
18
|
+
beforeEach(()=>{
|
|
19
|
+
mockFolderService = {
|
|
20
|
+
insert: jest.fn(),
|
|
21
|
+
findById: jest.fn(),
|
|
22
|
+
getAll: jest.fn(),
|
|
23
|
+
update: jest.fn(),
|
|
24
|
+
delete: jest.fn()
|
|
25
|
+
};
|
|
26
|
+
controller = new FolderController(mockFolderService);
|
|
27
|
+
});
|
|
28
|
+
it('wires the injected service onto the generated controller base', ()=>{
|
|
29
|
+
expect(controller.folderService).toBe(mockFolderService);
|
|
30
|
+
expect(controller.service).toBe(mockFolderService);
|
|
31
|
+
});
|
|
32
|
+
describe('insert', ()=>{
|
|
33
|
+
it('creates a folder and returns the mapped response', async ()=>{
|
|
34
|
+
const user = buildMockUser();
|
|
35
|
+
mockFolderService.insert.mockResolvedValue(buildFolder());
|
|
36
|
+
const result = await controller.insert({
|
|
37
|
+
name: 'Documents'
|
|
38
|
+
}, user);
|
|
39
|
+
expect(mockFolderService.insert).toHaveBeenCalledWith({
|
|
40
|
+
name: 'Documents'
|
|
41
|
+
}, user);
|
|
42
|
+
expect(result.success).toBe(true);
|
|
43
|
+
expect(result.data).toEqual(expect.objectContaining({
|
|
44
|
+
id: 'folder-1',
|
|
45
|
+
name: 'Documents'
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('getAll', ()=>{
|
|
50
|
+
it('paginates folders and maps the response list', async ()=>{
|
|
51
|
+
mockFolderService.getAll.mockResolvedValue({
|
|
52
|
+
data: [
|
|
53
|
+
buildFolder()
|
|
54
|
+
],
|
|
55
|
+
total: 1
|
|
56
|
+
});
|
|
57
|
+
const result = await controller.getAll({}, buildMockUser(), '');
|
|
58
|
+
expect(mockFolderService.getAll).toHaveBeenCalledWith('', {}, buildMockUser());
|
|
59
|
+
expect(result.data).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('delete', ()=>{
|
|
63
|
+
it('delegates soft-delete to the service', async ()=>{
|
|
64
|
+
mockFolderService.delete.mockResolvedValue({
|
|
65
|
+
count: 1
|
|
66
|
+
});
|
|
67
|
+
const result = await controller.delete({
|
|
68
|
+
id: [
|
|
69
|
+
'folder-1'
|
|
70
|
+
],
|
|
71
|
+
type: 'delete'
|
|
72
|
+
}, buildMockUser());
|
|
73
|
+
expect(mockFolderService.delete).toHaveBeenCalledWith({
|
|
74
|
+
id: [
|
|
75
|
+
'folder-1'
|
|
76
|
+
],
|
|
77
|
+
type: 'delete'
|
|
78
|
+
}, buildMockUser());
|
|
79
|
+
expect(result.success).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|