// @vitest-environment jsdom import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; import { buildDefenderVulnerabilityModel, type DefenderSourceRow, type DefenderSourceTable } from '../../src/domain/defender-for-iot'; import { importFile } from '../../src/importers/importer'; import { createCanonicalImportBatch } from '../../src/importers/canonical-import'; import { importProfiles } from '../../src/profiles/profile-catalog'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset'; import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness'; import { createScannerUnifiedResult } from '../../src/domain/unified-result'; const summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low']; const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung']; function table(tableIndex: number, headers: string[], rows: Array>): DefenderSourceTable { return { tableIndex, name: headers.includes('CVE') ? 'Vulnerability detail' : 'Asset summary', headers, rows: rows.map((fields, index): DefenderSourceRow => ({ tableIndex, rowIndex: index + 1, fields: fields as DefenderSourceRow['fields'] })) }; } function summary(name: string, ip: string, site: string, counts: [number, number, number, number], sensor = 'sensor-a') { return { Name: name, IP: ip, Site: site, Sensor: sensor, TotalCVEs: counts.reduce((sum, value) => sum + value, 0), Critical: counts[0], High: counts[1], Medium: counts[2], Low: counts[3] }; } function detail(name: string, ip: string, site: string, sensor: string, cve: string, severity: string, description = 'Synthetic description', nullValue: unknown = null) { return { Name: name, IP: ip, Site: site, Sensor: sensor, CVE: cve, Severity: severity, NULL: nullValue, Beschreibung: description }; } describe('Microsoft Defender for IoT vulnerability model', () => { it('models CVEs, devices, occurrences, observations, and aggregate-only counts independently', () => { const model = buildDefenderVulnerabilityModel([ table(0, summaryHeaders, [ summary('00:11:22:33:44:55', '10.0.0.1', 'North', [2, 3, 0, 1]), summary('device-b', '10.0.0.2', 'North', [1, 0, 0, 0]) ]), table(1, detailHeaders, [ detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical'), detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-b', 'CVE-2026-0001', 'Critical'), detail('00:11:22:33:44:55', '10.0.0.1', 'North', 'sensor-a', 'CVE-2026-0002', 'Critical'), detail('device-b', '10.0.0.2', 'North', 'sensor-a', 'CVE-2026-0001', 'Critical') ]) ]); expect(model.cves).toHaveLength(2); expect(model.devices).toHaveLength(2); expect(model.occurrences).toHaveLength(3); expect(model.observations).toHaveLength(4); expect(model.cves.find(item => item.id === 'CVE-2026-0001')).toMatchObject({ deviceIds: expect.arrayContaining([expect.any(String), expect.any(String)]), observationCount: 3 }); expect(model.occurrences.find(item => item.cve === 'CVE-2026-0001' && item.observations.length === 2)?.sensors).toEqual(['sensor-a', 'sensor-b']); expect(model.devices.find(item => item.displayName === '00:11:22:33:44:55')?.severity.high).toMatchObject({ detail: 0, summary: 3, aggregateOnly: 3, state: 'aggregate-only' }); expect(model.aggregateOnlyCount).toBe(4); expect(model.sites[0]).toMatchObject({ assetCount: 2, uniqueCveCount: 2, affectedDeviceCount: 2, rawObservationCount: 4 }); }); it('uses MAC, IP, and site-scoped name identities without merging a reused IP across sites', () => { const model = buildDefenderVulnerabilityModel([table(0, detailHeaders, [ detail('AA-BB-CC-DD-EE-FF', '10.0.0.1', 'A', 's1', 'CVE-2026-0101', 'High'), detail('device-ip', '10.0.0.2', 'A', 's1', 'CVE-2026-0102', 'High'), detail('device-name', '', 'A', 's1', 'CVE-2026-0103', 'Medium'), detail('device-reused-a', '10.0.0.9', 'A', 's1', 'CVE-2026-0104', 'Low'), detail('device-reused-b', '10.0.0.9', 'B', 's2', 'CVE-2026-0104', 'Low'), detail('', '', '', '', 'CVE-2026-0105', 'Critical') ])]); expect(model.devices.map(item => item.identityMethod)).toEqual(expect.arrayContaining(['mac', 'ip', 'name', 'unresolved'])); expect(model.devices.filter(item => item.ip === '10.0.0.9')).toHaveLength(2); expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ 'defender-device-identity-site-scoped', 'defender-device-identity-unresolved' ])); }); it('keeps duplicate rows as evidence and flags conflicts, unexpected NULL data, and reconciliation failures', () => { const duplicate = detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0201', 'Critical', 'Description A'); const model = buildDefenderVulnerabilityModel([ table(0, summaryHeaders, [ { ...summary('device-a', '', 'A', [3, 0, 0, 0]), TotalCVEs: 99 }, summary('device-a', '', 'A', [2, 0, 0, 0]) ]), table(1, detailHeaders, [ duplicate, { ...duplicate }, detail('device-a', '', 'A', 'sensor-b', 'CVE-2026-0201', 'High', 'Description B', 'preserve-me'), detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0202', 'Critical') ]) ]); expect(model.occurrences).toHaveLength(2); expect(model.observations).toHaveLength(4); expect(model.observations.some(item => item.additionalSourceFields.NULL === 'preserve-me')).toBe(true); expect(model.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ 'defender-summary-total-mismatch', 'defender-summary-conflict', 'defender-duplicate-source-observation', 'defender-null-column-populated', 'defender-cve-severity-conflict', 'defender-cve-description-conflict', 'defender-summary-detail-mismatch' ])); }); it('distinguishes zero, unavailable detail, and single-sheet variants', () => { const summaryOnly = buildDefenderVulnerabilityModel([table(0, summaryHeaders, [summary('device-a', '', 'A', [0, 2, 0, 0])])]); const detailOnly = buildDefenderVulnerabilityModel([table(0, detailHeaders, [detail('device-a', '', 'A', 'sensor-a', 'CVE-2026-0301', 'Medium')])]); expect(summaryOnly.available).toBe(true); expect(summaryOnly.occurrences).toHaveLength(0); expect(summaryOnly.devices[0].severity.critical.state).toBe('zero'); expect(summaryOnly.devices[0].severity.high.state).toBe('aggregate-only'); expect(detailOnly.devices[0].severity.medium.state).toBe('detail-only'); expect(detailOnly.devices[0].severity.low.state).toBe('unavailable'); }); it('selects the versioned profile and emits one candidate per CVE with devices aggregated', async () => { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ summary('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', [1, 2, 0, 0]), summary('device-b', '10.0.0.2', 'Synthetic site', [1, 0, 0, 0]) ], { header: summaryHeaders }), 'Assets'); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical'), detail('00:11:22:33:44:55', '10.0.0.1', 'Synthetic site', 'sensor-b', 'CVE-2026-0401', 'Critical'), detail('device-b', '10.0.0.2', 'Synthetic site', 'sensor-a', 'CVE-2026-0401', 'Critical') ], { header: detailHeaders }), 'Vulnerabilities'); const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles }); expect(imported.profileId).toBe('defender-for-iot-vulnerabilities'); expect(imported.profileVersion).toBe('1.1.0'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' }); // Two devices share one CVE: the Work view must show a single aggregated finding, not one row per device. expect(imported.extraction?.candidates).toHaveLength(1); expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Observation Count')?.originalValue).toBe(3); expect(imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Device Count')?.originalValue).toBe(2); // The affected devices are carried as a markdown table for the result popup, one row per device with its scanner and site. const devicesTable = imported.extraction?.candidates[0].fields.find(field => field.label === 'Affected Devices')?.decodedValue ?? ''; expect(devicesTable).toContain('| Affected Device | Scanner | Site |'); expect(devicesTable).toContain('00:11:22:33:44:55'); expect(devicesTable).toContain('device-b'); expect(imported.mapping?.candidates).toHaveLength(1); expect(imported.sheets.map(sheet => sheet.analysis.records.length).sort()).toEqual([2, 3]); const entry = confirmEntryMappings(previewEntryFromDataset(imported, { sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified }), importProfiles); const canonical = createCanonicalImportBatch('defender-synthetic', [entry], '2026-09-10T06:00:00.000Z').model; expect(canonical.importFiles[0]).toMatchObject({ detectedTool: { id: 'defender-for-iot-vulnerabilities' }, processedWith: { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', normalizationVersion: '1.1.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2' } }); expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingOccurrences).toHaveLength(1); expect(canonical.findingOccurrences[0]).toMatchObject({ outcome: { normalizedValue: 'warning' }, technicalExecutionState: { normalizedValue: 'completed' } }); expect(createScannerUnifiedResult({ project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0], definition: canonical.findingDefinitions[0], occurrence: canonical.findingOccurrences[0], reviews: canonical.reviews })).toMatchObject({ resultType: 'vulnerability_finding', outcomeNormalized: 'warning', isIoe: false, isDeviation: true }); expect(createScannerUnifiedResult({ project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0], definition: canonical.findingDefinitions[0], occurrence: { ...canonical.findingOccurrences[0], outcome: { ...canonical.findingOccurrences[0].outcome!, normalizedValue: 'unknown' } }, reviews: canonical.reviews })).toMatchObject({ resultType: 'vulnerability_finding', outcomeNormalized: 'warning', isIoe: false, isDeviation: true }); // The aggregated CVE carries its affected-device count as the affected asset (two devices here); the device list lives in the evidence table. expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toBe('2'); expect(canonical.findingOccurrences[0].score).toBeUndefined(); expect(canonical.evidence).toHaveLength(1); expect(canonical.evidence[0]).toMatchObject({ kind: 'object', value: expect.any(Array) }); expect(canonical.evidence[0].value).toHaveLength(3); // Both asset-summary rows stay preserved as source data alongside the single aggregated finding. expect(canonical.sourceTables.flatMap(source => source.rows ?? [])).toHaveLength(2); }); it('keeps a summary-only import viewable without creating finding definitions or occurrences', async () => { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet([ summary('synthetic-device', '', 'Synthetic site', [0, 2, 0, 0]) ], { header: summaryHeaders }), 'Assets'); const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); const imported = await importFile(new File([bytes], 'defender-summary-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles }); const entry = confirmEntryMappings(previewEntryFromDataset(imported, { sha256: 'e'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified }), importProfiles); const canonical = createCanonicalImportBatch('defender-summary-synthetic', [entry], '2026-09-10T06:00:00.000Z').model; const projected = canonicalModelToDataset(canonical); expect(imported.profileId).toBe('defender-for-iot-vulnerabilities'); expect(canonical.findingDefinitions).toHaveLength(0); expect(canonical.findingOccurrences).toHaveLength(0); expect(projected?.profileId).toBe('defender-for-iot-vulnerabilities'); expect(projected?.sheets.some(sheet => sheet.analysis.headers.includes('TotalCVEs'))).toBe(true); }); });