// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты. // Запуск: node upload/frontend/test_upload_frontend.mjs import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; // ── Полифилл File (Node 18 не имеет global File) ── if (typeof globalThis.File === 'undefined') { globalThis.File = class File extends Blob { constructor(parts, name, opts) { super(parts, opts); this.name = name; this.lastModified = (opts && opts.lastModified) || Date.now(); } }; } import { listZipFiles } from './zip/list_zip_files.js'; import { parseZip } from './zip/parse_zip.js'; import { decodeZipName } from './zip/decode_zip_name.js'; import { addFileWithDedup } from './table/add_file_with_dedup.js'; import { esc } from './table/esc.js'; import { fs } from './table/fs.js'; import { fmtSec } from './table/fmt_sec.js'; import { estForFile } from './table/est_for_file.js'; const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md']; // ── CRC32 (для сборки тестовых ZIP) ── const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })(); function crc32(bytes) { let c = 0xffffffff; for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } // ── Сборка минимального ZIP (method 0, UTF-8-флаг) ── function makeZip(entries) { const enc = new TextEncoder(); const chunks = []; const central = []; let offset = 0; const utf8Flag = 0x0800; for (const e of entries) { const nameB = enc.encode(e.name); const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data; const crc = crc32(dataB); const lh = new DataView(new ArrayBuffer(30)); lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, utf8Flag, true); lh.setUint16(8, 0, true); lh.setUint16(10, 0, true); lh.setUint16(12, 0x21, true); lh.setUint32(14, crc, true); lh.setUint32(18, dataB.length, true); lh.setUint32(22, dataB.length, true); lh.setUint16(26, nameB.length, true); lh.setUint16(28, 0, true); chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB)); const cd = new DataView(new ArrayBuffer(46)); cd.setUint32(0, 0x02014b50, true); cd.setUint16(4, 20, true); cd.setUint16(6, 20, true); cd.setUint16(8, utf8Flag, true); cd.setUint16(10, 0, true); cd.setUint16(12, 0, true); cd.setUint16(14, 0x21, true); cd.setUint32(16, crc, true); cd.setUint32(20, dataB.length, true); cd.setUint32(24, dataB.length, true); cd.setUint16(28, nameB.length, true); cd.setUint16(30, 0, true); cd.setUint16(32, 0, true); cd.setUint16(34, 0, true); cd.setUint16(36, 0, true); cd.setUint32(38, 0, true); cd.setUint32(42, offset, true); central.push(Buffer.from(cd.buffer), Buffer.from(nameB)); offset += 30 + nameB.length + dataB.length; } const cdSize = central.reduce((s, x) => s + x.byteLength, 0); const cdStart = offset; const eocd = new DataView(new ArrayBuffer(22)); eocd.setUint32(0, 0x06054b50, true); eocd.setUint16(4, 0, true); eocd.setUint16(6, 0, true); eocd.setUint16(8, entries.length, true); eocd.setUint16(10, entries.length, true); eocd.setUint32(12, cdSize, true); eocd.setUint32(16, cdStart, true); eocd.setUint16(20, 0, true); chunks.push(...central, Buffer.from(eocd.buffer)); return Buffer.concat(chunks); } // ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ── async function deflateRaw(bytes) { // zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw) return deflateRawSync(Buffer.from(bytes)); } async function makeZipDeflate(entries) { const enc = new TextEncoder(); const chunks = []; const central = []; let offset = 0; const utf8Flag = 0x0800; for (const e of entries) { const nameB = enc.encode(e.name); const raw = enc.encode(e.data); const comp = await deflateRaw(raw); const crc = crc32(raw); const lh = new DataView(new ArrayBuffer(30)); lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, utf8Flag, true); lh.setUint16(8, 8, true); // method 8 deflate lh.setUint16(10, 0, true); lh.setUint16(12, 0x21, true); lh.setUint32(14, crc, true); lh.setUint32(18, comp.length, true); lh.setUint32(22, raw.length, true); lh.setUint16(26, nameB.length, true); lh.setUint16(28, 0, true); chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp)); const cd = new DataView(new ArrayBuffer(46)); cd.setUint32(0, 0x02014b50, true); cd.setUint16(4, 20, true); cd.setUint16(6, 20, true); cd.setUint16(8, utf8Flag, true); cd.setUint16(10, 8, true); cd.setUint16(12, 0, true); cd.setUint16(14, 0x21, true); cd.setUint32(16, crc, true); cd.setUint32(20, comp.length, true); cd.setUint32(24, raw.length, true); cd.setUint16(28, nameB.length, true); cd.setUint16(30, 0, true); cd.setUint16(32, 0, true); cd.setUint16(34, 0, true); cd.setUint16(36, 0, true); cd.setUint32(38, 0, true); cd.setUint32(42, offset, true); central.push(Buffer.from(cd.buffer), Buffer.from(nameB)); offset += 30 + nameB.length + comp.length; } const cdSize = central.reduce((s, x) => s + x.byteLength, 0); const cdStart = offset; const eocd = new DataView(new ArrayBuffer(22)); eocd.setUint32(0, 0x06054b50, true); eocd.setUint16(4, 0, true); eocd.setUint16(6, 0, true); eocd.setUint16(8, entries.length, true); eocd.setUint16(10, entries.length, true); eocd.setUint32(12, cdSize, true); eocd.setUint32(16, cdStart, true); eocd.setUint16(20, 0, true); chunks.push(...central, Buffer.from(eocd.buffer)); return Buffer.concat(chunks); } // ── Тесты decodeZipName ── function testDecodeZipName() { const utf8 = new TextEncoder().encode('договор.pdf'); assert.equal(decodeZipName(utf8, true), 'договор.pdf'); // Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть assert.equal(decodeZipName(utf8, false), 'договор.pdf'); console.log(' decodeZipName: ok'); } // ── Тесты listZipFiles ── async function testZipCyrillic() { const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]); const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED); assert.equal(files.length, 1); assert.equal(files[0].name, 'договор.pdf'); console.log(' zip кириллица: ok'); } async function testZipNested() { const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]); const outer = makeZip([ { name: 'inner.zip', data: inner }, { name: 'skip.txt', data: 'x' }, ]); const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED); const names = files.map(f => f.name).sort(); assert.deepEqual(names, ['inner.txt', 'skip.txt']); console.log(' вложенный zip: ok'); } async function testZipNonDoc() { const zip = makeZip([ { name: 'doc.pdf', data: 'pdf' }, { name: 'img.png', data: 'png' }, { name: 'readme.txt', data: 'readme' }, ]); const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED); const names = files.map(f => f.name).sort(); assert.deepEqual(names, ['doc.pdf', 'readme.txt']); console.log(' не-doc фильтр: ok'); } async function testZipDeflate() { // deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет let supported = true; try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; } if (!supported) { console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw'); return; } const content = 'Hello deflate world '.repeat(50); const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]); const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED); assert.equal(files.length, 1); assert.equal(files[0].name, 'deflated.txt'); assert.equal(await files[0].text(), content); console.log(' zip deflate (метод 8): ok'); } async function testParseZipNotZip() { await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/); console.log(' не-ZIP бросок: ok'); } // ── Тесты addFileWithDedup ── function newState() { return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null }; } function testDedup() { const st = newState(); const c = { maxFileBytes: 100, maxSessionBytes: 300 }; // одинаковое имя+размер → дедуп (false), список не растёт assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true); assert.equal(st.files.length, 1); assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false); assert.equal(st.files.length, 1); // имя то же, размер другой → суффикс _2 assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true); assert.equal(st.files.length, 2); assert.equal(st.files[1].name, 'a_2.txt'); console.log(' дедуп/суффиксы: ok'); } function testLimits() { // лимит на один файл const st = newState(); const c = { maxFileBytes: 100, maxSessionBytes: 300 }; addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c); assert.ok(st.overNames.has('big.bin')); assert.equal(st.files.length, 1); // суммарный лимит сессии const st2 = newState(); const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 }; addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2); addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over assert.ok(st2.overNames.has('f2.bin')); console.log(' лимиты over: ok'); } function testSuffixes() { const st = newState(); const c = { maxFileBytes: 10000, maxSessionBytes: 100000 }; addFileWithDedup(st, new File(['aaa'], 'a.txt'), c); addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']); console.log(' суффиксы _2/_3: ok'); } function testEsc() { assert.equal(esc('&\''), '<a href="x">&'</a>'); console.log(' esc (XSS-экранирование): ok'); } function testFs() { assert.equal(fs(0), '0 B'); assert.equal(fs(1023), '1023 B'); assert.equal(fs(1024), '1.0 KB'); assert.equal(fs(1048576), '1.0 MB'); console.log(' fs (размер): ok'); } function testFmtSec() { assert.equal(fmtSec(0), '0с'); assert.equal(fmtSec(59), '59с'); assert.equal(fmtSec(60), '1м 0с'); assert.equal(fmtSec(125), '2м 5с'); console.log(' fmtSec: ok'); } function testEstForFile() { assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с assert.equal(estForFile(null, 12), 0); console.log(' estForFile: ok'); } // ── Прогон ── const TESTS = [ ['decodeZipName', testDecodeZipName], ['zip кириллица', testZipCyrillic], ['вложенный zip', testZipNested], ['не-doc фильтр', testZipNonDoc], ['zip deflate (метод 8)', testZipDeflate], ['не-ZIP бросок', testParseZipNotZip], ['дедуп/суффиксы', testDedup], ['лимиты over', testLimits], ['суффиксы _2/_3', testSuffixes], ['esc XSS', testEsc], ['fs размер', testFs], ['fmtSec', testFmtSec], ['estForFile', testEstForFile], ]; let failed = 0; for (const [name, fn] of TESTS) { try { await fn(); console.log('PASS ' + name); } catch (err) { failed++; console.error('FAIL ' + name + ': ' + err.message); } } if (failed) { console.error(failed + ' тестов упало'); process.exit(1); } console.log('Все фронт-тесты прошли');