Files
LWLT-AIBOT/control-plane/test/passenger-roster-workbook.test.ts
2026-09-07 20:22:00 +08:00

576 lines
21 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 ERP_HEADERS = [
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
'签发地', '签发日', '有效期', '电话', '备注'
] as const;
const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注';
const REORDERED_SOURCE_INDEXES = [8, 1, 13, 0, 10, 4, 6, 12, 3, 11, 5, 9, 2, 7];
const ATTACHMENT_VARIANT_INDEXES = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 4];
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,
'测试甲',
'ALPHATEST',
'男',
'',
'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 erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('ERP名单');
sheet.addRow([...ERP_HEADERS]);
sheet.addRow([
1,
'测试甲',
'ALPHATEST',
'男',
'1990/1/2',
'测试地一',
'护照',
123456789,
'测试签发地',
new Date(Date.UTC(2025, 0, 2)),
new Date(Date.UTC(2035, 0, 1)),
13800000000,
'领队\t备注'
]);
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('the ERP field row is auto-detected and source columns may be reordered', 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 reordered = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
for (const rowNumber of [2, 3, 4]) {
const row = sheet.getRow(rowNumber);
const values = REORDERED_SOURCE_INDEXES.map((index) => row.getCell(index + 1).value);
row.values = values;
}
sheet.getCell('G3').value = {
formula: 'DATEDIF(K3,TODAY(),"Y")',
result: 36
};
sheet.getCell('J3').value = {
formula: 'EDATE(E3,10*12)-1',
result: new Date(Date.UTC(2035, 0, 1))
};
sheet.getCell('G4').value = {
formula: 'DATEDIF(K4,TODAY(),"Y")',
result: 26
};
sheet.getCell('J4').value = {
formula: 'EDATE(E4,10*12)-1',
result: 49310
};
});
const reorderedResult = await normalizePassengerRosterWorkbook({
content: reordered,
fileName: 'reordered-roster.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(reorderedResult.headerRow, 2);
assert.equal(reorderedResult.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'));
const shifted = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.insertRow(2, ['额外说明']);
sheet.getCell('G4').value = {
formula: 'DATEDIF(F4,TODAY(),"Y")',
result: 36
};
sheet.getCell('L4').value = {
formula: 'EDATE(K4,10*12)-1',
result: new Date(Date.UTC(2035, 0, 1))
};
sheet.getCell('G5').value = {
formula: 'DATEDIF(F5,TODAY(),"Y")',
result: 26
};
sheet.getCell('L5').value = {
formula: 'EDATE(K5,10*12)-1',
result: 49310
};
});
const shiftedResult = await normalizePassengerRosterWorkbook({
content: shifted,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(shiftedResult.headerRow, 3);
assert.equal(shiftedResult.rowCount, 2);
const erpHeaderOnFirstRow = await normalizePassengerRosterWorkbook({
content: await erpHeaderWorkbook(),
fileName: 'erp-roster.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(erpHeaderOnFirstRow.headerRow, 1);
assert.equal(erpHeaderOnFirstRow.canonicalTsv, [
CANONICAL_HEADER,
'1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注'
].join('\n'));
});
test('the approved attachment aliases and reverse issue-date formula are normalized', async () => {
const attachmentVariant = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
for (const rowNumber of [2, 3, 4]) {
const row = sheet.getRow(rowNumber);
const values = ATTACHMENT_VARIANT_INDEXES.map((index) => row.getCell(index + 1).value);
row.values = values;
}
sheet.getCell('N2').value = '身份证';
sheet.getCell('F3').value = 36;
sheet.getCell('J3').value = {
formula: 'EDATE(K3,-10*12)+1',
result: new Date(Date.UTC(2025, 0, 2))
};
sheet.getCell('K3').value = new Date(Date.UTC(2035, 0, 1));
sheet.getCell('F4').value = 26;
sheet.getCell('J4').value = {
formula: 'EDATE(K4,-10*12)+1',
result: 45659
};
sheet.getCell('K4').value = 49310;
});
const result = await normalizePassengerRosterWorkbook({
content: attachmentVariant,
fileName: 'attachment-variant.xlsx',
contentType: 'application/octet-stream'
});
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('source header order may vary, but every required label must appear exactly once', async () => {
const missingHeader = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: missingHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_header_not_found'
);
const duplicateHeader = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.getCell('N2').value = '姓名';
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: duplicateHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_header_not_found'
);
const ignoredExtraData = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('O2').value = '酒店偏好';
sheet.getCell('O3').value = 'SYNTHETIC-EXTRA-SHOULD-NOT-LEAK';
sheet.getCell('P3').value = { formula: '1+1' };
sheet.getCell('Q6').value = '仅额外列尾注';
});
const ignoredExtraResult = await normalizePassengerRosterWorkbook({
content: ignoredExtraData,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(ignoredExtraResult.rowCount, 2);
assert.doesNotMatch(ignoredExtraResult.canonicalTsv, /SYNTHETIC-EXTRA-SHOULD-NOT-LEAK|酒店偏好|仅额外列尾注/);
});
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('identity cards and other non-import columns are ignored without leaking values', async () => {
const secret = 'SYNTHETIC-ID-SHOULD-NOT-LEAK';
const content = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('E3').value = secret;
sheet.getColumn(5).hidden = true;
});
const result = await normalizePassengerRosterWorkbook({
content,
fileName: 'synthetic.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(result.rowCount, 2);
assert.doesNotMatch(result.canonicalTsv, new RegExp(secret));
});
test('source document type is ignored and canonical ERP document type remains passport', async () => {
const sourceType = await erpHeaderWorkbook((workbook) => {
workbook.getWorksheet('ERP名单')!.getCell('G2').value = '身份证';
});
const result = await normalizePassengerRosterWorkbook({
content: sourceType,
fileName: 'erp-roster.xlsx',
contentType: 'application/octet-stream'
});
assert.equal(result.rowCount, 1);
assert.equal(result.canonicalTsv.split('\n')[1]!.split('\t')[6], '护照');
assert.doesNotMatch(result.canonicalTsv, /身份证/);
});
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('unsafe formulas stay blocked while ignored columns do not affect selected fields', 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 }
);
const issueDateWithoutResult = await syntheticWorkbook((workbook) => {
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('K3').value = {
formula: 'EDATE(L3,-10*12)+1'
};
sheet.getCell('L3').value = new Date(Date.UTC(2035, 0, 1));
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({ content: issueDateWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }),
'roster_workbook_formula_result_missing',
{ row: 3, column: 11 }
);
});
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'
);
});