// @vitest-environment jsdom import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; 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 { createScannerUnifiedResult } from '../../src/domain/unified-result'; import { isDeviationRecord } from '../../src/importers/status-normalization'; import { recordTitleFor } from '../../src/features/dataset-viewer/record-title'; import { datahubScanReadiness } from '../../src/features/project-import/datahub-scan-readiness'; import { commonDefenderAlertDescription } from '../../src/domain/defender-for-iot-alerts'; import { selectFindingFields } from '../../src/features/finding-reader/finding-fields'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { RichTextValue } from '../../src/components/RichTextValue/RichTextValue'; // Synthetic alert export: fictional titles, TEST-NET addresses and documentation MACs only. const header = 'Id,Title,First Detection,Last Detection,Last Activity,Severity,Description,Acknowledged,Additional Information,Source Address,Destination Address'; const lines = [ 'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.21 over port CIP (44818),192.0.2.41,192.0.2.21', 'A-1,Synthetic Unauthorized PLC Programming,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic programming from 192.0.2.41,False,192.0.2.22 over port CIP (44818),192.0.2.41,192.0.2.21', 'A-2,Synthetic Unauthorized PLC Programming,12/09/2026 08:00:00,12/09/2026 08:01:00,12/09/2026 08:01:00,Critical,"Synthetic programming, second source",False,,192.0.2.42,192.0.2.21', 'A-3,Synthetic Internet Connection,13/09/2026 09:00:00,13/09/2026 09:30:00,13/09/2026 09:30:00,Major,Synthetic outbound connection,False,198.51.100.7 over port HTTPS (443),00-00-5E-00-53-01,', 'A-4,Synthetic Unused Protocol,14/09/2026 07:00:00,14/09/2026 07:00:00,14/09/2026 07:05:00,Warning,Synthetic unused protocol,True,,192.0.2.50,192.0.2.51' ]; function csvFile() { return new File([new TextEncoder().encode([header, ...lines].join('\n'))], 'alerts-synthetic.csv', { type: 'text/csv' }); } function wrappedWorkbook(rows = [header, ...lines]) { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(rows.map(line => [line])), 'Sheet1'); const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); return new File([bytes], 'alerts-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); } function fieldValue(candidate: { fields: Array<{ label: string; originalValue: unknown; decodedValue: string }> } | undefined, label: string) { return candidate?.fields.find(field => field.label === label); } async function canonicalFor(file: File) { const imported = await importFile(file, { profiles: importProfiles }); const entry = confirmEntryMappings(previewEntryFromDataset(imported, { sha256: 'a'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified }), importProfiles); return { imported, canonical: createCanonicalImportBatch('defender-alerts-synthetic', [entry], '2026-09-30T06:00:00.000Z').model }; } describe('Microsoft Defender for IoT alert export', () => { it.each([ ['native CSV', csvFile], ['workbook with one CSV line per cell', () => wrappedWorkbook()] ])('imports the %s as one finding per alert type', async (_name, file) => { const imported = await importFile(file(), { profiles: importProfiles }); expect(imported.profileId).toBe('defender-for-iot-alerts'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-alert-source-extractor', version: '1.5.0' }); expect(imported.extraction?.diagnostics).toEqual([]); const candidates = imported.extraction?.candidates ?? []; expect(candidates.map(candidate => fieldValue(candidate, 'Title')?.decodedValue)).toEqual([ 'Synthetic Unauthorized PLC Programming', 'Synthetic Internet Connection', 'Synthetic Unused Protocol' ]); const programming = candidates[0]; // Two rows share Id A-1 and differ only in Additional Information: one alert, both lines kept. expect(fieldValue(programming, 'Alert Count')?.originalValue).toBe(2); // Alert A-1 spans two source rows, so the Alerts table holds three rows for two alerts. expect(fieldValue(programming, 'Detail Rows')?.originalValue).toBe(3); expect(fieldValue(programming, 'Unacknowledged Alerts')?.originalValue).toBe(2); expect(fieldValue(programming, 'Acknowledged Alerts')).toBeUndefined(); expect(fieldValue(programming, 'Status')?.decodedValue).toBe('Unacknowledged'); // Differing descriptions: Description holds the wording shared by all alerts under its own field key. expect(fieldValue(programming, 'Description')).toMatchObject({ key: 'common-description', decodedValue: 'Synthetic …' }); const table = fieldValue(programming, 'Alerts')?.decodedValue ?? ''; // One table row per source row: alert A-1 appears twice, each with its own Additional Information. const tableRows = table.split('\n').slice(2); expect(tableRows).toHaveLength(3); expect(tableRows.filter(row => row.startsWith('| A-1 | False | 11/09/2026 10:05:12 |'))).toHaveLength(2); expect(tableRows[0]).toMatch(/\| 192\.0\.2\.21 over port CIP \(44818\) \|$/); expect(tableRows[1]).toMatch(/\| 192\.0\.2\.22 over port CIP \(44818\) \|$/); expect(tableRows[2]).toContain('Synthetic programming, second source'); expect(fieldValue(programming, 'Source Observations')?.originalValue).toHaveLength(3); // Native severity stays retained; Major maps to High and Warning to Low. expect(fieldValue(candidates[1], 'Severity')?.decodedValue).toBe('High'); expect(fieldValue(candidates[1], 'Defender Severity')?.decodedValue).toBe('Major'); // A single alert keeps its original description. expect(fieldValue(candidates[1], 'Description')).toMatchObject({ key: 'description', decodedValue: 'Synthetic outbound connection' }); expect(fieldValue(candidates[2], 'Severity')?.decodedValue).toBe('Low'); expect(fieldValue(candidates[2], 'Status')?.decodedValue).toBe('Acknowledged'); }); it('stores the wrapped workbook findings so Data Hub lists the scan as viewable', async () => { // Regression 2026-09-30: the single unrecognized text column was not a selected table, so every finding was dropped. const { canonical } = await canonicalFor(wrappedWorkbook()); expect(canonical.findingOccurrences).toHaveLength(3); expect(datahubScanReadiness([canonical], canonical.scanRuns[0].id, 'defender-for-iot-alerts')).toBe('recognized'); }); it('keeps only the wording shared by every description and marks differing parts', () => { expect(commonDefenderAlertDescription([ 'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.189, Destination IP: 192.0.2.188, Destination port: 17', 'A Broadcast connection not allowed by policy has been detected.Source IP: 192.0.2.192, Destination IP: 192.0.2.193, Destination port: Netbios Name Service (137)', 'A Multicast connection not allowed by policy has been detected.Source IP: 192.0.2.204, Destination IP: 192.0.2.194, Destination port: Web Service Discovery (3702)' ])).toBe('A … connection not allowed by policy has been detected.Source IP: … Destination IP: … Destination port: …'); // One distinct text keeps the original; nothing shared yields no derived text. expect(commonDefenderAlertDescription(['Same text', 'Same text'])).toBeUndefined(); expect(commonDefenderAlertDescription(['alpha beta', 'gamma delta'])).toBeUndefined(); }); it('renders underscores inside alert descriptions literally in the Results table', async () => { // Regression 2026-09-30: `[IP_189], Destination IP: [IP_` was rendered as italics and lost its underscores. const description = 'Synthetic broadcast.Source IP: [IP_189], Destination IP: [IP_188], Destination port: [PORT_17]'; const imported = await importFile(new File([new TextEncoder().encode([header, `A-7,Synthetic Broadcast,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Major,"${description}",False,,192.0.2.1,192.0.2.255` ].join('\n'))], 'alerts-underscore.csv', { type: 'text/csv' }), { profiles: importProfiles }); const table = fieldValue(imported.extraction?.candidates[0], 'Alerts')!.decodedValue; const container = document.createElement('div'); container.innerHTML = renderToStaticMarkup(createElement(RichTextValue, { value: table })); expect(container.querySelector('em')).toBeNull(); expect([...container.querySelectorAll('td')].some(cell => cell.textContent === description)).toBe(true); }); it('keeps the original single-cell line as provenance for a wrapped workbook', async () => { const imported = await importFile(wrappedWorkbook(), { profiles: importProfiles }); expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: false }); const observations = fieldValue(imported.extraction?.candidates[1], 'Source Observations')?.originalValue as Array>; expect(observations[0]).toMatchObject({ rawLine: lines[3], fields: { Id: 'A-3', Severity: 'Major', 'Destination Address': '' } }); }); it('counts unacknowledged alert types as deviations and acknowledged ones as visible non-deviations', async () => { const { canonical } = await canonicalFor(csvFile()); const results = canonical.findingOccurrences.map(occurrence => createScannerUnifiedResult({ project: canonical.project, assessment: canonical.assessment, scanRun: canonical.scanRuns[0], importFile: canonical.importFiles[0], definition: canonical.findingDefinitions.find(definition => definition.id === occurrence.definitionId)!, occurrence, reviews: canonical.reviews })); const byRule = new Map(canonical.findingOccurrences.map((occurrence, index) => [occurrence.sourceIdentity.originalRuleId, { occurrence, result: results[index] }])); expect(canonical.findingDefinitions).toHaveLength(3); expect(byRule.get('Synthetic Unauthorized PLC Programming')).toMatchObject({ occurrence: { severity: { normalizedValue: 'critical' }, outcome: { normalizedValue: 'warning' } }, result: { isDeviation: true } }); expect(byRule.get('Synthetic Internet Connection')?.occurrence.severity.normalizedValue).toBe('high'); expect(byRule.get('Synthetic Unused Protocol')).toMatchObject({ occurrence: { severity: { normalizedValue: 'low' }, outcome: { normalizedValue: 'informational' } }, result: { isDeviation: false } }); expect(canonical.importFiles[0]).toMatchObject({ processedWith: { profileId: 'defender-for-iot-alerts', compatibilityKey: 'defender-for-iot-alerts-v1' } }); // Work reads the same deviation from the projected record and names it by the alert title. const projected = canonicalModelToDataset(canonical)!; const sheet = projected.sheets.find(item => item.toolId === 'defender-for-iot-alerts' && item.analysis.records.length)!; const deviations = sheet.analysis.records.filter(record => isDeviationRecord(record, sheet.toolId)); expect(deviations.map(record => recordTitleFor(record, '', sheet.toolId)).sort()).toEqual([ 'Synthetic Internet Connection', 'Synthetic Unauthorized PLC Programming' ]); // The Finding Reader leads with the shared wording as Description. const programming = sheet.analysis.records.find(record => recordTitleFor(record, '', sheet.toolId) === 'Synthetic Unauthorized PLC Programming')!; expect(selectFindingFields(programming, 'Synthetic Unauthorized PLC Programming', true, sheet.toolId).summary[0]) .toMatchObject({ label: 'Description', value: 'Synthetic …' }); }); it('reports conflicting rows, unreadable wrapped lines and missing titles instead of dropping them silently', async () => { const imported = await importFile(wrappedWorkbook([ header, lines[0], lines[1].replace('Critical', 'Major'), 'A-9,Synthetic broken line,"unterminated', 'A-10,,11/09/2026 10:05:12,11/09/2026 10:07:11,11/09/2026 10:07:11,Critical,Synthetic,False,,192.0.2.1,192.0.2.2' ]), { profiles: importProfiles }); // A failed extraction is never a silent success: the export is recognized but needs confirmation. expect(imported.recognition?.source).toMatchObject({ selected: { toolId: 'defender-for-iot-alerts' }, requiresConfirmation: true }); expect(imported.extraction?.diagnostics.map(item => item.code).sort()).toEqual([ 'defender-alert-rows-conflict', 'defender-alert-title-missing', 'defender-alert-wrapped-row-unreadable' ]); expect(imported.extraction?.processing.status).toBe('failed'); // Both conflicting rows remain evidence of the single alert. expect(fieldValue(imported.extraction?.candidates[0], 'Source Observations')?.originalValue).toHaveLength(2); }); it('does not claim a vulnerability export or a generic table', async () => { const imported = await importFile(new File([new TextEncoder().encode('Id,Title,Severity\n1,Synthetic,High')], 'other.csv', { type: 'text/csv' }), { profiles: importProfiles }); expect(imported.profileId).not.toBe('defender-for-iot-alerts'); }); });