Tests/Get-MsecAzureSecureScore.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecAzureSecureScore. The function hits Defender for Cloud's ARM # endpoints (management.azure.com/.../Microsoft.Security/secureScores) using the # caller's Az.Accounts identity - so the mocks are at the Az cmdlet layer # (Get-AzContext, Get-AzSubscription, Get-AzAccessToken) plus Invoke-RestMethod # for the ARM calls themselves. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop # Get-MsecAzureSecureScore enumerates subscriptions through Get-MsecSubscriptionList, which # refreshes the completion cache. With Az mocked that would leave a folder of fake data in # the developer's real cache directory. $script:PrevCacheEnv = $env:MSEC_CACHE_DIR $env:MSEC_CACHE_DIR = Join-Path ([System.IO.Path]::GetTempPath()) "msec-test-cache-$([guid]::NewGuid())" New-Item -ItemType Directory -Path $env:MSEC_CACHE_DIR -Force | Out-Null $script:CacheDir = $env:MSEC_CACHE_DIR } AfterAll { if ($script:CacheDir -and (Test-Path -LiteralPath $script:CacheDir)) { Remove-Item -LiteralPath $script:CacheDir -Recurse -Force } $env:MSEC_CACHE_DIR = $script:PrevCacheEnv Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecAzureSecureScore' { It 'returns one Overall row per subscription with correct percent (current / max * 100)' { $rows = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-A' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @( [pscustomobject]@{ Id = 'sub-A'; Name = 'we-dev-sub' } [pscustomobject]@{ Id = 'sub-B'; Name = 'we-prod-sub' } ) } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'mock-arm-token'; ExpiresOn = (Get-Date).AddHours(1) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscriptions/sub-A/.*/secureScores' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'ascScore' properties = [pscustomobject]@{ displayName = 'ASC score' score = [pscustomobject]@{ max = 58.0; current = 32.0; percentage = 0.5517 } weight = 100 } } ) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscriptions/sub-B/.*/secureScores' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'ascScore' properties = [pscustomobject]@{ displayName = 'ASC score' score = [pscustomobject]@{ max = 50.0; current = 47.0; percentage = 0.94 } weight = 100 } } ) } } Get-MsecAzureSecureScore } $rows.Count | Should -Be 2 $a = $rows | Where-Object SubscriptionId -eq 'sub-A' $a.SubscriptionName | Should -Be 'we-dev-sub' $a.ScoreType | Should -Be 'Overall' $a.ScorePercent | Should -Be 55.17 # 32 / 58 * 100, rounded to 2dp $b = $rows | Where-Object SubscriptionId -eq 'sub-B' $b.SubscriptionName | Should -Be 'we-prod-sub' $b.ScorePercent | Should -Be 94.0 } It '-IncludeControls emits one row per (subscription, control) in addition to Overall' { $rows = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-A' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-A'; Name = 'we-dev-sub' }) } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'mock-arm-token'; ExpiresOn = (Get-Date).AddHours(1) } } # Overall Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'secureScores\?api-version' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'ascScore' properties = [pscustomobject]@{ displayName = 'ASC score' score = [pscustomobject]@{ max = 100.0; current = 72.0 } } } ) } } # Controls Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'secureScoreControls\?api-version' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ properties = [pscustomobject]@{ displayName = 'Enable MFA' score = [pscustomobject]@{ max = 10.0; current = 10.0 } } } [pscustomobject]@{ properties = [pscustomobject]@{ displayName = 'Apply system updates' score = [pscustomobject]@{ max = 10.0; current = 4.0 } } } [pscustomobject]@{ # Edge case: control with no applicable assessments -> max=0 properties = [pscustomobject]@{ displayName = 'Custom recommendation (n/a)' score = [pscustomobject]@{ max = 0.0; current = 0.0 } } } ) } } Get-MsecAzureSecureScore -IncludeControls } # 1 Overall + 3 controls = 4 rows $rows.Count | Should -Be 4 ($rows | Where-Object ScoreType -eq 'Overall').ScorePercent | Should -Be 72.0 ($rows | Where-Object ScoreType -eq 'Enable MFA').ScorePercent | Should -Be 100.0 ($rows | Where-Object ScoreType -eq 'Apply system updates').ScorePercent | Should -Be 40.0 # max=0 control: ScorePercent must be $null (don't fake a 0 - it means "no data") ($rows | Where-Object ScoreType -eq 'Custom recommendation (n/a)').ScorePercent | Should -BeNullOrEmpty } It 'warns and skips a single failing subscription rather than aborting the whole batch' { $output = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-A' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @( [pscustomobject]@{ Id = 'sub-A'; Name = 'reader-ok' } # works [pscustomobject]@{ Id = 'sub-B'; Name = 'no-reader' } # 403 [pscustomobject]@{ Id = 'sub-C'; Name = 'reader-ok2' } # works ) } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'mock'; ExpiresOn = (Get-Date).AddHours(1) } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscriptions/sub-B/' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscriptions/sub-[AC]/' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ name = 'ascScore' properties = [pscustomobject]@{ displayName = 'ASC score' score = [pscustomobject]@{ max = 50.0; current = 25.0 } } } ) } } # Capture warnings + rows in a single call - calling twice would run # the mocked Invoke-RestMethod twice and inflate the row count. $warnings = @() $rows = Get-MsecAzureSecureScore -WarningVariable warnings -WarningAction SilentlyContinue [pscustomobject]@{ Rows = $rows; Warnings = $warnings } } # 2 successful subs return rows, the failing one is skipped (no row, just warning). $output.Rows.Count | Should -Be 2 $output.Rows.SubscriptionId | Should -Not -Contain 'sub-B' # Matched across ALL warnings rather than at [0]. Az.Accounts emits its own # version-upgrade advisory into the same WarningVariable, and on a runner with an # older Az than the Gallery's latest it arrives FIRST - so indexing [0] asserted # against Microsoft's upgrade notice instead of the command's output. ($output.Warnings -join "`n") | Should -Match 'no-reader' } } |