tests/Assessment.Config.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules Pester <# Pester tests for the PowerShell-native assessment config load/save/fallback layer (src/assess/Import-ScoutConfig.ps1, src/assess/Export-ScoutConfig.ps1), plus the Get-RuleSet -Overrides threshold-override hook they feed. Tracks ADO Stories AB#373 (load), AB#374 (save), AB#375 (hardcoded fallback). Pure-logic tests -- no Azure connection, no live rule-file mutation (Get-RuleSet reads YAML fresh on every call, so overrides never leak between tests). #> BeforeAll { $root = Split-Path $PSScriptRoot -Parent . "$root/src/assess/Import-ScoutConfig.ps1" . "$root/src/assess/Export-ScoutConfig.ps1" . "$root/src/assess/Compare-Benchmark.ps1" . "$root/src/assess/engine/Get-RuleSet.ps1" $script:defaultBenchmarkPath = "$root/src/assess/benchmarks/alz-reference.json" } Describe 'Import-ScoutConfig' { It 'falls back to the built-in default benchmark when -ConfigPath is omitted' { $cfg = Import-ScoutConfig $cfg.Source | Should -Be 'Default' $cfg.ConfigPath | Should -BeNullOrEmpty $cfg.RulePatterns | Should -BeNullOrEmpty $cfg.RuleOverrides.Count | Should -Be 0 $cfg.Benchmark.managementGroups.expected | Should -Contain 'platform' } It 'loads a valid config file and surfaces benchmark, rulePatterns, and ruleOverrides' { $path = Join-Path $TestDrive 'valid-config.json' @{ benchmark = @{ managementGroups = @{ expected = @('platform', 'sandbox') } requiredPolicyAssignments = @('Deny-Public-IP') network = @{ requireHubSpoke = $true } } rulePatterns = @('waf.security', 'caf.governance') ruleOverrides = @{ 'WAF-SE-02' = 3 } } | ConvertTo-Json -Depth 10 | Out-File -LiteralPath $path -Encoding utf8 $cfg = Import-ScoutConfig -ConfigPath $path $cfg.Source | Should -Be 'File' $cfg.ConfigPath | Should -Be (Resolve-Path $path).ProviderPath $cfg.Benchmark.managementGroups.expected | Should -Be @('platform', 'sandbox') $cfg.RulePatterns | Should -Be @('waf.security', 'caf.governance') $cfg.RuleOverrides['WAF-SE-02'] | Should -Be 3 } It 'applies a loaded config end to end: rulePatterns select the rule set, ruleOverrides patch a threshold, and benchmark drives Compare-Benchmark' { $path = Join-Path $TestDrive 'applied-config.json' @{ benchmark = @{ managementGroups = @{ expected = @('only-this-mg') } requiredPolicyAssignments = @('Only-This-Policy') network = @{} } rulePatterns = @('waf.security') ruleOverrides = @{ 'WAF-SE-02' = 1 } # default is countLessThan 5 } | ConvertTo-Json -Depth 10 | Out-File -LiteralPath $path -Encoding utf8 $cfg = Import-ScoutConfig -ConfigPath $path # rulePatterns actually selects only the waf.security rule file. $ruleSet = Get-RuleSet -Patterns $cfg.RulePatterns -Overrides $cfg.RuleOverrides $ruleSet.Area | Should -Be 'Security' $ruleSet.Rules.Count | Should -BeGreaterThan 0 # ruleOverrides patched WAF-SE-02's assert.value from 5 to 1. $rule = $ruleSet.Rules | Where-Object { $_.id -eq 'WAF-SE-02' } $rule.assert.value | Should -Be 1 # an id absent from the override map is untouched. $untouched = $ruleSet.Rules | Where-Object { $_.id -eq 'WAF-SE-01' } $untouched.assert.value | Should -Be 0 # benchmark drives Compare-Benchmark exactly as the built-in default would. $collect = [pscustomobject]@{ governance = [pscustomobject]@{ managementGroups = @([pscustomobject]@{ name = 'only-this-mg' }) policyAssignments = @([pscustomobject]@{ properties = [pscustomobject]@{ displayName = 'Only-This-Policy' } }) } } $findings = Compare-Benchmark -Collect $collect -Benchmark $cfg.Benchmark ($findings | Where-Object Id -eq 'BENCH-MG-only-this-mg').Status | Should -Be 'Pass' ($findings | Where-Object Id -eq 'BENCH-POL-Only-This-Policy').Status | Should -Be 'Pass' } It 'falls back to the built-in default (with a warning, no throw) when -ConfigPath does not exist' { $missing = Join-Path $TestDrive 'does-not-exist.json' { Import-ScoutConfig -ConfigPath $missing -WarningAction SilentlyContinue } | Should -Not -Throw $warnings = @() $cfg = Import-ScoutConfig -ConfigPath $missing -WarningAction SilentlyContinue -WarningVariable +warnings $cfg.Source | Should -Be 'Default' $cfg.Benchmark.managementGroups.expected | Should -Contain 'platform' $warnings.Count | Should -BeGreaterThan 0 } It 'falls back to the built-in default (with a warning, no throw) when the config file is corrupt JSON' { $path = Join-Path $TestDrive 'corrupt.json' '{ this is not valid json,,, ' | Out-File -LiteralPath $path -Encoding utf8 { Import-ScoutConfig -ConfigPath $path -WarningAction SilentlyContinue } | Should -Not -Throw $warnings = @() $cfg = Import-ScoutConfig -ConfigPath $path -WarningAction SilentlyContinue -WarningVariable +warnings $cfg.Source | Should -Be 'Default' $cfg.Benchmark.requiredPolicyAssignments | Should -Contain 'Deny-Public-IP' $warnings.Count | Should -BeGreaterThan 0 } It 'falls back to the built-in default (with a warning, no throw) when the JSON parses but is not an object (e.g. a bare array)' { $path = Join-Path $TestDrive 'not-an-object.json' '[1, 2, 3]' | Out-File -LiteralPath $path -Encoding utf8 { Import-ScoutConfig -ConfigPath $path -WarningAction SilentlyContinue } | Should -Not -Throw $warnings = @() $cfg = Import-ScoutConfig -ConfigPath $path -WarningAction SilentlyContinue -WarningVariable +warnings $cfg.Source | Should -Be 'Default' $warnings.Count | Should -BeGreaterThan 0 } } Describe 'Export-ScoutConfig' { It 'writes the schema Import-ScoutConfig reads, round-tripping load -> save -> load to an equal config' { $srcPath = Join-Path $TestDrive 'roundtrip-source.json' @{ benchmark = @{ managementGroups = @{ expected = @('platform', 'connectivity') } requiredPolicyAssignments = @('Deny-Public-IP', 'Deploy-VM-Backup') network = @{ requireHubSpoke = $true; requireFirewallInHub = $false } } rulePatterns = @('waf.*', 'caf.security') ruleOverrides = @{ 'WAF-SE-02' = 3; 'WAF-SE-04' = @{ value = 1 } } } | ConvertTo-Json -Depth 10 | Out-File -LiteralPath $srcPath -Encoding utf8 $loaded1 = Import-ScoutConfig -ConfigPath $srcPath $exportPath = Join-Path $TestDrive 'roundtrip-export.json' $written = Export-ScoutConfig -Path $exportPath -Config $loaded1 $written | Should -Be (Resolve-Path $exportPath).ProviderPath $loaded2 = Import-ScoutConfig -ConfigPath $exportPath ($loaded2.Benchmark | ConvertTo-Json -Depth 10 -Compress) | Should -Be ($loaded1.Benchmark | ConvertTo-Json -Depth 10 -Compress) $loaded2.RulePatterns | Should -Be $loaded1.RulePatterns $loaded2.RuleOverrides.Count | Should -Be $loaded1.RuleOverrides.Count $loaded2.RuleOverrides['WAF-SE-02'] | Should -Be $loaded1.RuleOverrides['WAF-SE-02'] $loaded2.RuleOverrides['WAF-SE-04'].value | Should -Be $loaded1.RuleOverrides['WAF-SE-04'].value } It 'accepts the Parts parameter set (Benchmark/RulePatterns/RuleOverrides) built from scratch' { $exportPath = Join-Path $TestDrive 'parts-export.json' $bench = @{ managementGroups = @{ expected = @('platform') }; requiredPolicyAssignments = @(); network = @{} } Export-ScoutConfig -Path $exportPath -Benchmark $bench -RulePatterns @('caf.security') -RuleOverrides @{ 'X-1' = 2 } | Out-Null $reloaded = Import-ScoutConfig -ConfigPath $exportPath $reloaded.RulePatterns | Should -Be @('caf.security') $reloaded.RuleOverrides['X-1'] | Should -Be 2 $reloaded.Benchmark.managementGroups.expected | Should -Be @('platform') } It 'does not overwrite an existing file without -Force' { $path = Join-Path $TestDrive 'no-clobber.json' Export-ScoutConfig -Path $path -Benchmark @{ managementGroups = @{ expected = @('a') } } -RulePatterns @() -RuleOverrides @{} | Out-Null { Export-ScoutConfig -Path $path -Benchmark @{ managementGroups = @{ expected = @('b') } } -RulePatterns @() -RuleOverrides @{} -WarningAction SilentlyContinue } | Should -Not -Throw $result = Export-ScoutConfig -Path $path -Benchmark @{ managementGroups = @{ expected = @('b') } } -RulePatterns @() -RuleOverrides @{} -WarningAction SilentlyContinue $result | Should -BeNullOrEmpty (Import-ScoutConfig -ConfigPath $path).Benchmark.managementGroups.expected | Should -Be @('a') } It 'overwrites an existing file with -Force' { $path = Join-Path $TestDrive 'clobber.json' Export-ScoutConfig -Path $path -Benchmark @{ managementGroups = @{ expected = @('a') } } -RulePatterns @() -RuleOverrides @{} | Out-Null Export-ScoutConfig -Path $path -Benchmark @{ managementGroups = @{ expected = @('b') } } -RulePatterns @() -RuleOverrides @{} -Force | Out-Null (Import-ScoutConfig -ConfigPath $path).Benchmark.managementGroups.expected | Should -Be @('b') } } Describe 'Get-RuleSet -Overrides' { It 'is a no-op when -Overrides is $null or empty' { $withNull = Get-RuleSet -Patterns @('waf.security') -Overrides $null $withEmpty = Get-RuleSet -Patterns @('waf.security') -Overrides @{} $plain = Get-RuleSet -Patterns @('waf.security') ($withNull.Rules | Where-Object id -eq 'WAF-SE-02').assert.value | Should -Be 5 ($withEmpty.Rules | Where-Object id -eq 'WAF-SE-02').assert.value | Should -Be 5 ($plain.Rules | Where-Object id -eq 'WAF-SE-02').assert.value | Should -Be 5 } It 'supports overriding both assert.value and assert.type via an object override' { $ruleSet = Get-RuleSet -Patterns @('waf.security') -Overrides @{ 'WAF-SE-02' = @{ value = 2; type = 'countLessThan' } } $rule = $ruleSet.Rules | Where-Object id -eq 'WAF-SE-02' $rule.assert.value | Should -Be 2 $rule.assert.type | Should -Be 'countLessThan' } It 'leaves manual rules (no assert) untouched even if named in -Overrides' { $ruleSet = Get-RuleSet -Patterns @('waf.security') -Overrides @{ 'WAF-SE-05' = 99 } $rule = $ruleSet.Rules | Where-Object id -eq 'WAF-SE-05' $rule.assert.type | Should -Be 'manual' } } Describe 'Get-RuleSet -- malformed rule file crash class (StrictMode sweep)' { # ConvertFrom-Yaml returns a plain Hashtable, not a PSCustomObject -- dotting # into a top-level key it doesn't have (rules/area/framework/weight), or an # empty file (which parses to $null outright), throws PropertyNotFoundException # under Set-StrictMode -Version Latest rather than returning $null. Get-RuleSet # has no -RuleDir override, so these tests drop a uniquely-named, pattern- # isolated throwaway file into the real src/assess/rules folder and always # remove it again in -finally, even on assertion failure. BeforeAll { $script:MalformedRuleDir = "$root/src/assess/rules" } It 'does not throw and skips an empty (blank) rule YAML file, with a warning' { $tempFile = Join-Path $script:MalformedRuleDir 'zzz-test-empty.yaml' try { '' | Out-File -LiteralPath $tempFile -Encoding utf8 # Get-RuleSet has no [CmdletBinding()], so -WarningVariable is not # bound as a real common parameter on it (silently a no-op) -- capture # stream 3 (the warning stream) directly instead, which works # regardless of CmdletBinding. Also called directly (not wrapped in # `{ } | Should -Not -Throw`): that wrapper invokes the scriptblock in # a child scope, so a `$result =` assignment inside it would never # leak back out; a throw here still fails the It block on its own. $result = Get-RuleSet -Patterns @('zzz-test-empty') 3>$null @($result).Count | Should -Be 0 $warnings = @(Get-RuleSet -Patterns @('zzz-test-empty') 3>&1 | Where-Object { $_ -is [System.Management.Automation.WarningRecord] }) $warnings.Count | Should -BeGreaterThan 0 } finally { if (Test-Path $tempFile) { Remove-Item -LiteralPath $tempFile -Force } } } It 'does not throw on a rule file missing the top-level rules/area/framework/weight keys' { $tempFile = Join-Path $script:MalformedRuleDir 'zzz-test-nokeys.yaml' try { 'someOtherKey: true' | Out-File -LiteralPath $tempFile -Encoding utf8 $result = Get-RuleSet -Patterns @('zzz-test-nokeys') $result.Area | Should -BeNullOrEmpty $result.Framework | Should -BeNullOrEmpty $result.Weight | Should -Be 1.0 @($result.Rules).Count | Should -Be 0 } finally { if (Test-Path $tempFile) { Remove-Item -LiteralPath $tempFile -Force } } } } |