95 lines
4.9 KiB
TypeScript
95 lines
4.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ImportDocument } from '../../src/domain/import-intermediate';
|
|
import { ImportError } from '../../src/shared/errors';
|
|
import {
|
|
resolveImportResourcePolicy,
|
|
validateDecodedTextResources,
|
|
validateImportDocumentResources,
|
|
validateImportSourceResources
|
|
} from '../../src/importers/resource-policy';
|
|
|
|
function zipWithDeclaredSizes(compressed: number, uncompressed: number, name = '', flags = 0): Uint8Array {
|
|
const encodedName = new TextEncoder().encode(name);
|
|
const endOffset = 46 + encodedName.length;
|
|
const bytes = new Uint8Array(endOffset + 22);
|
|
const view = new DataView(bytes.buffer);
|
|
view.setUint32(0, 0x02014b50, true);
|
|
view.setUint16(8, flags, true);
|
|
view.setUint16(10, 8, true);
|
|
view.setUint32(20, compressed, true);
|
|
view.setUint32(24, uncompressed, true);
|
|
view.setUint16(28, encodedName.length, true);
|
|
bytes.set(encodedName, 46);
|
|
view.setUint32(endOffset, 0x06054b50, true);
|
|
view.setUint16(endOffset + 8, 1, true);
|
|
view.setUint16(endOffset + 10, 1, true);
|
|
view.setUint32(endOffset + 12, 46 + encodedName.length, true);
|
|
view.setUint32(endOffset + 16, 0, true);
|
|
return bytes;
|
|
}
|
|
|
|
function errorCode(action: () => void): string | undefined {
|
|
try { action(); } catch (error) { return error instanceof ImportError ? error.diagnostics[0]?.code : undefined; }
|
|
return undefined;
|
|
}
|
|
|
|
describe('import resource policy', () => {
|
|
it('rejects byte mismatches, oversized files and suspicious ZIP compression before parsing', () => {
|
|
const policy = resolveImportResourcePolicy({ maximumFileBytes: 10, maximumZipCompressionRatio: 10 });
|
|
expect(errorCode(() => validateImportSourceResources({
|
|
metadata: { name: 'x.csv', sizeBytes: 2, lastModified: 0 }, bytes: new Uint8Array(1)
|
|
}, policy))).toBe('file-size-mismatch');
|
|
expect(errorCode(() => validateImportSourceResources({
|
|
metadata: { name: 'x.csv', sizeBytes: 11, lastModified: 0 }, bytes: new Uint8Array(11)
|
|
}, policy))).toBe('file-size-limit');
|
|
const zip = zipWithDeclaredSizes(1, 1_000);
|
|
expect(errorCode(() => validateImportSourceResources({
|
|
metadata: { name: 'bomb.xlsx', sizeBytes: zip.length, lastModified: 0 }, bytes: zip
|
|
}, { ...policy, maximumFileBytes: 10_000, maximumZipEntryUncompressedBytes: 10_000, maximumZipUncompressedBytes: 10_000 })))
|
|
.toBe('zip-compression-ratio-limit');
|
|
});
|
|
|
|
it('rejects encrypted, escaping and VBA-bearing Excel archives', () => {
|
|
const policy = resolveImportResourcePolicy();
|
|
for (const [name, flags, expectedCode] of [
|
|
['xl/workbook.xml', 1, 'zip-encryption-rejected'],
|
|
['../xl/workbook.xml', 0, 'zip-entry-path-rejected'],
|
|
['xl/vbaProject.bin', 0, 'excel-vba-content-rejected']
|
|
] as const) {
|
|
const zip = zipWithDeclaredSizes(1, 1, name, flags);
|
|
expect(errorCode(() => validateImportSourceResources({
|
|
metadata: { name: 'unsafe.xlsx', sizeBytes: zip.length, lastModified: 0 }, bytes: zip
|
|
}, policy))).toBe(expectedCode);
|
|
}
|
|
});
|
|
it('blocks XML entities, deep JSON, null bytes and excessive markup before DOM or JSON parsing', () => {
|
|
const policy = resolveImportResourcePolicy({ maximumJsonDepth: 2, maximumMarkupElements: 2 });
|
|
expect(errorCode(() => validateDecodedTextResources('<!DOCTYPE x [<!ENTITY e SYSTEM "file:///secret">]><x>&e;</x>', 'xml', policy)))
|
|
.toBe('xml-doctype-rejected');
|
|
expect(errorCode(() => validateDecodedTextResources('{"a":{"b":{"c":1}}}', 'json', policy))).toBe('json-depth-limit');
|
|
expect(errorCode(() => validateDecodedTextResources('a\0b', 'csv', policy))).toBe('null-byte-rejected');
|
|
expect(errorCode(() => validateDecodedTextResources('<a><b></b></a>', 'html', policy))).toBe('markup-element-limit');
|
|
});
|
|
|
|
it('enforces table, row, column and cell limits on the common intermediate model', () => {
|
|
const document: ImportDocument = {
|
|
version: 1, status: 'complete', format: 'CSV',
|
|
file: { name: 'x.csv', sizeBytes: 1, lastModified: 0, extension: 'csv' }, rawText: '', diagnostics: [], discardedContent: [],
|
|
views: [], processing: [],
|
|
tables: [{
|
|
id: 't', name: 'T', index: 0, rawText: '', headerCandidates: [], attributes: {},
|
|
columns: [{ index: 0, headerCandidates: [] }, { index: 1, headerCandidates: [] }],
|
|
rows: [{ index: 0, source: { tableIndex: 0, rowIndex: 0 }, cells: [{
|
|
rawValue: 'long', decodedValue: 'long', detectedType: 'string', source: { tableIndex: 0, rowIndex: 0, columnIndex: 0 }
|
|
}] }]
|
|
}]
|
|
};
|
|
expect(errorCode(() => validateImportDocumentResources(document, resolveImportResourcePolicy({ maximumColumnsPerTable: 1 }))))
|
|
.toBe('column-limit');
|
|
expect(errorCode(() => validateImportDocumentResources(document, resolveImportResourcePolicy({ maximumRowsPerTable: 0 }))))
|
|
.toBe('rows-per-table-limit');
|
|
expect(errorCode(() => validateImportDocumentResources(document, resolveImportResourcePolicy({ maximumCellCharacters: 3 }))))
|
|
.toBe('cell-size-limit');
|
|
});
|
|
});
|