|
| 1 | +import { validateMimeTypeAndExtensionMatch } from './validator' |
| 2 | + |
| 3 | +describe('validateMimeTypeAndExtensionMatch', () => { |
| 4 | + describe('valid cases', () => { |
| 5 | + it.each([ |
| 6 | + ['document.txt', 'text/plain'], |
| 7 | + ['page.html', 'text/html'], |
| 8 | + ['data.json', 'application/json'], |
| 9 | + ['document.pdf', 'application/pdf'], |
| 10 | + ['script.js', 'text/javascript'], |
| 11 | + ['script.js', 'application/javascript'], |
| 12 | + ['readme.md', 'text/markdown'], |
| 13 | + ['readme.md', 'text/x-markdown'], |
| 14 | + ['DOCUMENT.TXT', 'text/plain'], |
| 15 | + ['Document.TxT', 'text/plain'], |
| 16 | + ['my.document.txt', 'text/plain'], |
| 17 | + // Image types |
| 18 | + ['photo.jpg', 'image/jpeg'], |
| 19 | + ['photo.jpeg', 'image/jpeg'], // .jpeg should be normalized to .jpg |
| 20 | + ['PHOTO.JPEG', 'image/jpeg'], // Case insensitive and normalization |
| 21 | + ['image.png', 'image/png'], |
| 22 | + ['animation.gif', 'image/gif'], |
| 23 | + ['picture.webp', 'image/webp'], |
| 24 | + ['icon.svg', 'image/svg+xml'], |
| 25 | + ['IMAGE.PNG', 'image/png'], |
| 26 | + // Audio types |
| 27 | + ['audio.webm', 'audio/webm'], |
| 28 | + ['sound.m4a', 'audio/mp4'], |
| 29 | + ['sound.m4a', 'audio/x-m4a'], |
| 30 | + ['music.mp3', 'audio/mpeg'], |
| 31 | + ['music.mp3', 'audio/mp3'], |
| 32 | + ['audio.ogg', 'audio/ogg'], |
| 33 | + ['audio.oga', 'audio/ogg'], // .oga should normalize to ogg |
| 34 | + ['audio.oga', 'audio/oga'], |
| 35 | + ['sound.wav', 'audio/wav'], |
| 36 | + ['sound.wav', 'audio/wave'], |
| 37 | + ['sound.wav', 'audio/x-wav'], |
| 38 | + ['audio.aac', 'audio/aac'], |
| 39 | + ['audio.flac', 'audio/flac'], |
| 40 | + // Video types |
| 41 | + ['video.mp4', 'video/mp4'], |
| 42 | + ['video.webm', 'video/webm'], |
| 43 | + ['movie.mov', 'video/quicktime'], |
| 44 | + ['clip.avi', 'video/x-msvideo'], |
| 45 | + // YAML types |
| 46 | + ['config.yaml', 'application/vnd.yaml'], |
| 47 | + ['config.yaml', 'application/x-yaml'], |
| 48 | + ['config.yaml', 'text/vnd.yaml'], |
| 49 | + ['config.yaml', 'text/x-yaml'], |
| 50 | + ['config.yaml', 'text/yaml'], |
| 51 | + // SQL types |
| 52 | + ['query.sql', 'application/sql'], |
| 53 | + ['query.sql', 'text/x-sql'], |
| 54 | + // Document types |
| 55 | + ['document.rtf', 'application/rtf'], |
| 56 | + // Additional image types |
| 57 | + ['image.tiff', 'image/tiff'], |
| 58 | + ['image.tif', 'image/tiff'], // .tif should normalize to tiff |
| 59 | + ['image.tif', 'image/tif'], |
| 60 | + ['icon.ico', 'image/x-icon'], |
| 61 | + ['icon.ico', 'image/vnd.microsoft.icon'], |
| 62 | + ['photo.avif', 'image/avif'] |
| 63 | + ])('should pass validation for matching MIME type and extension - %s with %s', (filename, mimetype) => { |
| 64 | + expect(() => { |
| 65 | + validateMimeTypeAndExtensionMatch(filename, mimetype) |
| 66 | + }).not.toThrow() |
| 67 | + }) |
| 68 | + }) |
| 69 | + |
| 70 | + describe('invalid filename', () => { |
| 71 | + it.each([ |
| 72 | + ['empty filename', ''], |
| 73 | + ['null filename', null], |
| 74 | + ['undefined filename', undefined], |
| 75 | + ['non-string filename (number)', 123], |
| 76 | + ['object filename', {}] |
| 77 | + ])('should throw error for %s', (_description, filename) => { |
| 78 | + expect(() => { |
| 79 | + validateMimeTypeAndExtensionMatch(filename as unknown as string, 'text/plain') |
| 80 | + }).toThrow('Invalid filename: filename is required and must be a string') |
| 81 | + }) |
| 82 | + }) |
| 83 | + |
| 84 | + describe('invalid MIME type', () => { |
| 85 | + it.each([ |
| 86 | + ['empty MIME type', ''], |
| 87 | + ['null MIME type', null], |
| 88 | + ['undefined MIME type', undefined], |
| 89 | + ['non-string MIME type (number)', 123] |
| 90 | + ])('should throw error for %s', (_description, mimetype) => { |
| 91 | + expect(() => { |
| 92 | + validateMimeTypeAndExtensionMatch('file.txt', mimetype as unknown as string) |
| 93 | + }).toThrow('Invalid MIME type: MIME type is required and must be a string') |
| 94 | + }) |
| 95 | + }) |
| 96 | + |
| 97 | + describe('path traversal detection', () => { |
| 98 | + it.each([ |
| 99 | + ['filename with ..', '../file.txt'], |
| 100 | + ['filename with .. in middle', 'path/../file.txt'], |
| 101 | + ['filename with multle levels of ..', '../../../etc/passwd.txt'], |
| 102 | + ['filename with ..\\..\\..', '..\\..\\..\\windows\\system32\\file.txt'], |
| 103 | + ['filename with ....//....//', '....//....//etc/passwd.txt'], |
| 104 | + ['filename starting with /', '/etc/passwd.txt'], |
| 105 | + ['Windows absolute path', 'C:\\file.txt'], |
| 106 | + ['URL encoded path traversal', '%2e%2e/file.txt'], |
| 107 | + ['URL encoded path traversal multiple levels', '%2e%2e%2f%2e%2e%2f%2e%2e%2ffile.txt'], |
| 108 | + ['null byte', 'file\0.txt'] |
| 109 | + ])('should throw error for %s', (_description, filename) => { |
| 110 | + expect(() => { |
| 111 | + validateMimeTypeAndExtensionMatch(filename, 'text/plain') |
| 112 | + }).toThrow(`Invalid filename: unsafe characters or path traversal attempt detected in filename "${filename}"`) |
| 113 | + }) |
| 114 | + }) |
| 115 | + |
| 116 | + describe('files without extensions', () => { |
| 117 | + it.each([ |
| 118 | + ['filename without extension', 'file'], |
| 119 | + ['filename ending with dot', 'file.'] |
| 120 | + ])('should throw error for %s', (_description, filename) => { |
| 121 | + expect(() => { |
| 122 | + validateMimeTypeAndExtensionMatch(filename, 'text/plain') |
| 123 | + }).toThrow('File type not allowed: files must have a valid file extension') |
| 124 | + }) |
| 125 | + }) |
| 126 | + |
| 127 | + describe('unsupported MIME types', () => { |
| 128 | + it.each([ |
| 129 | + ['application/octet-stream', 'file.txt'], |
| 130 | + ['invalid-mime-type', 'file.txt'], |
| 131 | + ['application/x-msdownload', 'malware.exe'], |
| 132 | + ['application/x-executable', 'script.exe'], |
| 133 | + ['application/x-msdownload', 'program.EXE'], |
| 134 | + ['application/octet-stream', 'script.js'] |
| 135 | + ])('should throw error for unsupported MIME type %s with %s', (mimetype, filename) => { |
| 136 | + expect(() => { |
| 137 | + validateMimeTypeAndExtensionMatch(filename, mimetype) |
| 138 | + }).toThrow(`MIME type "${mimetype}" is not supported or does not have a valid file extension mapping`) |
| 139 | + }) |
| 140 | + }) |
| 141 | + |
| 142 | + describe('MIME type and extension mismatches', () => { |
| 143 | + it.each([ |
| 144 | + // [filename, mimetype, actualExt, expectedExt] |
| 145 | + ['file.txt', 'application/json', 'txt', 'json'], |
| 146 | + ['script.js', 'application/pdf', 'js', 'pdf'], |
| 147 | + ['page.html', 'text/plain', 'html', 'txt'], |
| 148 | + ['document.pdf', 'application/json', 'pdf', 'json'], |
| 149 | + ['data.json', 'text/plain', 'json', 'txt'], |
| 150 | + ['malware.exe', 'text/plain', 'exe', 'txt'], |
| 151 | + ['script.js', 'application/json', 'js', 'json'], |
| 152 | + // Image/audio mismatches |
| 153 | + ['photo.jpg', 'image/png', 'jpg', 'png'], |
| 154 | + ['image.png', 'image/jpeg', 'png', 'jpg'], |
| 155 | + ['audio.mp3', 'audio/wav', 'mp3', 'wav'], |
| 156 | + ['sound.wav', 'audio/mpeg', 'wav', 'mp3'], |
| 157 | + // New type mismatches |
| 158 | + ['config.yaml', 'application/json', 'yaml', 'json'], |
| 159 | + ['query.sql', 'text/plain', 'sql', 'txt'], |
| 160 | + ['document.rtf', 'application/pdf', 'rtf', 'pdf'], |
| 161 | + ['video.mp4', 'video/webm', 'mp4', 'webm'], |
| 162 | + ['image.tiff', 'image/png', 'tiff', 'png'], |
| 163 | + ['icon.ico', 'image/png', 'ico', 'png'] |
| 164 | + ])('should throw error when extension does not match MIME type - %s with %s', (filename, mimetype, actualExt, expectedExt) => { |
| 165 | + expect(() => { |
| 166 | + validateMimeTypeAndExtensionMatch(filename, mimetype) |
| 167 | + }).toThrow( |
| 168 | + `MIME type mismatch: file extension "${actualExt}" does not match declared MIME type "${mimetype}". Expected: ${expectedExt}` |
| 169 | + ) |
| 170 | + }) |
| 171 | + }) |
| 172 | +}) |
0 commit comments