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; async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise { 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 erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('ERP名单'); sheet.addRow([...ERP_HEADERS]); sheet.addRow([ 1, '测试甲', 'ALPHA,TEST', '男', '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, code: string, location?: { row?: number; column?: number } ): Promise { 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( '', '' ) ); 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' ); });