tests/Export-MsecDefenderDeviceReport.Tests.ps1

#Requires -Module Pester
#
# Tests for Export-MsecDefenderDeviceReport.
#
# The shared writing machinery is exercised by the VM and disabled-account report tests. What
# is specific here is the vulnerability BAND, and the thing that must not be smoothed over:
#
# Get-MsecDefenderDevice reports $null when the vulnerability export could not be read at
# all, and 0 when it was read and the device had no findings. Those are different answers.
# Folding the null into 'None' would report an unmeasured estate as a clean one - the only
# reading here that is certainly wrong.

$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 'Export-MsecDefenderDeviceReport' -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-dd-$([guid]::NewGuid().Guid).xlsx"
    }
    AfterEach {
        if ($script:Book -and (Test-Path $script:Book)) { Remove-Item $script:Book -Force -ErrorAction SilentlyContinue }
    }

    It 'bands by vulnerability count, keeping unmeasured apart from zero' {
        $rows = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecDefenderDevice -MockWith {
                [pscustomobject]@{ DeviceName = 'clean';   VulnerabilityCount = 0;    CriticalCount = 0; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'few';     VulnerabilityCount = 7;    CriticalCount = 0; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'some';    VulnerabilityCount = 20;   CriticalCount = 1; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'many';    VulnerabilityCount = 40;   CriticalCount = 2; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'lots';    VulnerabilityCount = 80;   CriticalCount = 0; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'disaster'; VulnerabilityCount = 300; CriticalCount = 9; HealthStatus = 'Active' }
                # No licence / no permission: unknown, NOT clean.
                [pscustomobject]@{ DeviceName = 'unknown'; VulnerabilityCount = $null; CriticalCount = $null; HealthStatus = 'Active' }
            }
            Export-MsecDefenderDeviceReport -Path $Book -PassThru -WarningAction SilentlyContinue
        }

        $byName = @{}; foreach ($r in $rows) { $byName[$r.DeviceName] = $r }
        $byName['clean'].VulnerabilityBand    | Should -Be 'None'
        $byName['few'].VulnerabilityBand      | Should -Be '1 to 10'
        $byName['some'].VulnerabilityBand     | Should -Be '11 to 25'
        $byName['many'].VulnerabilityBand     | Should -Be '26 to 50'
        $byName['lots'].VulnerabilityBand     | Should -Be '51 to 100'
        $byName['disaster'].VulnerabilityBand | Should -Be 'Over 100'

        # The one that matters: unmeasured must never read as clean.
        $byName['unknown'].VulnerabilityBand | Should -Be 'Not assessed'
        $byName['unknown'].VulnerabilityBand | Should -Not -Be 'None'
    }

    It 'counts devices and critical-carrying devices separately per band' {
        $summary = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecDefenderDevice -MockWith {
                [pscustomobject]@{ DeviceName = 'a'; VulnerabilityCount = 300; CriticalCount = 5; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'b'; VulnerabilityCount = 250; CriticalCount = 1; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'c'; VulnerabilityCount = 200; CriticalCount = 0; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'd'; VulnerabilityCount = 0;   CriticalCount = 0; HealthStatus = 'Active' }
            }
            Export-MsecDefenderDeviceReport -Path $Book -WarningAction SilentlyContinue | Out-Null
            @(Import-Excel -Path $Book -WorksheetName 'Summary')
        }

        # Every band present, so two tenants' charts stay comparable.
        @($summary).Count | Should -Be 7

        $over = $summary | Where-Object VulnerabilityBand -eq 'Over 100'
        $over.Devices      | Should -Be 3
        # Two of those three carry criticals - the second series, and the work order.
        $over.WithCritical | Should -Be 2

        ($summary | Where-Object VulnerabilityBand -eq 'None').Devices      | Should -Be 1
        ($summary | Where-Object VulnerabilityBand -eq 'None').WithCritical | Should -Be 0
        # Empty bands are 0, not the phantom 1 that @($null).Count gives.
        ($summary | Where-Object VulnerabilityBand -eq '1 to 10').Devices   | Should -Be 0
        (($summary | Measure-Object -Property Devices -Sum).Sum)            | Should -Be 4
    }

    It 'charts both series against the tenant, on a sheet named after it' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso Ltd' }) }
            }
            Mock Get-MsecDefenderDevice -MockWith {
                [pscustomobject]@{ DeviceName = 'a'; VulnerabilityCount = 300; CriticalCount = 5; HealthStatus = 'Active' }
            }
            Export-MsecDefenderDeviceReport -Path $Book -WarningAction SilentlyContinue | Out-Null
        }

        $package = Open-ExcelPackage -Path $script:Book
        try {
            $names = @($package.Workbook.Worksheets | ForEach-Object { $_.Name })
            $names[0] | Should -Be 'Dashboard'          # in front of the data
            $names | Should -Contain 'Contoso Ltd'      # tenant display name, not the GUID
            $names | Should -Contain 'Summary'

            $chart = $package.Workbook.Worksheets['Dashboard'].Drawings |
                Where-Object Name -eq 'chartContoso Ltd'
            @($chart.Series).Count | Should -Be 2
            @($chart.Series | ForEach-Object { $_.Header }) | Should -Be @('Devices', 'WithCritical')
        }
        finally { Close-ExcelPackage $package -NoSave }
    }

    It 'sorts the unmeasured to the top, then the worst' {
        $order = InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecDefenderDevice -MockWith {
                [pscustomobject]@{ DeviceName = 'clean';    VulnerabilityCount = 0;    CriticalCount = 0; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'bad-few';  VulnerabilityCount = 300;  CriticalCount = 1; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'bad-many'; VulnerabilityCount = 300;  CriticalCount = 9; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'unknown';  VulnerabilityCount = $null; CriticalCount = $null; HealthStatus = 'Active' }
            }
            @((Export-MsecDefenderDeviceReport -Path $Book -PassThru -WarningAction SilentlyContinue).DeviceName)
        }

        # What nobody has assessed first, then what is on fire, then the rest.
        $order | Should -Be @('unknown', 'bad-many', 'bad-few', 'clean')
    }

    It 'warns about unmeasured devices rather than letting them pass as clean' {
        $warnings = @()
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            Mock Invoke-MsecGraphRequest -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ displayName = 'Contoso' }) }
            }
            Mock Get-MsecDefenderDevice -MockWith {
                [pscustomobject]@{ DeviceName = 'a'; VulnerabilityCount = $null; CriticalCount = $null; HealthStatus = 'Active' }
                [pscustomobject]@{ DeviceName = 'b'; VulnerabilityCount = $null; CriticalCount = $null; HealthStatus = 'Active' }
            }
            Export-MsecDefenderDeviceReport -Path $Book
        } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null

        ($warnings -join ' ') | Should -Match "2 of 2 device"
        ($warnings -join ' ') | Should -Match 'rather than zero'
        ($warnings -join ' ') | Should -Match 'Vulnerability\.Read\.All'
    }

    It 'throws a clear error when not connected' {
        InModuleScope Msec -Parameters @{ Book = $script:Book } {
            param($Book)
            $script:MsecSession = $null
            { Export-MsecDefenderDeviceReport -Path $Book } | Should -Throw '*Connect-Msec*'
        }
    }
}