Files
LWLT-AIBOT/control-plane/test/passenger-roster-workbook.test.ts
2026-08-31 16:04:26 +08:00

386 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import {
PassengerRosterWorkbookError,
normalizePassengerRosterWorkbook
} from '../src/passenger-roster-workbook.js';
const SOURCE_HEADERS = [
'序号', '姓名', '英文姓名', '性别', '身份证号码', '出生日期', '年龄', '出生地',
'护照号码', '签发地', '签发日期', '有效期', '电话', '备注'
] as const;
const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
type WorkbookMutation = (workbook: ExcelJS.Workbook) => void | Promise<void>;
async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.addRow(['脱敏团队名单模板']);
sheet.addRow([...SOURCE_HEADERS]);
sheet.addRow([
1,
'测试甲',
'ALPHA,TEST',
'男',
'',
'1990/1/2',
{ formula: 'DATEDIF(F3,TODAY(),"Y")', result: 36 },
'测试地一',
123456789,
'测试签发地',
new Date(Date.UTC(2025, 0, 2)),
{ formula: 'EDATE(K3,10*12)-1', result: new Date(Date.UTC(2035, 0, 1)) },
13800000000,
'领队\t备注'
]);
sheet.addRow([
2,
'测试乙',
'BETA,TEST',
'女',
'',
36527,
{ formula: 'DATEDIF(F4,TODAY(),"Y")', result: 26 },
'测试地二',
'P00000002',
'测试签发地',
45659,
49310,
'',
''
]);
for (const mutation of mutations) await mutation(workbook);
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function expectRosterError(
work: () => Promise<unknown>,
code: string,
location?: { row?: number; column?: number }
): Promise<PassengerRosterWorkbookError> {
try {
await work();
} catch (error) {
assert.ok(error instanceof PassengerRosterWorkbookError, String(error));
assert.equal(error.code, code);
if (location?.row !== undefined) assert.equal(error.row, location.row);
if (location?.column !== undefined) assert.equal(error.column, location.column);
return error;
}
assert.fail(`expected ${code}`);
}
test('XLSX is deterministically normalized to the canonical 13-column TSV', async () => {
const result = await normalizePassengerRosterWorkbook({
content: await syntheticWorkbook(),
fileName: 'synthetic-roster.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
assert.equal(result.rowCount, 2);
assert.equal(result.sourceFormat, 'xlsx');
assert.equal(result.worksheetName, 'Sheet1');
assert.equal(result.headerRow, 2);
assert.equal(result.canonicalTsv, [
CANONICAL_HEADER,
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注',
'2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t'
].join('\n'));
});
test('row 1 is ignored as group metadata and the exact roster header must be on row 2', async () => {
const valid = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N1').value = '任意团信息';
});
const normalized = await normalizePassengerRosterWorkbook({
content: valid,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(normalized.headerRow, 2);
assert.equal(normalized.rowCount, 2);
const shifted = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.insertRow(2, ['额外说明']);
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: shifted,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
}),
'roster_workbook_header_row_invalid',
{ row: 3 }
);
});
test('legacy XLS is accepted only through an injected fail-closed converter', async () => {
const xlsx = await syntheticWorkbook();
const legacy = Buffer.concat([
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
Buffer.from('synthetic legacy workbook')
]);
let conversions = 0;
const result = await normalizePassengerRosterWorkbook({
content: legacy,
fileName: 'synthetic-roster.xls',
contentType: 'application/vnd.ms-excel'
}, {
convertLegacyXls: async (input) => {
conversions += 1;
assert.equal(input.content, legacy);
return xlsx;
}
});
assert.equal(conversions, 1);
assert.equal(result.sourceFormat, 'xls');
assert.equal(result.rowCount, 2);
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: legacy,
fileName: 'synthetic-roster.xls',
contentType: 'application/vnd.ms-excel'
}),
'roster_workbook_converter_unavailable'
);
});
test('non-empty identity cards fail closed without leaking cell values', async () => {
const secret = 'SYNTHETIC-ID-SHOULD-NOT-LEAK';
const content = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('E3').value = secret;
});
const error = await expectRosterError(
() => normalizePassengerRosterWorkbook({ content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_identity_card_not_supported',
{ row: 3, column: 5 }
);
assert.doesNotMatch(error.message, new RegExp(secret));
assert.doesNotMatch(JSON.stringify(error), new RegExp(secret));
});
test('sequence numbers must be positive and unique', async () => {
const duplicate = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('A4').value = 1;
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: duplicate, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_duplicate_sequence',
{ row: 4, column: 1 }
);
const decimal = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('A3').value = 1.5;
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: decimal, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_invalid_sequence',
{ row: 3, column: 1 }
);
const overTechnicalLimit = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('A3').value = 5001;
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: overTechnicalLimit, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_sequence_limit',
{ row: 3, column: 1 }
);
});
test('formulas are limited to the row-local age and expiry template formulas', async () => {
const networkFormula = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('G3').value = {
formula: 'WEBSERVICE("https://invalid.example")',
result: '36'
};
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: networkFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_unsafe_content',
{ row: 3, column: 7 }
);
const crossRowFormula = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('L3').value = {
formula: 'EDATE(K4,10*12)-1',
result: new Date(Date.UTC(2035, 0, 1))
};
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: crossRowFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_formula_not_allowed',
{ row: 3, column: 12 }
);
const ignoredAgeWithoutResult = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('G3').value = {
formula: 'DATEDIF(F3,TODAY(),"Y")'
};
});
const ignoredAgeResult = await normalizePassengerRosterWorkbook({
content: ignoredAgeWithoutResult,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(ignoredAgeResult.rowCount, 2);
const expiryWithoutResult = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('L3').value = {
formula: 'EDATE(K3,10*12)-1'
};
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: expiryWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_formula_result_missing',
{ row: 3, column: 12 }
);
});
test('macros, external relationships and ambiguous sheets fail closed', async () => {
const xlsx = await syntheticWorkbook();
const zip = await JSZip.loadAsync(xlsx);
zip.file('xl/vbaProject.bin', Buffer.from('synthetic macro'));
const macroWorkbook = await zip.generateAsync({ type: 'nodebuffer' });
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: macroWorkbook, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_unsafe_content'
);
const relationshipZip = await JSZip.loadAsync(xlsx);
const relationshipPath = 'xl/_rels/workbook.xml.rels';
const relationships = await relationshipZip.file(relationshipPath)!.async('string');
relationshipZip.file(
relationshipPath,
relationships.replace(
'</Relationships>',
'<Relationship Id="syntheticExternal" Type="urn:synthetic" Target="https://invalid.example" TargetMode="External"/></Relationships>'
)
);
const externalRelationship = await relationshipZip.generateAsync({ type: 'nodebuffer' });
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: externalRelationship, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_unsafe_content'
);
const externalFormula = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('L3').value = {
formula: "'[external.xlsx]Sheet1'!A1",
result: new Date(Date.UTC(2035, 0, 1))
};
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: externalFormula, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_unsafe_content'
);
const multipleSheets = await syntheticWorkbook((workbook) => {
workbook.addWorksheet('Extra');
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: multipleSheets, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_ambiguous_sheet'
);
});
test('hidden non-empty rows or columns and duplicate headers fail closed', async () => {
const hiddenRow = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getRow(4).hidden = true;
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: hiddenRow, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_hidden_data',
{ row: 4 }
);
const hiddenColumn = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getColumn(14).hidden = true;
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: hiddenColumn, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_hidden_data',
{ column: 14 }
);
const duplicateHeaders = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.addRow([...SOURCE_HEADERS]);
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: duplicateHeaders, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_ambiguous_header'
);
});
test('format, MIME, conversion output and technical limits are validated before parsing', async () => {
const xlsx = await syntheticWorkbook();
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: xlsx, fileName: 'synthetic.xls', contentType: 'application/vnd.ms-excel' }),
'roster_workbook_format_mismatch'
);
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: xlsx, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }, { maxInputBytes: 8 }),
'roster_workbook_too_large'
);
const legacy = Buffer.concat([
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
Buffer.from('synthetic legacy workbook')
]);
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: legacy,
fileName: 'synthetic.xls',
contentType: 'application/vnd.ms-excel'
}, { convertLegacyXls: async () => Buffer.from('not xlsx') }),
'roster_workbook_conversion_failed'
);
const conversionFailure = await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: legacy,
fileName: 'synthetic.xls',
contentType: 'application/vnd.ms-excel'
}, {
convertLegacyXls: async () => {
throw new PassengerRosterWorkbookError('roster_workbook_conversion_failed', {
conversionErrorCode: 'conversion_output_missing'
});
}
}),
'roster_workbook_conversion_failed'
);
assert.equal(conversionFailure.conversionErrorCode, 'conversion_output_missing');
const macroLegacy = Buffer.concat([legacy, Buffer.from('_VBA_PROJECT_CUR', 'utf16le')]);
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: macroLegacy,
fileName: 'synthetic.xls',
contentType: 'application/vnd.ms-excel'
}, { convertLegacyXls: async () => xlsx }),
'roster_workbook_unsafe_content'
);
const tooManyRows = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
for (let sequence = 3; sequence <= 5001; sequence += 1) {
sheet.addRow([
sequence, `测试${sequence}`, `TEST,${sequence}`, '男', '', '2000-01-01', '', '测试地',
`P${String(sequence).padStart(8, '0')}`, '测试签发地', '2025-01-01', '2035-01-01', '', ''
]);
}
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({
content: tooManyRows,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
}, { maxDataRows: 6000 }),
'roster_workbook_row_limit'
);
});