feat(defender-for-iot): dashboard layout and outcome marking

Adapted pick of 08771d5. findingOutcomeMark supplies the outcome mark in
the Work card and the Finding Reader header, including sources without a
status column. The Defender panel uses the Client Info header and a
six-column metrics row.

Unlike the original, tiles stay on the shared tile and text tokens
instead of --client-info-* colours, border: 0 and --btn-radius.
This commit is contained in:
Binh 2026-10-02 12:52:25 +02:00
parent 0b5dcfbcc6
commit a181d264bf
8 changed files with 90 additions and 22 deletions

View File

@ -4231,6 +4231,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
| 2026-10-02 | Phase 7: `08771d5` angepasst uebernommen; `c056c21` (CWE-Links, baut auf der ausgelassenen CVE-Anreicherung auf) ausgelassen, seine Scoring-Zeile fuer `securityToolId` folgt mit dem Result-DB-Werkzeugeintrag. `findingOutcomeMark` liefert die Outcome-Marke fuer Work-Karte und Finding-Reader-Kopf; Werkzeuge ohne Statusspalte erhalten sie aus `missingStatusOutcome` (Defender: `Warning`), alle anderen behalten ihr eigenes Statuswort. Die GPO-Diagnoseplaketten der Karte und die Vergleichs-Outcome-Ableitung des Readers bleiben unveraendert. Das Defender-Panel nutzt den Client-Info-Kopf (`Client`, `Assessment`, `Source`) und eine sechsspaltige Kennzahlzeile mit Container-Queries. Abweichend vom Original bleiben Kacheln, Sichten und Drilldowns auf den gemeinsamen Rollen `--tile-surface`, `--tile-border`, `--tile-radius`, `--text`, `--heading` und `--muted` statt `--client-info-*`, `border: 0` und `--btn-radius`, damit die verbindliche Kachelregel durchgehend gilt. Nicht im Browser geprueft, weil die lokale Datenbank keine Defender-Importe enthaelt. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.893 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 13A: Aus `33d2019` nur die Dokumentation uebernommen. `docs/CANONICAL_MODEL.md` beschreibt `vulnerability_finding` als additive ASSESSOR-Erweiterung des externen `result_type`-Katalogs; der Kommentar an `CanonicalResultType` verweist darauf. Nicht uebernommen: die Backfill-Migration (frueher 023) samt Katalogeintrag und Schema-Assertions, weil die neu angelegte lokale Datenbank keine Defender-Occurrences enthaelt, sowie auf Nutzerentscheidung der lesende Outcome-Fallback in `unified-result.ts` mit seinem Test; neue Importe erhalten `warning` bereits ueber `missingStatusOutcome`. Keine Verhaltens-, API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/13A: `fe71246` uebernommen; `e048295` (NVD-CVE-Anreicherung) auf Nutzerentscheidung vorerst ausgelassen. Der Result-Contract kennt den Typ `vulnerability_finding`; Defender-Findings erhalten ihn. Ein Profil kann `missingStatusOutcome` deklarieren, Defender setzt `warning`, weil jede CVE-Zeile selbst der Befund ist. Das Defender-Profil steigt auf 1.1.0 mit Kompatibilitaetsschluessel `-v2` (semantic-breaking, Reimport erforderlich; die lokale Datenbank enthaelt keine Defender-Importe), 1.0.0 ist nicht mehr aktuell. Der Work-Filter `Deviation` aller Scanner nutzt statt der Regex-Liste `UNIVERSAL_DEVIATION_PATTERNS` nun `isDeviationRecord` und damit das kanonische Result-DB-Praedikat; die GPO-Statuslogik, der Statuscache und die uebrigen Statusfilter der Basis bleiben unveraendert davor. Alle bestehenden Work-, Filter- und Scannertests bestehen unveraendert; eine vollstaendige Gleichwertigkeit fuer jeden realen Statuswert ist damit nicht bewiesen. Migration `027_vulnerability_finding_result_type.sql` (frueher 022) erweitert nur den Check-Constraint und schreibt keine Occurrences um; ohne die ausgelassene CVE-Tabelle. Vor der Anwendung wurde das verifizierte Backup `backups/assessor-pre-027-20261002-123608.dump` erstellt. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/7/13A: `5fbbda2` uebernommen. Der Defender-Extractor erzeugt nun ein Finding je CVE statt je Device-mal-CVE und liefert `Affected Device Count` sowie die Tabelle `Affected Devices` (Device, Scanner, Site); das Profil bildet die Anzahl auf die Asset-Rolle ab, nutzt `Affected Devices` als Ergebnisabschnitt und vergleicht nur noch ueber die CVE. Der Finding Reader blendet das Rohfeld `Source Observations` fuer Defender aus, es bleibt als Provenienz im Datensatz. Nicht uebernommen: die Umbenennung der Asset-Zeile im Result-DB-Zeilendetail, weil die Disclosure-Policy `affectedAsset` fuer jede Zeile zurueckhaelt und eine Device-Anzahl dort nicht erscheinen soll. Die Finding-Identitaet aendert sich wie im Original ohne Profilversionssprung; die neu angelegte lokale Datenbank enthaelt keine Defender-Importe, der Versionssprung folgt mit `c7c716c`. Keine API-, SQL- oder Persistenzmigration. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |

View File

@ -2,8 +2,8 @@ import { memo } from 'react';
import { RelatedFindingRing, useRelatedFindings } from '../../features/related-findings/RelatedFindings';
import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { toolIdForRecord } from '../../importers/status-normalization';
import { findingOutcomeMark, resultStatusFieldFor } from '../../features/linked-results/result-message';
import { policyDiagnosticBadges } from '../../profiles/group-policy/categories';
import { highlightText } from '../HighlightText/highlight-text';
@ -26,9 +26,8 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
const groupMarked = useRelatedFindings()?.selection?.group.recordIds.includes(record.id);
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const severity = normalizeSeverity(severityField?.value);
const statusField = findingCardStatus(record, toolId);
const outcomeMark = findingOutcomeMark(record, toolId);
const resolvedToolId = toolIdForRecord(record, toolId);
const outcome = outcomeForStatusValue(resolvedToolId, statusField?.value);
const diagnostics = policyDiagnosticBadges(record, resolvedToolId);
return (
<div data-record-id={record.id} className={`record-card-with-group${groupMarked ? ' is-group-marked' : ''}`}>
@ -39,8 +38,8 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
onClick={onOpen}
>
<span className="record-title">{highlightText(title, search)}{bookmarked && <span className="action-pill">Workspace</span>}</span>
{(statusField || diagnostics.length > 0) && <span className="record-meta-line">
{statusField && <span className="record-meta outcome-mark" data-outcome={outcome}>{highlightText(statusField.value, search)}</span>}
{(outcomeMark || diagnostics.length > 0) && <span className="record-meta-line">
{outcomeMark && <span className="record-meta outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{diagnostics.map(diagnostic => <span className="record-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={diagnostic}>{diagnostic}</span>)}
</span>}

View File

@ -54,9 +54,13 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
const affectedDevices = model.devices.filter(device => device.occurrenceIds.length > 0 || (device.totalCves ?? 0) > 0).length;
const occurrenceSeverity = Object.fromEntries(severities.map(item => [item, model.occurrences.filter(occurrence => occurrence.severity === item).length])) as Record<DefenderSeverity, number>;
return <div className="defender-page">
return <div className="dashboard-page client-command-center defender-page">
{navigation}
<p className="defender-context"><strong>Microsoft Defender for IoT vulnerabilities</strong><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span><WorkTimeslotControl /></p>
<header className="client-overview-header defender-header">
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
<div className="client-overview-source defender-source"><span>Source:</span><strong>Microsoft Defender for IoT</strong></div>
</header>
<section className="defender-metrics" aria-label="Vulnerability metrics">
<article><span>Assets</span><strong><Count value={model.devices.length} /></strong></article>
<article><span>Unique CVEs</span><strong><Count value={model.cves.length} /></strong></article>
@ -65,6 +69,7 @@ export function DefenderVulnerabilityPanel({ dataset, navigation }: { dataset: I
<article><span>Raw sensor observations</span><strong><Count value={model.observations.length} /></strong></article>
<article><span>Aggregate-only vulnerabilities</span><strong><Count value={model.aggregateOnlyCount} /></strong></article>
</section>
<p className="defender-context"><span>{model.summaryRowCount.toLocaleString('en-US')} source asset rows</span><span>{model.diagnostics.length.toLocaleString('en-US')} diagnostics</span></p>
<nav className="defender-tabs" aria-label="Defender vulnerability views">
{([['cves', 'CVEs'], ['devices', 'Devices'], ['sites', 'Sites']] as const).map(([id, title]) => <button type="button" key={id} aria-pressed={view === id} onClick={() => setView(id)}>{title}</button>)}
</nav>

View File

@ -26,7 +26,7 @@ import { isFileReference } from '../../profiles/purple-knight/result-linker';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { findingOutcomeMark, resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
@ -428,6 +428,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const problem = resolvedToolId === 'group-policy' && !policyReadiness ? policyProblemSummary(record) : undefined;
const firstRead = resolvedToolId !== 'group-policy' ? findingFirstRead(record, resolvedToolId, linkedSheets, comparisonOutcome) : undefined;
const outcomeMark = findingOutcomeMark(record, toolId);
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
// Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten
@ -456,7 +457,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
<div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, search)}</span>}
{outcomeMark && <span className="outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{/* The heading already names the CIS section; each number badge names its section on hover. */}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" title={cisCategoryRecordTitle(record, category)} key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => {

View File

@ -1,7 +1,7 @@
import type { FindingRecord, RecordField } from '../../domain/finding';
import type { DatasetSheet } from '../../domain/dataset';
import type { CanonicalOutcome } from '../../domain/canonical-model';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
import { pingCastleAffectedDetailRow } from '../../profiles/pingcastle/result-evidence';
@ -204,6 +204,37 @@ export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: str
}
return undefined;
}
/** Menschlich lesbare Marke je kanonischem Outcome (Rueckfall ohne Quellwort). */
const OUTCOME_MARK_LABELS: Record<CanonicalOutcome, string> = {
fail: 'Failed', pass: 'Passed', warning: 'Warning', informational: 'Informational',
execution_error: 'Error', not_run: 'Not run', not_applicable: 'Not applicable',
not_evaluated: 'Not evaluated', unknown: 'Unknown'
};
export interface FindingOutcomeMark {
outcome: CanonicalOutcome;
label: string;
}
/**
* Outcome-Marke eines rohen Work-Datensatzes fuer die kompakten Kartenlisten
* und den Reader-Kopf. Fuehrt das Werkzeug ein eigenes Statuswort, bleibt dieses
* die Beschriftung. Liefert ein Werkzeug seine Funde ohne Statusspalte (jede
* Defender-CVE ist eine Abweichung), traegt das Profil den Ausfall-Outcome
* (`normalization.missingStatusOutcome`) nach, damit die Liste nicht ohne
* Outcome-Hinweis bleibt. Werkzeuge ohne deklarierten Ausfall-Outcome behalten
* das bisherige Verhalten - ohne Statusfeld keine Marke.
*/
export function findingOutcomeMark(record: FindingRecord, fallbackToolId?: string): FindingOutcomeMark | undefined {
const toolId = toolIdForRecord(record, fallbackToolId);
const statusField = resultStatusFieldFor(record, fallbackToolId);
if (statusField) {
return { outcome: outcomeForStatusValue(toolId, statusField.value) ?? 'unknown', label: statusField.value };
}
const outcome = normalizeStatusDimensionsFor(toolId, '').outcome;
return outcome === 'unknown' ? undefined : { outcome, label: OUTCOME_MARK_LABELS[outcome] };
}
/**
* Felder, die als eigener benannter Abschnitt in das Results-Fenster gehoeren
* (siehe `resultSectionHeaders` im Toolprofil).

View File

@ -1,17 +1,17 @@
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--segment-gap); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.defender-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.defender-header { order: 0; }
.defender-source strong { color: var(--heading); font-weight: var(--fw-regular); }
.defender-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; color: var(--muted); font-size: var(--type-meta-size); }
.defender-context strong { color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.defender-context .work-timeslot-select { margin-left: auto; }
.defender-metrics { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.defender-metrics article { display: grid; align-content: space-between; gap: var(--space-3); min-height: 128px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
.defender-metrics span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
.defender-metrics strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-regular); font-variant-numeric: tabular-nums; }
.defender-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); justify-content: start; gap: var(--space-2); min-width: 0; }
.defender-metrics article { display: grid; align-content: center; gap: var(--space-2); min-width: 0; min-height: 88px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; }
.defender-metrics span { color: var(--muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.3; }
.defender-metrics strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; line-height: 1; }
.defender-tabs, .client-info-tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.defender-tabs button, .client-info-tabs button { min-height: 36px; padding: var(--space-2) var(--space-4); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; font-size: var(--ui-control-size); font-weight: var(--fw-semibold); cursor: pointer; }
.defender-tabs button:hover, .client-info-tabs button:hover { background: var(--tile-surface-hover); }
.defender-tabs button:focus-visible, .client-info-tabs button:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.defender-tabs button[aria-pressed="true"], .client-info-tabs button[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface); }
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.defender-view { display: grid; gap: var(--space-4); min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); }
.defender-view > header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); }
.defender-view > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); }
.defender-view > header span { color: var(--muted); font-size: var(--type-meta-size); }
@ -28,7 +28,7 @@
.defender-severity i { width: 4px; height: 18px; background: var(--severity, var(--muted)); }
.defender-conflict, .defender-warning { color: var(--warning); }
.defender-unavailable, .defender-empty { color: var(--muted); }
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
.defender-drilldown { display: grid; gap: var(--space-3); padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; }
.defender-drilldown h3, .defender-drilldown h4 { margin: 0; color: var(--heading); font-weight: var(--fw-semibold); }
.defender-drilldown h3 { font-size: var(--type-detail-title-size); }
.defender-drilldown h4 { font-size: var(--type-h4-size); }
@ -42,4 +42,6 @@
.defender-aggregate-summary span, .defender-severity-strip strong { font-size: var(--type-table-body-size); }
.defender-severity-strip article { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.defender-severity-strip strong { color: var(--heading); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
@media (max-width: 760px) { .defender-view > header { display: grid; } .defender-context .work-timeslot-select { margin-left: 0; } }
@container (max-width: 1100px) { .defender-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@container (max-width: 760px) { .defender-view > header { display: grid; } .defender-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container (max-width: 480px) { .defender-metrics { grid-template-columns: 1fr; } }

View File

@ -48,10 +48,15 @@ describe('Defender vulnerability dashboard projection', () => {
expect(component).toContain('Site dashboard');
expect(component).toContain('Source row');
expect(component).toContain('aggregate-only');
expect(component).toContain('className="dashboard-page client-command-center defender-page"');
expect(component).toContain('className="client-overview-header defender-header"');
expect(component).toContain('<span>Source:</span><strong>Microsoft Defender for IoT</strong>');
expect(dashboard).toContain("[['overview', 'Overview'] as const]");
expect(dashboard).toContain("[['defender', 'Microsoft Defender for IoT'] as const]");
expect(dashboard).toContain('<DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} />');
expect(styles).toContain('padding: var(--panel-inset)');
expect(styles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
expect(styles).toContain('background: var(--tile-surface)');
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--marking-color)');
expect(styles).toContain('outline: var(--bw-emphasis) solid var(--focus-ring)');
expect(styles).not.toContain('text-overflow: ellipsis');

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { promotedResultSheetTitlesFor, resultMessageFor, resultSectionsFor, resultSheetRowHighlightedFor, resultSheetTitleFor, resultStatusFieldFor, showResultSheetTitlesFor } from '../../src/features/linked-results/result-message';
import { findingOutcomeMark, promotedResultSheetTitlesFor, resultMessageFor, resultSectionsFor, resultSheetRowHighlightedFor, resultSheetTitleFor, resultStatusFieldFor, showResultSheetTitlesFor } from '../../src/features/linked-results/result-message';
function maesterRecord(fields: Array<[string, string]>): FindingRecord {
const built = fields.map(([label, value], index) => ({ label, value, index }));
@ -148,6 +148,30 @@ describe('resultStatusFieldFor', () => {
expect(resultStatusFieldFor(record, 'purple-knight')).toMatchObject({ label: 'Status', value: expected });
});
describe('findingOutcomeMark', () => {
it('traegt bei Defender-Schwachstellen ohne Statusspalte den Ausfall-Outcome nach', () => {
const record = maesterRecord([
['CVE', 'CVE-2026-25786'],
['Severity', 'Critical'],
['Beschreibung', 'Synthetic description']
]);
expect(findingOutcomeMark(record, 'defender-for-iot-vulnerabilities')).toEqual({ outcome: 'warning', label: 'Warning' });
});
it('behaelt das Quell-Statuswort, wo das Werkzeug eine Statusspalte fuehrt', () => {
const record = maesterRecord([['Status', 'IOE Found'], ['Result', 'SI000067']]);
expect(findingOutcomeMark(record, 'purple-knight')).toEqual({ outcome: 'fail', label: 'Failed' });
});
it('bleibt ohne Statusfeld und ohne deklarierten Ausfall-Outcome leer', () => {
const record = maesterRecord([['ID', 'MT.1011'], ['Name', 'Some finding']]);
expect(findingOutcomeMark(record, 'maester')).toBeUndefined();
});
});
describe('promotedResultSheetTitlesFor', () => {
it('ordnet mehrere Controls derselben Framework-Ueberschrift ihren Tabellen in Reihenfolge zu', () => {
const sections = [{