- копирую модуль upload/ из drhider (слои 1-2) - blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг) - upload_bp: contracts_upload_sink = _store_and_parse - routes: регистрирую create_upload_refs_blueprint(cfg, sink=...) - app.py: корень репо в sys.path (для import upload) - History: план переиспользования + ревью Соннета
345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
// Юнит-тесты фронта модуля 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>'), '<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('Все фронт-тесты прошли');
|