tests/Export-MsecPostureReport.Tests.ps1
|
#Requires -Module Pester # # Tests for Export-MsecPostureReport and the Excel append helper under it. # # The behaviour worth pinning down is what happens on the SECOND and THIRD run, because # that is where this feature actually lives and where the obvious implementation is wrong: # # * rows must accumulate, not overwrite, and the table's range must grow with them # * a changed column set must NOT be silently swallowed: Export-Excel -Append discards # properties that are not already columns, so drift has to be detected and the sheet # reshaped rather than quietly losing a field # * a measurement whose command throws must cost only its own row, not the whole run # # Charts live on a Dashboard sheet and use ORDINARY CELL RANGES. An earlier version used # structured table references, which EPPlus stores and reads back happily - so it tested # green - and which Excel then rendered as blank charts. Nothing here can catch that; only # opening the file in Excel can. The ranges are refreshed in place as rows are appended. # # Skipped wholesale when ImportExcel is absent. Unlike the old Word report, ImportExcel is # a real dependency of the command rather than an optional extra, so CI installing it is # the intent - see .github/workflows/ci.yml. # At FILE scope, not in BeforeAll. Pester 5 evaluates a Describe's -Skip: during discovery, # which happens before any BeforeAll runs - so a flag set in BeforeAll is still $null when # the decision is made, and every guarded block skips even where ImportExcel is installed. $script:HasExcel = $null -ne (Get-Module -ListAvailable ImportExcel) BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Add-MsecExcelRow' -Skip:(-not $script:HasExcel) { BeforeEach { $script:Book = Join-Path ([System.IO.Path]::GetTempPath()) "msec-posture-$([guid]::NewGuid().Guid).xlsx" } AfterEach { if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue } } It 'accumulates rows across runs and grows the table range with them' { # The table range is what a hand-drawn Excel chart binds to, so it growing is the # whole reason this writes tables rather than plain ranges. $result = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($i in 1..3) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = "2026-08-2$i 09:00:00"; SecureScorePercent = 60 + $i }) } } $result | Should -Be @(1, 2, 3) $rows = @(Import-Excel -Path $script:Book -WorksheetName 'Scores') $rows.Count | Should -Be 3 $rows[0].SecureScorePercent | Should -Be 61 $rows[2].SecureScorePercent | Should -Be 63 $package = Open-ExcelPackage -Path $script:Book try { # Header + 3 data rows. $package.Workbook.Worksheets['Scores'].Tables['tblScores'].Address.Address | Should -Be 'A1:B4' } finally { Close-ExcelPackage $package -NoSave } } It 'writes no chart on a data sheet - those all live on the Dashboard' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($i in 1..3) { Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = "d$i"; Value = $i }) | Out-Null } } $package = Open-ExcelPackage -Path $script:Book try { @($package.Workbook.Worksheets['S'].Drawings).Count | Should -Be 0 } finally { Close-ExcelPackage $package -NoSave } } It 'adds a new column for a property that appears later, without losing earlier rows' { # Export-Excel -Append maps onto the existing header and SILENTLY DISCARDS anything # not already a column - so a measurement gaining a field would never reach the # workbook. The helper must notice and reshape instead of appending. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'a'; One = 1 }) | Out-Null Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'b'; One = 2; Two = 9 }) -WarningAction SilentlyContinue | Out-Null } $rows = @(Import-Excel -Path $script:Book -WorksheetName 'S') $rows.Count | Should -Be 2 $rows[0].RunUtc | Should -Be 'a' $rows[0].Two | Should -BeNullOrEmpty # absent when that row was written $rows[1].Two | Should -Be 9 # NOT silently dropped } It 'appends without reshaping when only the column ORDER differs' { # Export-Excel -Append maps onto the header by NAME, so a reorder needs no reshape - # and reshaping anyway would rewrite the sheet and discard manual formatting for no # reason. The drift check is therefore a set comparison, not an ordered one. $warned = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'm1'; PROD = 72; SANDBOX = 31 }) | Out-Null Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'm2'; SANDBOX = 33; PROD = 74 }) ` -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } @($warned).Count | Should -Be 0 # no reshape happened $rows = @(Import-Excel -Path $script:Book -WorksheetName 'S') $rows.Count | Should -Be 2 $rows[1].PROD | Should -Be 74 # by name, not by position $rows[1].SANDBOX | Should -Be 33 } It 'refuses to replace a damaged workbook with a fresh one' { # THE OTHER WIPE. Export-Excel can CREATE the file, so it treats "cannot read this" and # "nothing here yet" identically - and a zero-byte file (a OneDrive placeholder that # never downloaded, a run killed mid-save) is indistinguishable from new. The old # behaviour replaced it with a workbook holding one sheet and one row, reported success, # and took every other tenant and every row ever collected with it. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($i in 1..4) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = "day$i"; SecureScorePercent = 60 + $i }) | Out-Null } } (Get-Item $script:Book).Length | Should -BeGreaterThan 0 # The file survives sync as an empty husk. Set-Content -Path $script:Book -Value '' -NoNewline InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = 'day5'; SecureScorePercent = 65 }) } | Should -Throw '*0 bytes*' } # Left exactly as found, so version history can still restore it. (Get-Item $script:Book).Length | Should -Be 0 } It 'still creates a workbook that does not exist yet' { # The guard must not break a genuine first run. $fresh = Join-Path ([System.IO.Path]::GetTempPath()) "msec-new-$([guid]::NewGuid().Guid).xlsx" try { $count = InModuleScope Msec -Parameters @{ Book = $fresh } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = 'day1'; SecureScorePercent = 61 }) } $count | Should -Be 1 Test-Path $fresh | Should -BeTrue } finally { Remove-Item $fresh -Force -ErrorAction SilentlyContinue } } It 'refuses to rewrite a sheet whose history it could not read back' { # THE WIPE THIS EXISTS FOR. A reshape rewrites the sheet with -ClearSheet, so whatever # was read back is the entire surviving record. An earlier version caught every read # failure and carried on with an empty history, which turned a transient lock - the # file open in Excel, a sync client mid-write - into the silent loss of every row ever # collected, reported only through Write-Verbose. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($i in 1..5) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = "day$i"; SecureScorePercent = 60 + $i }) | Out-Null } } InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Import-Excel -MockWith { throw 'The process cannot access the file because it is being used by another process.' } # A new column arrives, so this is a reshape - the destructive path. { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = 'day6'; SecureScorePercent = 66; ExposurePercent = 20 }) ` -WarningAction SilentlyContinue } | Should -Throw '*Refusing to rewrite*' } # The file is untouched: all five rows still there, and the new one was NOT written. $rows = @(Import-Excel -Path $script:Book -WorksheetName 'Scores') @($rows).Count | Should -Be 5 @($rows | ForEach-Object { $_.RunUtc }) | Should -Not -Contain 'day6' } It 'still writes a sheet that genuinely has no rows yet' { # The other side of the guard: an absent or header-only sheet is a real "nothing to # preserve", and must not be turned into a refusal. $count = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = 'day1'; SecureScorePercent = 61 }) } $count | Should -Be 1 } It 'warns when it reshapes, because that is the one case that discards manual changes' { $captured = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'a'; One = 1 }) | Out-Null Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'b'; One = 2; Two = 9 }) ` -WarningVariable warned -WarningAction SilentlyContinue | Out-Null $warned } ($captured -join ' ') | Should -Match 'new: Two' } It 'styles the table Medium2 by default, and restyles an existing one on a later run' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'a'; Value = 1 }) | Out-Null } $package = Open-ExcelPackage -Path $script:Book try { $package.Workbook.Worksheets['S'].Tables['tblS'].StyleName | Should -Be 'TableStyleMedium2' } finally { Close-ExcelPackage $package -NoSave } # Passed on the append path too, so changing -TableStyle restyles what is already # there rather than leaving old sheets on the previous style. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' -TableStyle 'Dark7' ` -Row ([pscustomobject]@{ RunUtc = 'b'; Value = 2 }) | Out-Null } $package = Open-ExcelPackage -Path $script:Book try { $table = $package.Workbook.Worksheets['S'].Tables['tblS'] $table.StyleName | Should -Be 'TableStyleDark7' $table.Address.Address | Should -Be 'A1:B3' # and both rows are still there } finally { Close-ExcelPackage $package -NoSave } } It 'flattens a collection-valued property instead of writing System.Object[]' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Add-MsecExcelRow -Path $Book -WorksheetName 'S' -TableName 'tblS' ` -Row ([pscustomobject]@{ RunUtc = 'a'; TopFailingPolicies = @('Block legacy', 'Require MFA') }) | Out-Null } (Import-Excel -Path $script:Book -WorksheetName 'S').TopFailingPolicies | Should -Be 'Block legacy; Require MFA' } } Describe 'Add-MsecExcelDashboard' -Skip:(-not $script:HasExcel) { BeforeEach { $script:Book = Join-Path ([System.IO.Path]::GetTempPath()) "msec-dash-$([guid]::NewGuid().Guid).xlsx" InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($i in 1..3) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = "d$i"; SecureScorePercent = 60 + $i; ExposurePercent = 30 - $i }) | Out-Null Add-MsecExcelRow -Path $Book -WorksheetName 'AzureSecureScore' -TableName 'tblAzureSecureScore' ` -Row ([pscustomobject]@{ RunUtc = "d$i"; PROD = 40 + $i; SANDBOX = 20 + $i }) | Out-Null } } $script:Specs = @( [pscustomobject]@{ Sheet = 'Scores'; Table = 'tblScores'; XColumn = 'RunUtc'; Title = 'Scores'; Series = @('SecureScorePercent', 'ExposurePercent') } [pscustomobject]@{ Sheet = 'AzureSecureScore'; Table = 'tblAzureSecureScore'; XColumn = 'RunUtc'; Title = 'Azure'; Series = @('PROD', 'SANDBOX') } ) } AfterEach { if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue } } It 'puts every chart on one Dashboard sheet, and that sheet first' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs -Heading 'Posture' } $package = Open-ExcelPackage -Path $script:Book try { $package.Workbook.Worksheets[1].Name | Should -Be 'Dashboard' $dashboard = $package.Workbook.Worksheets['Dashboard'] @($dashboard.Drawings).Count | Should -Be 2 $dashboard.Cells[1, 1].Value | Should -Be 'Posture' # ...and none left behind on the data sheets. @($package.Workbook.Worksheets['Scores'].Drawings).Count | Should -Be 0 @($package.Workbook.Worksheets['AzureSecureScore'].Drawings).Count | Should -Be 0 } finally { Close-ExcelPackage $package -NoSave } } It 'points each series at an ordinary cell range on the data sheet' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartAzureSecureScore' | Select-Object -First 1 # NOT a structured table reference. Excel renders those blank in a chart, which # is the bug this replaced - and no unit test caught it, because EPPlus stores # and reads the string back happily either way. $chart.Series[0].Series | Should -Be 'AzureSecureScore!$B$2:$B$4' $chart.Series[0].XSeries | Should -Be 'AzureSecureScore!$A$2:$A$4' $chart.Series[0].Series | Should -Not -Match '\[' $chart.Series[0].Header | Should -Be 'PROD' } finally { Close-ExcelPackage $package -NoSave } } It 'refreshes the ranges as rows are appended, without rebuilding the chart' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } # An edit made in Excel that must survive the refresh. $package = Open-ExcelPackage -Path $script:Book ($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores').Title.Text = 'Renamed by the user' Close-ExcelPackage $package InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) foreach ($i in 4..6) { Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = "d$i"; SecureScorePercent = 60 + $i; ExposurePercent = 30 - $i }) | Out-Null } Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores' | Select-Object -First 1 # 6 data rows now, so the ranges must reach row 7 - a stale chart would still # say $B$2:$B$4 and flat-line after the third point. $chart.Series[0].Series | Should -Be 'Scores!$B$2:$B$7' $chart.Series[0].XSeries | Should -Be 'Scores!$A$2:$A$7' # ...and the edit survived, because the chart was refreshed rather than rebuilt. $chart.Title.Text | Should -Be 'Renamed by the user' } finally { Close-ExcelPackage $package -NoSave } } It 'sets no series colour, leaving the workbook theme to apply' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } # An explicit colour would be written into the chart XML as a solidFill on the # series line; leaving it out is what lets Excel's theme palette take over. Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $script:Book)) try { foreach ($entry in $zip.Entries | Where-Object FullName -like '*chart*.xml') { $reader = [System.IO.StreamReader]::new($entry.Open()) $xml = $reader.ReadToEnd(); $reader.Close() $xml | Should -Not -Match 'srgbClr' } } finally { $zip.Dispose() } } It 'turns smoothing off so the line does not invent points between samples' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores' | Select-Object -First 1 $chart.Series[0].Smooth | Should -BeFalse } finally { Close-ExcelPackage $package -NoSave } } It 'creates each chart once, leaving later edits alone' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } # Stand in for a human editing the chart in Excel. $package = Open-ExcelPackage -Path $script:Book ($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores').Title.Text = 'Renamed by the user' Close-ExcelPackage $package InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelRow -Path $Book -WorksheetName 'Scores' -TableName 'tblScores' ` -Row ([pscustomobject]@{ RunUtc = 'd9'; SecureScorePercent = 99; ExposurePercent = 1 }) | Out-Null Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $dashboard = $package.Workbook.Worksheets['Dashboard'] @($dashboard.Drawings).Count | Should -Be 2 # not duplicated ($dashboard.Drawings | Where-Object Name -eq 'chartScores').Title.Text | Should -Be 'Renamed by the user' } finally { Close-ExcelPackage $package -NoSave } } It 'stacks one chart per band, each starting on its own printed page' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) $specs = foreach ($i in 0..4) { $name = "Slot$i" Add-MsecExcelRow -Path $Book -WorksheetName $name -TableName "tbl$name" ` -Row ([pscustomobject]@{ RunUtc = 'd1'; Value = 1 }) | Out-Null [pscustomobject]@{ Sheet = $name; Table = "tbl$name"; XColumn = 'RunUtc' Title = "chart $i"; Series = @('Value') } } Add-MsecExcelDashboard -Path $Book -Chart @($specs) } $package = Open-ExcelPackage -Path $script:Book try { $worksheet = $package.Workbook.Worksheets['Dashboard'] $charts = @($worksheet.Drawings | Where-Object Name -like 'chartSlot*' | Sort-Object { $_.From.Row }) $charts.Count | Should -Be 5 # The band is derived from -ChartHeight, so it is read off the result rather than # hardcoded - otherwise every size change breaks this test for no reason. $charts[0].From.Row | Should -Be 2 $band = $charts[1].From.Row - $charts[0].From.Row $band | Should -BeGreaterThan 0 # One per row band: every chart starts in column A, one band below the last. for ($i = 0; $i -lt $charts.Count; $i++) { $charts[$i].From.Row | Should -Be (2 + $i * $band) $charts[$i].From.Column | Should -Be 0 } # A page break at the top of every band but the first, so each chart prints on # its own page - and no chart straddles one. for ($i = 1; $i -lt $charts.Count; $i++) { $worksheet.Row(2 + $i * $band).PageBreak | Should -BeTrue $charts[$i - 1].To.Row | Should -BeLessThan (2 + $i * $band) } } finally { Close-ExcelPackage $package -NoSave } } It 'sizes charts to fit a Word page, and derives the print area from that width' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $worksheet = $package.Workbook.Worksheets['Dashboard'] $chart = $worksheet.Drawings | Where-Object Name -eq 'chartScores' | Select-Object -First 1 # Word pastes at true pixel size, and A4 portrait at standard margins gives about # 602 px of printable width. Anything wider has to be resized on every paste. $columnsSpanned = $chart.To.Column - $chart.From.Column $columnsSpanned | Should -BeLessOrEqual 10 # ~600 px at 64 px/column # The print area follows the chart width rather than being fixed wide, so # fit-to-one-page-wide scales the narrow band back up to fill the printed sheet. # ~600 px at 64 px per column plus one of slack, so about 11 columns - and in # any case far narrower than the 16 it took when charts were sized for Excel. $area = $package.Workbook.Worksheets['Dashboard'].PrinterSettings.PrintArea.Address $area | Should -Match '\$A\$1:\$[A-Z]+\$\d+' $lastColumn = [regex]::Match($area, ':\$([A-Z]+)\$').Groups[1].Value $lastColumn | Should -Not -BeNullOrEmpty $lastColumn.Length | Should -Be 1 # single letter, so < 27 columns [int][char]$lastColumn | Should -BeLessOrEqual ([int][char]'L') } finally { Close-ExcelPackage $package -NoSave } } It 'honours an explicit chart size, and -Reset is what applies it to an existing sheet' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs -ChartWidth 600 -ChartHeight 370 # Without -Reset the existing chart keeps its size: charts are created once. Add-MsecExcelDashboard -Path $Book -Chart $Specs -ChartWidth 1200 -ChartHeight 700 } $package = Open-ExcelPackage -Path $script:Book $narrow = ($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores') $narrowSpan = $narrow.To.Column - $narrow.From.Column Close-ExcelPackage $package -NoSave $narrowSpan | Should -BeLessOrEqual 10 InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs -ChartWidth 1200 -ChartHeight 700 -Reset } $package = Open-ExcelPackage -Path $script:Book try { $wide = ($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartScores') ($wide.To.Column - $wide.From.Column) | Should -BeGreaterThan $narrowSpan } finally { Close-ExcelPackage $package -NoSave } } It 'sets up the page so the charts actually come out in a PDF export' { InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $settings = $package.Workbook.Worksheets['Dashboard'].PrinterSettings $settings.Orientation | Should -Be 'Landscape' $settings.PaperSize | Should -Be 'A4' $settings.FitToPage | Should -BeTrue $settings.FitToWidth | Should -Be 1 # 0 = as many pages tall as needed. 1 would squeeze every chart onto one page. $settings.FitToHeight | Should -Be 0 # Charts are drawings anchored to cells, so they print only if those cells are # inside the print area - without this the export is blank pages. $settings.PrintArea.Address | Should -Match '\$A\$1:\$[A-Z]+\$\d+' } finally { Close-ExcelPackage $package -NoSave } } It 'repositions existing charts when a new measurement is inserted mid-list' { # This is how PolicyCompliance was added: not appended, but slotted in among the # existing specs. Every later slot shifts, and without repositioning the new chart is # drawn exactly on top of whichever chart already held that row - invisible, with # nothing to say it happened. $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) foreach ($name in 'A', 'B', 'C', 'D') { Add-MsecExcelRow -Path $Book -WorksheetName $name -TableName "tbl$name" ` -Row ([pscustomobject]@{ RunUtc = 'd1'; Value = 1 }) | Out-Null } $spec = { param($sheet) [pscustomobject]@{ Sheet = $sheet; Table = "tbl$sheet"; XColumn = 'RunUtc' Title = $sheet; Series = @('Value') } } Add-MsecExcelDashboard -Path $Book -Chart @((& $spec 'A'), (& $spec 'B'), (& $spec 'C')) # 'D' inserted BEFORE 'C', so C's slot moves from 2 to 3. Add-MsecExcelDashboard -Path $Book -Chart @((& $spec 'A'), (& $spec 'B'), (& $spec 'D'), (& $spec 'C')) } $package = Open-ExcelPackage -Path $script:Book try { $charts = @($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -like 'chart?') $positions = @($charts | ForEach-Object { $_.From.Row }) $charts.Count | Should -Be 4 @($positions | Sort-Object -Unique).Count | Should -Be 4 # no two stacked # C moved down a band to make room for D, rather than D landing on top of C. $band = ($charts | Where-Object Name -eq 'chartB').From.Row - ($charts | Where-Object Name -eq 'chartA').From.Row ($charts | Where-Object Name -eq 'chartD').From.Row | Should -Be (2 + 2 * $band) ($charts | Where-Object Name -eq 'chartC').From.Row | Should -Be (2 + 3 * $band) } finally { Close-ExcelPackage $package -NoSave } } It 'adds a series for a column that appeared after the chart was drawn' { # These sheets grow a column whenever the estate does - a new Azure subscription, a new # Secure Score category, a new policy initiative. Without this the column lands on the # data sheet while the chart silently keeps plotting only what existed the day it was # first drawn, so the report quietly under-reports the estate. InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs # A third subscription turns up. Add-MsecExcelRow -Path $Book -WorksheetName 'AzureSecureScore' -TableName 'tblAzureSecureScore' ` -Row ([pscustomobject]@{ RunUtc = 'd4'; PROD = 44; SANDBOX = 24; NEWSUB = 10 }) -WarningAction SilentlyContinue | Out-Null $grown = @( $Specs[0] [pscustomobject]@{ Sheet = 'AzureSecureScore'; Table = 'tblAzureSecureScore' XColumn = 'RunUtc'; Title = 'Azure'; Series = @('PROD', 'SANDBOX', 'NEWSUB') } ) Add-MsecExcelDashboard -Path $Book -Chart $grown } $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartAzureSecureScore' @($chart.Series | ForEach-Object { $_.Header }) | Should -Contain 'NEWSUB' @($chart.Series).Count | Should -Be 3 # Every series spans the full row range, the newcomer included. foreach ($series in $chart.Series) { $series.Series | Should -Match '\$5$' } } finally { Close-ExcelPackage $package -NoSave } } It 'keeps growing the range of a series this run did not produce' { # A subscription that dropped out of the Az context. Its column stays on the sheet # (Write-MsecExcelSheet takes the UNION, so history is never dropped), and its series # must keep spanning the sheet - otherwise the line stops dead at the row count it had # when it was last collected, which reads as the data ending rather than the # subscription leaving. InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs Add-MsecExcelRow -Path $Book -WorksheetName 'AzureSecureScore' -TableName 'tblAzureSecureScore' ` -Row ([pscustomobject]@{ RunUtc = 'd4'; PROD = 44; SANDBOX = 24 }) | Out-Null # SANDBOX is gone from this run's spec, the way a vanished subscription would be. $shrunk = @( $Specs[0] [pscustomobject]@{ Sheet = 'AzureSecureScore'; Table = 'tblAzureSecureScore' XColumn = 'RunUtc'; Title = 'Azure'; Series = @('PROD') } ) Add-MsecExcelDashboard -Path $Book -Chart $shrunk } $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartAzureSecureScore' # Not removed - the history it plots is still real. @($chart.Series | ForEach-Object { $_.Header }) | Should -Contain 'SANDBOX' $sandbox = $chart.Series | Where-Object Header -eq 'SANDBOX' $prod = $chart.Series | Where-Object Header -eq 'PROD' $sandbox.Series | Should -Match '\$5$' $prod.Series | Should -Match '\$5$' } finally { Close-ExcelPackage $package -NoSave } } It 'leaves no gap for a measurement that has not succeeded yet' { # 'Email' has no sheet, so its chart cannot be drawn - and it must cost NOTHING. The # earlier design reserved a slot for it, which on a workbook holding one measurement # meant the only chart sat several blank pages down the dashboard. $withGap = @( $script:Specs[0] [pscustomobject]@{ Sheet = 'Email'; Table = 'tblEmail'; XColumn = 'RunUtc'; Title = 'Email'; Series = @('Phishing') } $script:Specs[1] ) InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $withGap } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $dashboard = $package.Workbook.Worksheets['Dashboard'] @($dashboard.Drawings).Count | Should -Be 2 @($dashboard.Drawings | Where-Object Name -eq 'chartEmail').Count | Should -Be 0 # Slot 1, not slot 2: AzureSecureScore sits directly under Scores, with Email's # empty place collapsed rather than printed as a blank page. $scores = ($dashboard.Drawings | Where-Object Name -eq 'chartScores').From.Row $azure = ($dashboard.Drawings | Where-Object Name -eq 'chartAzureSecureScore').From.Row $scores | Should -Be 2 # One band apart, whatever the band works out to. $gap = $azure - $scores $gap | Should -BeGreaterThan 0 $gap | Should -BeLessThan 40 # one band, not two } finally { Close-ExcelPackage $package -NoSave } } It 'pushes later charts down when a new measurement lands, without overlapping' { # The accepted cost of packing densely, pinned so it stays a one-off move rather than # turning into charts drawn on top of each other. $withGap = @( $script:Specs[0] [pscustomobject]@{ Sheet = 'Email'; Table = 'tblEmail'; XColumn = 'RunUtc'; Title = 'Email'; Series = @('Phishing') } $script:Specs[1] ) $before = InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $withGap } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs $p = Open-ExcelPackage -Path $Book $row = ($p.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartAzureSecureScore').From.Row Close-ExcelPackage $p -NoSave $row } # Now Email succeeds for the first time, taking the slot between the two. InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $withGap } { param($Book, $Specs) Add-MsecExcelRow -Path $Book -WorksheetName 'Email' -TableName 'tblEmail' ` -Row ([pscustomobject]@{ RunUtc = 'd1'; Phishing = 3 }) | Out-Null Add-MsecExcelDashboard -Path $Book -Chart $Specs } $package = Open-ExcelPackage -Path $script:Book try { $drawings = $package.Workbook.Worksheets['Dashboard'].Drawings @($drawings).Count | Should -Be 3 $rows = @($drawings | ForEach-Object { $_.From.Row }) # Every chart on its own row - the failure this guards is two charts sharing one, # where the lower is invisible. @($rows | Select-Object -Unique).Count | Should -Be 3 $scores = ($drawings | Where-Object Name -eq 'chartScores').From.Row $email = ($drawings | Where-Object Name -eq 'chartEmail').From.Row $azure = ($drawings | Where-Object Name -eq 'chartAzureSecureScore').From.Row # Canonical ORDER is still the caller's, even though spacing is not. $scores | Should -BeLessThan $email $email | Should -BeLessThan $azure # And the newcomer pushed the last one down rather than landing on it. $azure | Should -BeGreaterThan $before } finally { Close-ExcelPackage $package -NoSave } } It 'repositions a chart whose columns were not discovered this run' { # The partial-run trap. A spec whose series are DISCOVERED from the data - one column # per subscription, per initiative, per Secure Score category - carries an EMPTY series # list on a run that did not collect that measurement. If an empty list made the whole # spec be skipped, the chart would keep the row it was drawn at while every slot around # it moved, and the next chart would be drawn straight on top of it. InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) Add-MsecExcelDashboard -Path $Book -Chart $Specs } # Displace it, the way inserting a new measurement ahead of it would. $package = Open-ExcelPackage -Path $script:Book ($package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartAzureSecureScore').SetPosition(500, 0, 0, 0) Close-ExcelPackage $package # Now a run that collected only Scores: the Azure spec knows no columns at all. InModuleScope Msec -Parameters @{ Book = $script:Book; Specs = $script:Specs } { param($Book, $Specs) $partial = @( $Specs[0] [pscustomobject]@{ Sheet = 'AzureSecureScore'; Table = 'tblAzureSecureScore' XColumn = 'RunUtc'; Title = 'Azure'; Series = @() } ) Add-MsecExcelDashboard -Path $Book -Chart $partial } $package = Open-ExcelPackage -Path $script:Book try { $drawings = $package.Workbook.Worksheets['Dashboard'].Drawings $scores = ($drawings | Where-Object Name -eq 'chartScores').From.Row $azure = ($drawings | Where-Object Name -eq 'chartAzureSecureScore').From.Row $azure | Should -Not -Be 500 # back in its slot $azure | Should -BeGreaterThan $scores # and below the chart before it # Its series are untouched: this run knew nothing about its columns, so it had # nothing to say about them. $series = $drawings | Where-Object Name -eq 'chartAzureSecureScore' | ForEach-Object { $_.Series } | ForEach-Object { $_.Header } @($series) | Should -Be @('PROD', 'SANDBOX') } finally { Close-ExcelPackage $package -NoSave } } } Describe 'Export-MsecPostureReport' -Skip:(-not $script:HasExcel) { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant-1'; ClientId = 'client'; KeyVaultName = 'kv' KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{} } } $script:Book = Join-Path ([System.IO.Path]::GetTempPath()) "msec-report-$([guid]::NewGuid().Guid).xlsx" } AfterEach { if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue } } It 'trims Secure Score to its newest snapshot rather than appending 90 days of history' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecSecureScore -MockWith { [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-01'; ScorePercent = 50 } [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-15'; ScorePercent = 55 } [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-27'; ScorePercent = 61 } [pscustomobject]@{ ScoreType = 'Identity'; Date = [datetime]'2026-08-27'; ScorePercent = 70 } } Mock Get-MsecDefenderScoreExposure -MockWith { [pscustomobject]@{ ScorePercent = 29 } } Mock Get-MsecDefenderScoreDeviceConfiguration -MockWith { [pscustomobject]@{ Score = 412 } } Export-MsecPostureReport -Path $Book -Measurement Scores, SecureScoreByCategory ` -WarningAction SilentlyContinue | Out-Null } $scores = @(Import-Excel -Path $script:Book -WorksheetName 'Scores') $scores.Count | Should -Be 1 $scores[0].SecureScorePercent | Should -Be 61 # newest, not 50 or 55 $scores[0].ExposurePercent | Should -Be 29 $scores[0].DeviceConfigurationScore | Should -Be 412 $scores[0].TenantId | Should -Be 'tenant-1' (Import-Excel -Path $script:Book -WorksheetName 'SecureScoreByCategory').Identity | Should -Be 70 } It 'gives Azure Secure Score one column per subscription, not a tenant-wide average' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecAzureSecureScore -MockWith { [pscustomobject]@{ ScoreType = 'Overall'; SubscriptionId = 'aaaaaaaa-1111-2222-3333-444444444444'; SubscriptionName = 'PROD'; ScorePercent = 72 } [pscustomobject]@{ ScoreType = 'Overall'; SubscriptionId = 'bbbbbbbb-1111-2222-3333-444444444444'; SubscriptionName = 'SANDBOX'; ScorePercent = 31 } # A control-level row, which must not become a column of its own. [pscustomobject]@{ ScoreType = 'EnableMFA'; SubscriptionId = 'aaaaaaaa-1111-2222-3333-444444444444'; SubscriptionName = 'PROD'; ScorePercent = 90 } } Export-MsecPostureReport -Path $Book -Measurement AzureSecureScore -WarningAction SilentlyContinue | Out-Null } $azure = @(Import-Excel -Path $script:Book -WorksheetName 'AzureSecureScore') $azure.Count | Should -Be 1 $azure[0].PROD | Should -Be 72 $azure[0].SANDBOX | Should -Be 31 # Averaging 72 and 31 to 51.5 would describe neither subscription. $azure[0].PSObject.Properties.Name | Should -Not -Contain 'AzureSecureScorePercent' $azure[0].PSObject.Properties.Name | Should -Not -Contain 'EnableMFA' } It 'disambiguates two subscriptions that share a name instead of losing one' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecAzureSecureScore -MockWith { [pscustomobject]@{ ScoreType = 'Overall'; SubscriptionId = 'aaaaaaaa-1111-2222-3333-444444444444'; SubscriptionName = 'Cloud Subscription'; ScorePercent = 40 } [pscustomobject]@{ ScoreType = 'Overall'; SubscriptionId = 'bbbbbbbb-1111-2222-3333-444444444444'; SubscriptionName = 'Cloud Subscription'; ScorePercent = 80 } } Export-MsecPostureReport -Path $Book -Measurement AzureSecureScore -WarningAction SilentlyContinue | Out-Null } $azure = @(Import-Excel -Path $script:Book -WorksheetName 'AzureSecureScore') $columns = @($azure[0].PSObject.Properties.Name) # Both survive: the second is suffixed with the leading octet of its id. $azure[0].'Cloud Subscription' | Should -Be 40 ($columns | Where-Object { $_ -like 'Cloud Subscription*' }).Count | Should -Be 2 $columns | Should -Contain 'Cloud Subscription (bbbbbbbb)' } It 'passes -Subscription through to Get-MsecAzureSecureScore as resolved ids' { $seen = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Resolve-MsecSubscription -MockWith { @('aaaaaaaa-1111-2222-3333-444444444444') } $captured = @{} Mock Get-MsecAzureSecureScore -MockWith { $captured['Ids'] = $SubscriptionId [pscustomobject]@{ ScoreType = 'Overall'; SubscriptionId = 'aaaaaaaa-1111-2222-3333-444444444444'; SubscriptionName = 'PROD'; ScorePercent = 72 } } Export-MsecPostureReport -Path $Book -Measurement AzureSecureScore ` -Subscription 'PROD' -WarningAction SilentlyContinue | Out-Null $captured } $seen['Ids'] | Should -Be @('aaaaaaaa-1111-2222-3333-444444444444') } It 'fails loudly on an unresolvable subscription rather than logging it as unavailable' { # A typo in a subscription name must not read as "Azure Secure Score was not # licensed on this tenant", which is what a per-measurement catch would make of it. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Resolve-MsecSubscription -MockWith { throw "No subscription matched 'PRDO'." } { Export-MsecPostureReport -Path $Book -Measurement AzureSecureScore -Subscription 'PRDO' } | Should -Throw "*PRDO*" } } It 'appends one row per run, so three runs leave three rows' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) $script:Pct = 60 Mock Get-MsecSecureScore -MockWith { $script:Pct += 2 [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-27'; ScorePercent = $script:Pct } } Mock Get-MsecDefenderScoreExposure -MockWith { } Mock Get-MsecDefenderScoreDeviceConfiguration -MockWith { } 1..3 | ForEach-Object { Export-MsecPostureReport -Path $Book -Measurement Scores -WarningAction SilentlyContinue } } $scores = @(Import-Excel -Path $script:Book -WorksheetName 'Scores') $scores.Count | Should -Be 3 $scores.SecureScorePercent | Should -Be @(62, 64, 66) } It 'keeps going when one measurement throws, and records it in RunLog' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecSecureScore -MockWith { [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-27'; ScorePercent = 61 } } Mock Get-MsecDefenderScoreExposure -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } Mock Get-MsecDefenderScoreDeviceConfiguration -MockWith { } Mock Get-MsecDefenderIncidentStats -MockWith { throw 'no Defender licence' } Export-MsecPostureReport -Path $Book -Measurement Scores, Incidents ` -WarningAction SilentlyContinue | Out-Null } # The score row still landed, minus the exposure figure. $scores = @(Import-Excel -Path $script:Book -WorksheetName 'Scores') $scores.Count | Should -Be 1 $scores[0].SecureScorePercent | Should -Be 61 $scores[0].ExposurePercent | Should -BeNullOrEmpty # The failed measurement contributed NO row rather than a fabricated zero. { Import-Excel -Path $script:Book -WorksheetName 'Incidents' -ErrorAction Stop } | Should -Throw $log = @(Import-Excel -Path $script:Book -WorksheetName 'RunLog') ($log | Where-Object Source -eq 'Get-MsecDefenderScoreExposure').Status | Should -Be 'Failed' ($log | Where-Object Source -eq 'Get-MsecDefenderIncidentStats').Message | Should -Match 'licence' ($log | Where-Object Source -eq 'Get-MsecSecureScore').Status | Should -Be 'Succeeded' } It 'skips policy compliance when the Az context is on a different tenant' { # The multi-tenant loop this report invites: Connect-Msec per tenant, export per # tenant. Connect-Msec does not move the Az context, so without this guard tenant B's # workbook gets tenant A's policy numbers - plausible, wrong, and in a compliance report. $result = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'other-tenant' } } } Mock Search-MsecAzureResourceGraph -MockWith { throw 'must not be called' } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance ` -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } ($result -join ' ') | Should -Match 'WRONG TENANT' ($result -join ' ') | Should -Match 'Select-MsecAzureContext' # No sheet at all - a gap in the chart, not a wrong number. { Import-Excel -Path $script:Book -WorksheetName 'PolicyCompliance' -ErrorAction Stop } | Should -Throw # ...and the reason is recorded, so a missing column is explainable months later. $log = @(Import-Excel -Path $script:Book -WorksheetName 'RunLog') ($log | Where-Object Source -like '*Policy/Compliance*').Status | Should -Be 'Skipped' } It 'collects policy compliance when the Az context matches the session tenant' { $columns = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Same tenant as the session set up in BeforeEach. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Initiative = 'NIS2'; CompliantResources = 8; Resources = 10 } } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance -WarningAction SilentlyContinue | Out-Null @((Import-Excel -Path $Book -WorksheetName 'PolicyCompliance')[0].PSObject.Properties.Name) } $columns | Should -Contain 'NIS2' } It 'weights policy compliance by resources rather than averaging the percentages' { $row = InModuleScope Msec { # Matches the session tenant, so the wrong-tenant guard lets it through. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { # Same initiative in two subscriptions of very different size. [pscustomobject]@{ Initiative = 'CIS Benchmark'; SubscriptionName = 'PROD' CompliantResources = 100; Resources = 400 } # 25% [pscustomobject]@{ Initiative = 'CIS Benchmark'; SubscriptionName = 'SANDBOX' CompliantResources = 4; Resources = 4 } # 100% } @(Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance ` -PassThru -WarningAction SilentlyContinue)[0].Row } -Parameters @{ Book = $script:Book } # 104 compliant of 404 graded = 25.74%. # Averaging the two percentages would give 62.5 - a number matching neither # subscription, and one that a four-resource sandbox can swing at will. $row.'CIS Benchmark' | Should -Be 25.74 } It 'orders initiative columns by how much of the estate they grade' { $columns = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Matches the session tenant, so the wrong-tenant guard lets it through. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Initiative = 'Small'; CompliantResources = 1; Resources = 2 } [pscustomobject]@{ Initiative = 'Broad'; CompliantResources = 50; Resources = 900 } [pscustomobject]@{ Initiative = 'Medium'; CompliantResources = 10; Resources = 40 } # Grades nothing, so it is not a column at all - an empty line on the chart # says less than no line. [pscustomobject]@{ Initiative = 'Unused'; CompliantResources = 0; Resources = 0 } } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance -WarningAction SilentlyContinue | Out-Null @((Import-Excel -Path $Book -WorksheetName 'PolicyCompliance')[0].PSObject.Properties.Name) } # Widest coverage leftmost, so the first chart series is the one grading most. $initiatives = @($columns | Where-Object { $_ -notin 'RunUtc', 'TenantId' }) $initiatives | Should -Be @('Broad', 'Medium', 'Small') $initiatives | Should -Not -Contain 'Unused' } It 'filters initiatives by wildcard' { $columns = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Matches the session tenant, so the wrong-tenant guard lets it through. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Initiative = 'CIS Benchmark v2'; CompliantResources = 5; Resources = 10 } [pscustomobject]@{ Initiative = 'Tagging standards'; CompliantResources = 5; Resources = 10 } } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance ` -PolicyInitiative '*Benchmark*' -WarningAction SilentlyContinue | Out-Null @((Import-Excel -Path $Book -WorksheetName 'PolicyCompliance')[0].PSObject.Properties.Name) } $columns | Should -Contain 'CIS Benchmark v2' $columns | Should -Not -Contain 'Tagging standards' } It 'warns per pattern that matched nothing, and names what was actually in scope' { # Three standards asked for, two assigned. Silently returning two columns would read # as "SOC 2 has no data" rather than "SOC 2 is not assigned, or is called something # else here". $warnings = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Matches the session tenant, so the wrong-tenant guard lets it through. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Initiative = 'ISO 27001:2013'; CompliantResources = 5; Resources = 10 } [pscustomobject]@{ Initiative = 'NIS2'; CompliantResources = 8; Resources = 10 } } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance ` -PolicyInitiative '*NIS2*', '*SOC*', '*27001*' ` -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } $text = $warnings -join ' ' $text | Should -Match "No policy initiative matches '\*SOC\*'" $text | Should -Match 'ISO 27001:2013' # says what IS there $text | Should -Not -Match "matches '\*NIS2\*'" # the two that hit stay quiet $text | Should -Not -Match "matches '\*27001\*'" # ...and the two that matched still produce their columns. $columns = @((Import-Excel -Path $script:Book -WorksheetName 'PolicyCompliance')[0].PSObject.Properties.Name) $columns | Should -Contain 'NIS2' $columns | Should -Contain 'ISO 27001:2013' } It 'warns rather than silently drawing an unreadable chart when many initiatives are in scope' { $warnings = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # Matches the session tenant, so the wrong-tenant guard lets it through. Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = [pscustomobject]@{ Id = 'tenant-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { 1..10 | ForEach-Object { [pscustomobject]@{ Initiative = "Initiative $_"; CompliantResources = 1; Resources = 2 } } } Export-MsecPostureReport -Path $Book -Measurement PolicyCompliance ` -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } ($warnings -join ' ') | Should -Match '10 policy initiatives' ($warnings -join ' ') | Should -Match '-PolicyInitiative' } It 'aggregates Intune compliance from the per-device rows' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id = 'a'; ComplianceState = 'compliant'; IsEncrypted = $true } [pscustomobject]@{ Id = 'b'; ComplianceState = 'compliant'; IsEncrypted = $true } [pscustomobject]@{ Id = 'c'; ComplianceState = 'noncompliant'; IsEncrypted = $false } [pscustomobject]@{ Id = 'd'; ComplianceState = 'inGracePeriod'; IsEncrypted = $true } } Export-MsecPostureReport -Path $Book -Measurement DeviceCompliance -WarningAction SilentlyContinue | Out-Null } $devices = @(Import-Excel -Path $script:Book -WorksheetName 'DeviceCompliance') $devices[0].TotalDevices | Should -Be 4 $devices[0].Compliant | Should -Be 2 $devices[0].Noncompliant | Should -Be 1 $devices[0].InGracePeriod | Should -Be 1 $devices[0].Encrypted | Should -Be 3 # Out of ALL enrolled devices - counting the grace-period one out of the denominator # would flatter the number exactly where it matters. $devices[0].CompliantPercent | Should -Be 50 } It 'counts privileged people, not privileged assignments' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraRoleHolder -MockWith { # One person, three roles. Counting rows would call this three administrators. [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; RoleName = 'Global Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; RoleName = 'Security Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; RoleName = 'Exchange Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } # A second, genuinely different person. [pscustomobject]@{ EffectiveId = 'u2'; EffectiveType = 'user'; RoleName = 'Global Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } # Holds a role, but not a privileged one - in AllRoleAssignments and nothing else. [pscustomobject]@{ EffectiveId = 'u3'; EffectiveType = 'user'; RoleName = 'Message Center Reader' IsHighlyPrivileged = $false; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } } Export-MsecPostureReport -Path $Book -Measurement PrivilegedAccess -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'PrivilegedAccess')[0] $row.StandingPrivileged | Should -Be 2 # people, not the 4 privileged rows $row.GlobalAdminHolders | Should -Be 2 # Both totals are still carried, so the ratio between them stays checkable. $row.PrivilegedAssignments | Should -Be 4 $row.AllRoleAssignments | Should -Be 5 } It 'splits standing privilege from PIM-eligible, counting someone with both in each' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraRoleHolder -MockWith { [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; RoleName = 'Global Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } # Same person, also eligible. Standing access they never have to activate for # plus an eligible assignment is a real state, and belongs in both counts. [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; RoleName = 'Security Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Eligible'; AccountEnabled = $true; IsResolved = $true } [pscustomobject]@{ EffectiveId = 'u2'; EffectiveType = 'user'; RoleName = 'Security Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Eligible'; AccountEnabled = $true; IsResolved = $true } } Export-MsecPostureReport -Path $Book -Measurement PrivilegedAccess -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'PrivilegedAccess')[0] $row.StandingPrivileged | Should -Be 1 $row.EligiblePrivileged | Should -Be 2 } It 'separates the holders no MFA or PIM policy covers' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraRoleHolder -MockWith { [pscustomobject]@{ EffectiveId = 'u1'; EffectiveType = 'user'; UserType = 'Member'; RoleName = 'Global Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } [pscustomobject]@{ EffectiveId = 'g1'; EffectiveType = 'user'; UserType = 'Guest'; RoleName = 'Global Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } [pscustomobject]@{ EffectiveId = 'sp1'; EffectiveType = 'servicePrincipal'; RoleName = 'Application Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $true; IsResolved = $true } # Cannot sign in today, but the assignment survives the account being re-enabled. [pscustomobject]@{ EffectiveId = 'u2'; EffectiveType = 'user'; UserType = 'Member'; RoleName = 'Security Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; AccountEnabled = $false; IsResolved = $true } # Graph would not name this one - privilege nobody is reviewing. [pscustomobject]@{ EffectiveId = 'x1'; EffectiveType = 'unknown'; RoleName = 'Security Administrator' IsHighlyPrivileged = $true; AssignmentType = 'Active'; IsResolved = $false } } Export-MsecPostureReport -Path $Book -Measurement PrivilegedAccess -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'PrivilegedAccess')[0] $row.PrivilegedGuests | Should -Be 1 $row.PrivilegedServicePrincipals | Should -Be 1 $row.PrivilegedDisabled | Should -Be 1 $row.UnresolvedHolders | Should -Be 1 } It 'contributes no row rather than a row of zeroes when role holders cannot be read' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraRoleHolder -MockWith { throw 'Forbidden' } Export-MsecPostureReport -Path $Book -Measurement PrivilegedAccess -WarningAction SilentlyContinue | Out-Null } # A fabricated zero would read as 'no administrators', which is the one conclusion # a failed read must never support. The gap in the chart is the honest answer. @(Open-ExcelPackage -Path $script:Book | ForEach-Object { $_.Workbook.Worksheets.Name }) | Should -Not -Contain 'PrivilegedAccess' @(Import-Excel -Path $script:Book -WorksheetName 'RunLog')[0].Source | Should -Be 'Get-MsecEntraRoleHolder' } It 'counts devices per OS family, and never drops one with no OS' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='Windows'; OsVersion='10.0.19045.4046'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='Windows'; OsVersion='10.0.22631.3155'; ComplianceState='compliant' } [pscustomobject]@{ Id='c'; Os='iOS'; OsVersion='17.4.1'; ComplianceState='compliant' } [pscustomobject]@{ Id='d'; Os='macOS'; OsVersion='14.4.1'; ComplianceState='compliant' } # No OS reported. Still a device - dropping it would quietly shrink the total. [pscustomobject]@{ Id='e'; Os=$null; OsVersion=$null; ComplianceState='unknown' } } Export-MsecPostureReport -Path $Book -Measurement DevicePlatform -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'DevicePlatform')[0] $row.TotalDevices | Should -Be 5 $row.Windows | Should -Be 2 $row.iOS | Should -Be 1 $row.macOS | Should -Be 1 $row.Unknown | Should -Be 1 # The columns must account for every device, or the sheet is quietly lying. ($row.Windows + $row.iOS + $row.macOS + $row.Unknown) | Should -Be $row.TotalDevices } It 'files Windows 11 as Windows 11, not as Windows 10' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { # Windows 11 reports itself as 10.0.x - the build is the ONLY thing separating # the two, and splitting on the version string files every 11 as a 10. [pscustomobject]@{ Id='a'; Os='Windows'; OsVersion='10.0.19045.4046'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='Windows'; OsVersion='10.0.22000.194'; ComplianceState='compliant' } [pscustomobject]@{ Id='c'; Os='Windows'; OsVersion='10.0.22631.3155'; ComplianceState='compliant' } [pscustomobject]@{ Id='d'; Os='iOS'; OsVersion='17.4.1'; ComplianceState='compliant' } [pscustomobject]@{ Id='e'; Os='iOS'; OsVersion='16.7.7'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'DeviceOsVersion')[0] $row.'Windows 10' | Should -Be 1 $row.'Windows 11' | Should -Be 2 # 22000 is the boundary, and it is inclusive $row.'iOS 17' | Should -Be 1 $row.'iOS 16' | Should -Be 1 $row.TotalDevices | Should -Be 5 } It 'collapses patch-level versions into one release column' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { # Three builds of the same release. Column-per-raw-version would reshape the # sheet every patch Tuesday and give the chart three one-point series. [pscustomobject]@{ Id='a'; Os='Windows'; OsVersion='10.0.22631.3155'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='Windows'; OsVersion='10.0.22631.4169'; ComplianceState='compliant' } [pscustomobject]@{ Id='c'; Os='Windows'; OsVersion='10.0.22621.2861'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null } $row = @(Import-Excel -Path $script:Book -WorksheetName 'DeviceOsVersion')[0] $row.'Windows 11' | Should -Be 3 # One release column, not three build columns. @($row.PSObject.Properties.Name | Where-Object { $_ -like 'Windows*' }).Count | Should -Be 1 } It 'collects Intune once when several device measurements are asked for' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='Windows'; OsVersion='10.0.22631.3155'; ComplianceState='compliant'; IsEncrypted=$true } } Export-MsecPostureReport -Path $Book -Measurement DeviceCompliance,DevicePlatform,DeviceOsVersion -WarningAction SilentlyContinue | Out-Null # Three sheets off ONE read - they all describe the same device list. Should -Invoke Get-MsecIntuneDevice -Times 1 -Exactly } $names = @(Open-ExcelPackage -Path $script:Book | ForEach-Object { $_.Workbook.Worksheets.Name }) $names | Should -Contain 'DeviceCompliance' $names | Should -Contain 'DevicePlatform' $names | Should -Contain 'DeviceOsVersion' } It 'reports a release nobody is on any more as 0, not as blank' { # A blank is a GAP in an Excel line chart, so the line stops dead exactly where it # should have descended to zero - "we stopped measuring" instead of "nobody is on it # any more", which is the good news you most want to see. Columns here are discovered # from the data, so the run where the last device leaves a release simply stops # producing that column, and -Append maps by name and leaves the cell empty. $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='26.1'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='26.1'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null # Everyone upgrades. 'iOS 26' is no longer produced by the collection at all. Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='27.0'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='27.0'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null @(Import-Excel -Path $Book -WorksheetName 'DeviceOsVersion') } @($rows).Count | Should -Be 2 $rows[1].'iOS 27' | Should -Be 2 # 0, and specifically NOT blank. $rows[1].'iOS 26' | Should -Be 0 $rows[1].'iOS 26' | Should -Not -BeNullOrEmpty # The columns still account for every device. ([int] $rows[1].'iOS 26' + [int] $rows[1].'iOS 27') | Should -Be $rows[1].TotalDevices } It 'does not rewrite the sheet when a release merely empties out' { # A column going missing is schema drift as much as a column appearing, and a rewrite # is the one operation that discards manual formatting. Backfilling the zero avoids it. $warnings = @() InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='26.1'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='27.0'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null } InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) # The iOS 26 device upgrades; the release set SHRINKS. Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='27.0'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='27.0'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join ' ') | Should -Not -Match 'different column set' } It 'still adds a column and a chart series when a NEW release appears' { # The other direction, which cannot avoid a reshape - and must not. InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='26.1'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='iOS'; OsVersion='26.1'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='28.0'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DeviceOsVersion -WarningAction SilentlyContinue | Out-Null } $rows = @(Import-Excel -Path $script:Book -WorksheetName 'DeviceOsVersion') $rows[1].'iOS 28' | Should -Be 1 # The earlier row keeps its history rather than being invented as 0 - that release did # not exist yet, which is different from nobody being on it. $rows[0].'iOS 26' | Should -Be 1 $package = Open-ExcelPackage -Path $script:Book try { $chart = $package.Workbook.Worksheets['Dashboard'].Drawings | Where-Object Name -eq 'chartDeviceOsVersion' @($chart.Series | ForEach-Object { $_.Header }) | Should -Contain 'iOS 28' } finally { Close-ExcelPackage $package -NoSave } } It 'charts both device sheets on the dashboard' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecIntuneDevice -MockWith { [pscustomobject]@{ Id='a'; Os='Windows'; OsVersion='10.0.22631.3155'; ComplianceState='compliant' } [pscustomobject]@{ Id='b'; Os='iOS'; OsVersion='17.4.1'; ComplianceState='compliant' } } Export-MsecPostureReport -Path $Book -Measurement DevicePlatform,DeviceOsVersion -WarningAction SilentlyContinue | Out-Null } $package = Open-ExcelPackage -Path $script:Book try { $drawings = @($package.Workbook.Worksheets['Dashboard'].Drawings) @($drawings | ForEach-Object { $_.Name }) | Should -Contain 'chartDevicePlatform' @($drawings | ForEach-Object { $_.Name }) | Should -Contain 'chartDeviceOsVersion' # Series follow the columns actually discovered this run. $platform = $drawings | Where-Object Name -eq 'chartDevicePlatform' @($platform.Series | ForEach-Object { $_.Header } | Sort-Object) | Should -Be @('iOS', 'Windows') } finally { Close-ExcelPackage $package -NoSave } } It 'writes a Target column and plots it, only on the sheets named' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraMfaRegistrationStats -MockWith { [pscustomobject]@{ TotalUsers = 100; MfaCapable = 87; MfaCapablePercent = 87 } } Mock Get-MsecDefenderIncidentStats -MockWith { [pscustomobject]@{ TotalCreated = 12; High = 1; Medium = 4; Low = 7; Informational = 0 } } Export-MsecPostureReport -Path $Book -Measurement MfaCoverage, Incidents ` -Target @{ MfaCoverage = 95 } -WarningAction SilentlyContinue | Out-Null } # The named sheet carries the constant, which is what Excel draws as a flat line. (Import-Excel -Path $script:Book -WorksheetName 'MfaCoverage').Target | Should -Be 95 # The sheet NOT named is untouched - no column, so no extra series and no clutter. @((Import-Excel -Path $script:Book -WorksheetName 'Incidents')[0].PSObject.Properties.Name) | Should -Not -Contain 'Target' $package = Open-ExcelPackage -Path $script:Book try { $dashboard = $package.Workbook.Worksheets['Dashboard'] $mfa = $dashboard.Drawings | Where-Object Name -eq 'chartMfaCoverage' $incidents = $dashboard.Drawings | Where-Object Name -eq 'chartIncidents' # Target is LAST, so it takes the final theme colour and reads as an annotation. @($mfa.Series)[-1].Header | Should -Be 'Target' @($incidents.Series | ForEach-Object { $_.Header }) | Should -Not -Contain 'Target' } finally { Close-ExcelPackage $package -NoSave } } It 'records a raised target as a step rather than rewriting the earlier rows' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraMfaRegistrationStats -MockWith { [pscustomobject]@{ TotalUsers = 100; MfaCapable = 87; MfaCapablePercent = 87 } } Export-MsecPostureReport -Path $Book -Measurement MfaCoverage -Target @{ MfaCoverage = 90 } -WarningAction SilentlyContinue | Out-Null Export-MsecPostureReport -Path $Book -Measurement MfaCoverage -Target @{ MfaCoverage = 95 } -WarningAction SilentlyContinue | Out-Null } # Stored per row, so "we moved the bar" is visible in the chart. A single stored # constant would have silently restated the old months at the new target. @((Import-Excel -Path $script:Book -WorksheetName 'MfaCoverage').Target) | Should -Be @(90, 95) } It 'warns when -Target names a sheet that does not exist' { $warnings = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecEntraMfaRegistrationStats -MockWith { [pscustomobject]@{ TotalUsers = 100; MfaCapablePercent = 87 } } Export-MsecPostureReport -Path $Book -Measurement MfaCoverage ` -Target @{ MfaCoverge = 95 } ` -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } # A typo would otherwise be a target that silently never appears. ($warnings -join ' ') | Should -Match "'MfaCoverge'" ($warnings -join ' ') | Should -Match 'MfaCoverage' # names the real ones } It 'honours -WhatIf without creating the workbook' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecSecureScore -MockWith { [pscustomobject]@{ ScoreType = 'Overall'; Date = [datetime]'2026-08-27'; ScorePercent = 61 } } Mock Get-MsecDefenderScoreExposure -MockWith { } Mock Get-MsecDefenderScoreDeviceConfiguration -MockWith { } Export-MsecPostureReport -Path $Book -Measurement Scores -WhatIf -WarningAction SilentlyContinue } Test-Path $script:Book | Should -BeFalse } It 'records the look-back window alongside the counts it describes' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-MsecDefenderIncidentStats -MockWith { [pscustomobject]@{ TotalCreated = 12; High = 1; Medium = 4; Low = 7; Informational = 0; CurrentlyOpen = 3 } } Export-MsecPostureReport -Path $Book -Measurement Incidents -Days 7 -WarningAction SilentlyContinue | Out-Null } $incidents = @(Import-Excel -Path $script:Book -WorksheetName 'Incidents') $incidents[0].WindowDays | Should -Be 7 $incidents[0].TotalCreated | Should -Be 12 } It 'throws a clear error when not connected' { InModuleScope Msec { $script:MsecSession = $null { Export-MsecPostureReport -Path './nope.xlsx' } | Should -Throw '*Connect-Msec*' } } } |