tests/Export-MsecVMUpdateReport.Tests.ps1
|
#Requires -Module Pester # # Tests for Export-MsecVMUpdateReport and the worksheet-name sanitiser under it. # # This is an EVIDENCE document, not a trend: one row per VM, a fresh file per run, nothing # appended. So the things worth pinning down are about what the table shows a reviewer: # # * every VM appears, including the ones that could not be reached - evidence that quietly # omits the machines it failed on is not evidence # * the four assessments stay distinct, because they need different follow-up: Up to date, # Stale, No update history, No answer # * unparseable output is No answer, never a clean machine # * a sheet written twice is REPLACED, since a snapshot has nothing to accumulate # * two subscriptions truncating to the same 31 characters must not share a sheet $script:HasExcel = $null -ne (Get-Module -ListAvailable ImportExcel) BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'ConvertTo-MsecExcelSheetName' { It 'passes a legal name through untouched' { InModuleScope Msec { ConvertTo-MsecExcelSheetName -Name 'Contoso PROD' | Should -Be 'Contoso PROD' } } It 'strips the characters Excel refuses, and collapses the gap' { InModuleScope Msec { # Replaced with a space rather than removed, so 'A/B' does not become 'AB'. ConvertTo-MsecExcelSheetName -Name 'Contoso: PROD / EU' | Should -Be 'Contoso PROD EU' ConvertTo-MsecExcelSheetName -Name 'a[b]c*d?e\f' | Should -Be 'a b c d e f' } } It 'truncates to 31 characters, which is Excel s hard limit' { InModuleScope Msec { $long = 'Viedoc Production Subscription Northern Europe' $name = ConvertTo-MsecExcelSheetName -Name $long $name.Length | Should -BeLessOrEqual 31 $long | Should -BeLike "$name*" } } It 'disambiguates two names that truncate to the same thing' { InModuleScope Msec { $a = ConvertTo-MsecExcelSheetName -Name 'Contoso Production Platform EU' $b = ConvertTo-MsecExcelSheetName -Name 'Contoso Production Platform US' -Existing @($a) # Without this the second subscription would overwrite the first's evidence. $b | Should -Not -Be $a $b.Length | Should -BeLessOrEqual 31 } } It 'handles empty and the reserved name' { InModuleScope Msec { ConvertTo-MsecExcelSheetName -Name '' | Should -Be 'Subscription' ConvertTo-MsecExcelSheetName -Name ' ' | Should -Be 'Subscription' ConvertTo-MsecExcelSheetName -Name 'History' | Should -Not -Be 'History' } } } Describe 'Export-MsecVMUpdateReport' -Skip:(-not $script:HasExcel) { BeforeEach { $script:Book = Join-Path ([System.IO.Path]::GetTempPath()) "msec-vm-$([guid]::NewGuid().Guid).xlsx" } AfterEach { if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue } } It 'writes one row per VM, including the ones that gave no answer' { $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = 'Contoso PROD'; Id = 'sub-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'fresh'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true; Location = 'westeurope' } [pscustomobject]@{ Name = 'stale'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true; Location = 'westeurope' } [pscustomobject]@{ Name = 'silent'; ResourceGroupName = 'rg'; Os = 'Linux'; Running = $true; Location = 'westeurope' } [pscustomobject]@{ Name = 'nohist'; ResourceGroupName = 'rg'; Os = 'Linux'; Running = $true; Location = 'westeurope' } [pscustomobject]@{ Name = 'stopped'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $false; Location = 'westeurope' } } # One result per call: the command pipes VMs in, so Pester invokes the mock per VM. Mock Invoke-MsecAzureVMScript -MockWith { switch ($Name) { 'fresh' { [pscustomobject]@{ VmName = 'fresh'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":3,"SecurityPending":0,"RebootRequired":false}' } } 'stale' { [pscustomobject]@{ VmName = 'stale'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":90,"SecurityPending":7,"RebootRequired":true}' } } 'silent' { [pscustomobject]@{ VmName = 'silent'; Status = 'Failed'; Output = $null; Error = 'timed out' } } 'nohist' { [pscustomobject]@{ VmName = 'nohist'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":null,"SecurityPending":2}' } } } } Export-MsecVMUpdateReport -Path $Book -PassThru -WarningAction SilentlyContinue } # Five VMs in the subscription, five rows - the stopped one was never attempted and is # still on the sheet. Leaving it out would be evidence with a hole in it. @($rows).Count | Should -Be 5 $byName = @{}; foreach ($r in $rows) { $byName[$r.VmName] = $r } $byName['fresh'].Assessment | Should -Be 'Up to date' $byName['stale'].Assessment | Should -Be 'Stale' $byName['silent'].Assessment | Should -Be 'No answer' $byName['nohist'].Assessment | Should -Be 'No update history' $byName['stopped'].Assessment | Should -Be 'Not running' # The reason travels with the row, so a reviewer does not have to ask. $byName['silent'].Error | Should -Be 'timed out' $byName['stale'].SecurityPending | Should -Be 7 $byName['stale'].RebootRequired | Should -BeTrue # And it is all on the sheet, not just in the pipeline. @(Import-Excel -Path $script:Book -WorksheetName 'Contoso PROD').Count | Should -Be 5 } It 'orders the rows worst first, with the unassessed at the TOP' { $names = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = 'PROD'; Id = 'sub-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'a'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } [pscustomobject]@{ Name = 'b'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } [pscustomobject]@{ Name = 'c'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } Mock Invoke-MsecAzureVMScript -MockWith { switch ($Name) { 'a' { [pscustomobject]@{ VmName = 'a'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":10}' } } 'b' { [pscustomobject]@{ VmName = 'b'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":200}' } } 'c' { [pscustomobject]@{ VmName = 'c'; Status = 'Failed'; Output = $null } } } } @((Export-MsecVMUpdateReport -Path $Book -PassThru -WarningAction SilentlyContinue).VmName) } # Ordered by assessment severity: No answer, then Stale, then Up to date. On an # evidence table the machines nobody could assess are what a reviewer is hunting for, # so they belong at the top rather than buried under a long list of healthy ones. # # This was the other way round when the chart plotted days per VM and a null must not # lead. The chart now counts per assessment, so that constraint is gone. $names | Should -Be @('c', 'b', 'a') } It 'treats unparseable output as no answer rather than as a clean machine' { $row = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = 'PROD'; Id = 'sub-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'vm1'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } Mock Invoke-MsecAzureVMScript -MockWith { # Azure returns only the last 4096 bytes of stdout; a clipped JSON object # arrives as an unparseable string. Zeros would read as a patched machine. [pscustomobject]@{ VmName = 'vm1'; Status = 'Succeeded'; Output = '{"DaysSince' } } @(Export-MsecVMUpdateReport -Path $Book -PassThru -WarningAction SilentlyContinue)[0] } $row.Assessment | Should -Be 'No answer' $row.DaysSinceUpdate | Should -BeNullOrEmpty } It 'replaces the sheet when the same subscription is written again' { $counts = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = 'PROD'; Id = 'sub-1' } } } $script:VmCount = 3 Mock Search-MsecAzureResourceGraph -MockWith { 1..$script:VmCount | ForEach-Object { [pscustomobject]@{ Name = "vm$_"; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } } Mock Invoke-MsecAzureVMScript -MockWith { [pscustomobject]@{ VmName = $Name; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":5}' } } Export-MsecVMUpdateReport -Path $Book -WarningAction SilentlyContinue | Out-Null $first = @(Import-Excel -Path $Book -WorksheetName 'PROD').Count # A VM was decommissioned between collections. -Force because this test is about # REPLACE semantics, not about the overwrite prompt - which is exactly what would # otherwise fire here, since re-writing the same subscription is what it guards. $script:VmCount = 2 Export-MsecVMUpdateReport -Path $Book -Force -WarningAction SilentlyContinue | Out-Null $second = @(Import-Excel -Path $Book -WorksheetName 'PROD').Count @($first, $second) } # Replaced, not appended: a snapshot of two VMs must not still show the third. $counts | Should -Be @(3, 2) } It 'gives each subscription its own sheet and its own chart in one document' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) $script:Sub = 'PROD' Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = $script:Sub; Id = "sub-$($script:Sub)" } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'vm1'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } Mock Invoke-MsecAzureVMScript -MockWith { [pscustomobject]@{ VmName = 'vm1'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":5}' } } foreach ($name in 'PROD', 'TEST', 'DEV') { $script:Sub = $name Export-MsecVMUpdateReport -Path $Book -WarningAction SilentlyContinue | Out-Null } } $package = Open-ExcelPackage -Path $script:Book try { $names = @($package.Workbook.Worksheets | ForEach-Object { $_.Name }) foreach ($name in 'PROD', 'TEST', 'DEV') { $names | Should -Contain $name } $names[0] | Should -Be 'Dashboard' $dashboard = $package.Workbook.Worksheets['Dashboard'] @($dashboard.Drawings).Count | Should -Be 3 # Each chart counts VMs per assessment and reads only its own block of the shared # Summary sheet - three subscriptions, five rows each, so PROD is rows 2-6. $chart = $dashboard.Drawings | Where-Object Name -eq 'chartPROD' $chart.Series[0].Header | Should -Be 'VMs' $chart.Series[0].XSeries | Should -Match 'Summary!' $chart.Series[0].Series | Should -Match 'Summary!' # Blocks must not overlap, or one subscription's chart shows another's numbers. $ranges = @($dashboard.Drawings | ForEach-Object { $_.Series[0].Series }) @($ranges | Sort-Object -Unique).Count | Should -Be 3 # No two charts stacked. @(@($dashboard.Drawings | ForEach-Object { $_.From.Row }) | Sort-Object -Unique).Count | Should -Be 3 } finally { Close-ExcelPackage $package -NoSave } } It 'counts every assessment, including the ones with no VMs in them' { $summary = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = 'PROD'; Id = 'sub-1' } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'a'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } [pscustomobject]@{ Name = 'b'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } Mock Invoke-MsecAzureVMScript -MockWith { [pscustomobject]@{ VmName = $Name; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":5}' } } Export-MsecVMUpdateReport -Path $Book -WarningAction SilentlyContinue | Out-Null @(Import-Excel -Path $Book -WorksheetName 'Summary') } # All five states present, so the chart has a stable set of categories run to run. @($summary).Count | Should -Be 5 # PowerShell wraps $null into a ONE-element array, so @($counts[$absent]).Count is 1 - # which made a two-VM subscription sum to five, with a phantom machine under every # empty state. ($summary | Where-Object Assessment -eq 'Up to date').VMs | Should -Be 2 ($summary | Where-Object Assessment -eq 'Stale').VMs | Should -Be 0 ($summary | Where-Object Assessment -eq 'No answer').VMs | Should -Be 0 (($summary | Measure-Object -Property VMs -Sum).Sum) | Should -Be 2 } It 'timestamps every sheet, and dates the heading by span not by the last run' { $result = InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) $script:Sub = 'PROD' Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Name = $script:Sub; Id = "sub-$($script:Sub)" } } } Mock Search-MsecAzureResourceGraph -MockWith { [pscustomobject]@{ Name = 'vm1'; ResourceGroupName = 'rg'; Os = 'Windows'; Running = $true } } Mock Invoke-MsecAzureVMScript -MockWith { [pscustomobject]@{ VmName = 'vm1'; Status = 'Succeeded'; Output = '{"DaysSinceUpdate":5}' } } Export-MsecVMUpdateReport -Path $Book -WarningAction SilentlyContinue | Out-Null Start-Sleep -Seconds 1 # so the two runs carry different clocks $script:Sub = 'TEST' Export-MsecVMUpdateReport -Path $Book -WarningAction SilentlyContinue | Out-Null $package = Open-ExcelPackage -Path $Book $heading = $package.Workbook.Worksheets['Dashboard'].Cells[1, 1].Value Close-ExcelPackage $package -NoSave $heading } # Every VM row is stamped... (Import-Excel -Path $script:Book -WorksheetName 'PROD').CollectedUtc | Should -Not -BeNullOrEmpty # ...and so is the Summary, per subscription block rather than per file. Without this # the sheet driving the charts carried no date at all. $summary = @(Import-Excel -Path $script:Book -WorksheetName 'Summary') $prod = @($summary | Where-Object Subscription -eq 'PROD') $test = @($summary | Where-Object Subscription -eq 'TEST') $prod[0].CollectedUtc | Should -Not -BeNullOrEmpty # The two runs happened at different times, and the sheet says so rather than # restating PROD's scan with TEST's clock. $prod[0].CollectedUtc | Should -Not -Be $test[0].CollectedUtc # The heading reports the span, not just the last run. $result | Should -Match 'collected .* to .* UTC' } It 'throws a clear error with no Azure context' { InModuleScope Msec -Parameters @{ Book = $script:Book } { param($Book) Mock Get-AzContext -MockWith { $null } { Export-MsecVMUpdateReport -Path $Book } | Should -Throw '*Connect-AzAccount*' } } } |