tests/Report.EChartsDashboard.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# Pester tests for src/report/renderers/Export-EChartsDashboard.ps1 -- the self-contained Apache ECharts HTML dashboard renderer (ADO Story AB#344). No Azure connection, no external network access required -- the vendored ECharts library is embedded in the renderer file itself (see that file's header for the offline-bundle design decision), so this suite never fetches anything at test time either. #> BeforeAll { $script:Root = Split-Path $PSScriptRoot -Parent . "$script:Root/src/assess/engine/Get-Score.ps1" . "$script:Root/src/report/renderers/Export-EChartsDashboard.ps1" function New-DashTestFinding { param($Id, $Framework, $Area, $Status, $Severity = 'medium', $Weight = 1.0) [pscustomobject]@{ Id = $Id; Title = "$Id title"; Framework = $Framework; Area = $Area; Severity = $Severity Status = $Status; EvidenceCount = 2; Evidence = @(); Remediation = "Remediate $Id." Manual = ($Status -eq 'Manual'); AreaWeight = $Weight } } # Mirrors Export-Pptx.ps1/Export-Word.ps1's test fixtures: a mix of # statuses and deliberately blank/null/unrecognized severities on Fail # rows, so the severity-bucketing guard is genuinely exercised. $script:Findings = @( (New-DashTestFinding 'net-1' 'CAF' 'Networking' 'Pass' 'low') (New-DashTestFinding 'net-2' 'CAF' 'Networking' 'Fail' 'high') (New-DashTestFinding 'net-3' 'CAF' 'Networking' 'Fail' $null) (New-DashTestFinding 'net-4' 'CAF' 'Networking' 'Fail' '') (New-DashTestFinding 'net-5' 'CAF' 'Networking' 'Fail' 'bogus-severity') (New-DashTestFinding 'sec-1' 'WAF' 'Security' 'Partial' 'medium') (New-DashTestFinding 'sec-2' 'WAF' 'Security' 'Manual') (New-DashTestFinding 'sec-3' 'WAF' 'Security' 'Unknown') ) $script:Scored = Get-Score -Findings $script:Findings $script:CollectPath = Join-Path $script:Root 'tests' 'datadump' 'sample-collect.json' $script:Collect = Get-Content $script:CollectPath -Raw | ConvertFrom-Json -Depth 100 $script:Collect | Add-Member -NotePropertyName _meta -NotePropertyValue ([pscustomobject]@{ scope = 'ArmOnly'; managementGroupId = 'mg-test-01'; generatedOn = (Get-Date).ToString('o') }) -Force $script:OutDir = Join-Path $script:Root 'tests' 'test-output' 'echarts' if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force } New-Item -ItemType Directory -Path $script:OutDir -Force | Out-Null function Get-DashboardPayload { param([string]$Html) $match = [regex]::Match($Html, 'window\.__DASHBOARD_DATA__ = (\{.*?\});(?=\s*</script>)', [System.Text.RegularExpressions.RegexOptions]::Singleline) $match.Success | Should -BeTrue -Because 'the embedded window.__DASHBOARD_DATA__ payload must be present and well-formed' return ($match.Groups[1].Value | ConvertFrom-Json -Depth 20) } } AfterAll { if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force -ErrorAction SilentlyContinue } } Describe 'Export-EChartsDashboard -- basic structure and offline self-containment (AB#344)' { BeforeAll { $script:DashPath = Export-EChartsDashboard -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir $script:Html = Get-Content $script:DashPath -Raw $script:Payload = Get-DashboardPayload -Html $script:Html } It 'writes assessment_dashboard.html into -OutputPath and returns its path' { $script:DashPath | Should -Exist (Split-Path $script:DashPath -Leaf) | Should -Be 'assessment_dashboard.html' } It 'has no external CDN / network references (self-contained offline artifact)' { $script:Html | Should -Not -Match 'src\s*=\s*"http' $script:Html | Should -Not -Match "src\s*=\s*'http" $script:Html | Should -Not -Match 'href\s*=\s*"http' $script:Html | Should -Not -Match "href\s*=\s*'http" $script:Html | Should -Not -Match 'cdn\.' } It 'is a single self-contained file (inline style/script only, no linked css or external js)' { $script:Html | Should -Match '<style>' $script:Html | Should -Match '<script>' $script:Html | Should -Not -Match '<link[^>]+rel="stylesheet"' $script:Html | Should -Not -Match '<script[^>]+src=' } It 'embeds the real vendored Apache ECharts v5.6.0 library and reports HasEchartsLib true' { $script:Html | Should -Match 'version="5\.6\.0"' $script:Html | Should -Match 'Apache Software Foundation' $script:Payload.HasEchartsLib | Should -BeTrue } It 'declares all four required chart containers' { $script:Html | Should -Match 'id="chart-severity"' $script:Html | Should -Match 'id="chart-area-score"' $script:Html | Should -Match 'id="chart-status"' $script:Html | Should -Match 'id="chart-resources"' } It 'wires a light/dark theme toggle' { $script:Html | Should -Match 'id="theme-toggle"' $script:Html | Should -Match 'data-theme="dark"' $script:Html | Should -Match "data-theme.*===.*'dark'" } } Describe 'Export-EChartsDashboard -- chart data correctness' { BeforeAll { $script:DashPath2 = Export-EChartsDashboard -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir $script:Payload2 = Get-DashboardPayload -Html (Get-Content $script:DashPath2 -Raw) } It 'buckets findings-by-severity into exactly High/Medium/Low/Unknown, summing to the total finding count' { $names = $script:Payload2.SeverityCounts.Name $names | Should -Be @('High', 'Medium', 'Low', 'Unknown') ($script:Payload2.SeverityCounts.Value | Measure-Object -Sum).Sum | Should -Be $script:Findings.Count # net-2 (high) is the only recognized-severity Fail; net-3/4/5 (null/blank/bogus) # all bucket into Unknown alongside sec-3's Unknown *status* row (severity 'medium'). ($script:Payload2.SeverityCounts | Where-Object Name -eq 'High').Value | Should -Be 1 ($script:Payload2.SeverityCounts | Where-Object Name -eq 'Unknown').Value | Should -Be 3 } It 'produces one score-by-area entry per scored area, sorted by Framework/Area' { $script:Payload2.AreaScores.Count | Should -Be $script:Scored.Areas.Count $script:Payload2.AreaScores[0].Name | Should -Be 'CAF - Networking' $script:Payload2.AreaScores[1].Name | Should -Be 'WAF - Security' } It 'sums the pass/partial/fail/manual/unknown/error breakdown to the total finding count' { $names = $script:Payload2.StatusBreakdown.Name $names | Should -Be @('Pass', 'Partial', 'Fail', 'Manual', 'Unknown', 'Error') ($script:Payload2.StatusBreakdown.Value | Measure-Object -Sum).Sum | Should -Be $script:Findings.Count ($script:Payload2.StatusBreakdown | Where-Object Name -eq 'Fail').Value | Should -Be 4 ($script:Payload2.StatusBreakdown | Where-Object Name -eq 'Manual').Value | Should -Be 1 } It 'auto-discovers resource counts from the real sample-collect.json fixture, capped at 12, sorted descending' { $rc = $script:Payload2.ResourceCounts $rc.Count | Should -Be 12 ($rc | Where-Object Name -eq 'Virtual Networks').Value | Should -Be 3 ($rc | Where-Object Name -eq 'Virtual Machines').Value | Should -Be 3 ($rc | Where-Object Name -eq 'Advisor').Value | Should -Be 10 # descending order for ($i = 1; $i -lt $rc.Count; $i++) { $rc[$i - 1].Value | Should -BeGreaterOrEqual $rc[$i].Value } } } Describe 'Export-EChartsDashboard -- graceful degrade when Collect is absent (AB#344)' { It 'does not throw and embeds an empty ResourceCounts array when Collect is $null' { { $script:BareDashPath = Export-EChartsDashboard -Findings $script:Scored -Collect $null -OutputPath $script:OutDir } | Should -Not -Throw $payload = Get-DashboardPayload -Html (Get-Content $script:BareDashPath -Raw) @($payload.ResourceCounts).Count | Should -Be 0 } It 'does not throw on an empty Findings set (zero severity/status entries, still valid HTML)' { $emptyScored = Get-Score -Findings @() { $script:EmptyDashPath = Export-EChartsDashboard -Findings $emptyScored -Collect $null -OutputPath $script:OutDir } | Should -Not -Throw $payload = Get-DashboardPayload -Html (Get-Content $script:EmptyDashPath -Raw) ($payload.SeverityCounts.Value | Measure-Object -Sum).Sum | Should -Be 0 @($payload.AreaScores).Count | Should -Be 0 } } Describe 'Export-EChartsDashboard -- SVG/CSS fallback when the vendored library is unavailable (AB#344)' { It 'degrades to the lightweight inline SVG/CSS fallback and honestly banners it, rather than shipping a broken chart library' { $saved = $Script:ScoutEChartsLibJs try { $Script:ScoutEChartsLibJs = '' $path = Export-EChartsDashboard -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir $html = Get-Content $path -Raw $payload = Get-DashboardPayload -Html $html $payload.HasEchartsLib | Should -BeFalse $html | Should -Match 'function renderFallbackPie' $html | Should -Match 'function renderFallbackBar' $html | Should -Match 'ECharts library was not available for this build' # still self-contained -- the fallback path must not reach for a CDN either. $html | Should -Not -Match 'src\s*=\s*"http' $html | Should -Not -Match 'cdn\.' # and dramatically smaller, since the ~1MB vendored library is absent. $html.Length | Should -BeLessThan 100000 } finally { $Script:ScoutEChartsLibJs = $saved } } } Describe 'Export-EChartsDashboard -- non-fatal on failure (AB#344)' { It 'writes a clearly-labeled HTML fallback instead of throwing when rendering fails' { $dir = Join-Path $script:Root 'tests' 'test-output' 'echarts-forcefail' if (Test-Path $dir) { Remove-Item $dir -Recurse -Force } $savedFn = ${function:Get-ScoutEchartsSeverityCounts} try { Set-Item function:Get-ScoutEchartsSeverityCounts -Value { throw 'forced failure for fallback test' } $allOutput = @(Export-EChartsDashboard -Findings $script:Scored -Collect $script:Collect -OutputPath $dir 3>&1) $path = $allOutput | Where-Object { $_ -is [string] } | Select-Object -First 1 $warnings = $allOutput | Where-Object { $_ -is [System.Management.Automation.WarningRecord] } $path | Should -Exist (Split-Path $path -Leaf) | Should -Be 'assessment_dashboard_fallback.html' $content = Get-Content $path -Raw $content | Should -Match 'dashboard generation failed' $content | Should -Match 'forced failure for fallback test' @($warnings) | Where-Object { $_.Message -match 'dashboard generation failed' } | Should -Not -BeNullOrEmpty } finally { Set-Item function:Get-ScoutEchartsSeverityCounts -Value $savedFn if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue } } } } Describe 'Get-ScoutEchartsSeverityBucket / Get-ScoutEchartsLabel (unit)' { It 'buckets null/blank/unrecognized severities as Unknown and recognized ones by name, never throws' { Get-ScoutEchartsSeverityBucket $null | Should -Be 'Unknown' Get-ScoutEchartsSeverityBucket '' | Should -Be 'Unknown' Get-ScoutEchartsSeverityBucket 'not-a-real-severity' | Should -Be 'Unknown' Get-ScoutEchartsSeverityBucket 'HIGH' | Should -Be 'High' Get-ScoutEchartsSeverityBucket 'Medium' | Should -Be 'Medium' Get-ScoutEchartsSeverityBucket 'low' | Should -Be 'Low' } It 'converts camelCase Collect property names into human-readable Title Case labels' { Get-ScoutEchartsLabel 'virtualNetworks' | Should -Be 'Virtual Networks' Get-ScoutEchartsLabel 'nsgPublicInbound' | Should -Be 'Nsg Public Inbound' Get-ScoutEchartsLabel 'advisor' | Should -Be 'Advisor' } } |