tests/Collect.Resilience.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules Pester #Requires -Modules Az.ResourceGraph <# Pester tests for src/collect/Invoke-Collect.ps1's collector resilience additions: AB#397 - per-subscription try/catch/continue fallback when a tenant/MG-wide batch query throws: a single bad subscription is skipped (warned about by name) while every other subscription's rows for that same query still come back. AB#398 - an AuthorizationFailed error on an -ManagementGroupId-scoped query gets an actionable Reader-role hint naming the management group. AB#399 - the known false "resource provider not registered" condition is swallowed at Verbose -- no per-subscription retry, no warning noise. AB#400 - firewall policy rule-collection group parsing continues past one malformed group instead of losing the whole query's results. AB#401 - an empty-data guard warns when literally nothing came back from any collected query. Search-AzGraph is mocked throughout -- no live Azure connection is made. #> BeforeAll { $root = Split-Path $PSScriptRoot -Parent Import-Module Az.ResourceGraph -ErrorAction Stop . "$root/src/collect/Invoke-Collect.ps1" function Get-MockSubscriptions { @( [pscustomobject]@{ id = 'sub-1'; name = 'sub-1'; state = 'Enabled'; tags = $null } [pscustomobject]@{ id = 'sub-2'; name = 'sub-2'; state = 'Enabled'; tags = $null } [pscustomobject]@{ id = 'sub-3'; name = 'sub-3'; state = 'Enabled'; tags = $null } ) } } Describe 'Invoke-Collect -- AB#397 per-subscription fallback' { BeforeEach { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } if ($Query -match 'peeringCount') { # No -Subscription bound at all -> this is the tenant/MG-wide batch # call, which fails (simulating a transient/DNS/token error). if (-not $Subscription) { throw 'A transient DNS resolution error occurred while querying virtualNetworks.' } # One specific subscription keeps failing even on the per-sub retry. if ($Subscription -contains 'sub-2') { throw "A transient error occurred for subscription 'sub-2'." } return @([pscustomobject]@{ name = "vnet-$($Subscription[0])"; resourceGroup = 'rg1'; subscriptionId = $Subscription[0]; peeringCount = 0; ddosEnabled = $false }) } return @() } } It 'retries per-subscription after a tenant-wide batch failure, and does not throw' { { Invoke-Collect -Categories @('Networking') } | Should -Not -Throw } It 'collects rows from the subscriptions that succeed on retry, skipping only the one that keeps failing' { $result = Invoke-Collect -Categories @('Networking') $names = @($result.networking.virtualNetworks.name) $names | Should -Contain 'vnet-sub-1' $names | Should -Contain 'vnet-sub-3' $names | Should -Not -Contain 'vnet-sub-2' @($result.networking.virtualNetworks).Count | Should -Be 2 } It 'warns once for the subscription that still fails on retry, naming it and AB#397' { Invoke-Collect -Categories @('Networking') -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Match "failed for subscription 'sub-2'" ($warnings -join "`n") | Should -Match 'AB#397' } } Describe 'Invoke-Collect -- AB#398 AuthorizationFailed management-group hint' { It 'surfaces an actionable Reader-role hint naming the management group' { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } if ($Query -match 'peeringCount') { throw 'AuthorizationFailed: The client does not have authorization to perform action on the requested management group scope.' } return @() } Invoke-Collect -Categories @('Networking') -ManagementGroupId 'contoso-root-mg' -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Match 'AuthorizationFailed' ($warnings -join "`n") | Should -Match "Reader role at the 'contoso-root-mg' management group scope" } It 'still falls back to a per-subscription retry after the MG hint (a subscription-level Reader may still work)' { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } if ($Query -match 'peeringCount') { if ($ManagementGroup) { throw 'AuthorizationFailed: missing Reader at management group scope.' } return @([pscustomobject]@{ name = "vnet-$($Subscription[0])"; resourceGroup = 'rg1'; subscriptionId = $Subscription[0]; peeringCount = 0; ddosEnabled = $false }) } return @() } $result = Invoke-Collect -Categories @('Networking') -ManagementGroupId 'contoso-root-mg' @($result.networking.virtualNetworks).Count | Should -Be 3 } } Describe 'Invoke-Collect -- AB#399 known-noise error is swallowed' { BeforeEach { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } if ($Query -match 'microsoft\.keyvault/vaults') { throw "MissingSubscriptionRegistration: The subscription is not registered to use namespace 'Microsoft.KeyVault'." } if ($Query -match 'microsoft\.network/networksecuritygroups') { return @([pscustomobject]@{ nsg = 'nsg1'; resourceGroup = 'rg1'; rule = 'AllowAll'; port = '*' }) } return @() } } It 'does not warn for the known-noise error and returns an empty (not thrown) result for that query' { $result = Invoke-Collect -Categories @('Security') -WarningVariable warnings -WarningAction SilentlyContinue @($result.domains.security.keyVaults).Count | Should -Be 0 ($warnings -join "`n") | Should -Not -Match 'keyVaults' } It 'logs the known-noise condition at Verbose' { $verboseRecords = Invoke-Collect -Categories @('Security') -Verbose 4>&1 | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] } ($verboseRecords | ForEach-Object { $_.Message }) -join "`n" | Should -Match 'AB#399' } } Describe 'Invoke-Collect -- AB#400 firewall policy rule-group parse errors continue per-group' { BeforeEach { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } if ($Query -match 'firewallpolicies/rulecollectiongroups') { return @( [pscustomobject]@{ name = 'good-group'; resourceGroup = 'rg1'; subscriptionId = 'sub-1'; policyName = 'fwpolicy1' priority = 100 ruleCollections = @( [pscustomobject]@{ rules = @([pscustomobject]@{ name = 'r1' }, [pscustomobject]@{ name = 'r2' }) } ) } [pscustomobject]@{ name = 'bad-group'; resourceGroup = 'rg1'; subscriptionId = 'sub-1'; policyName = 'fwpolicy1' priority = 200 ruleCollections = 'not-a-valid-shape' } ) } return @() } } It 'parses the well-formed group normally and records a placeholder with parseError for the malformed one' { $result = Invoke-Collect -Categories @('Networking') $groups = @($result.networking.firewallPolicyRuleGroups) $groups.Count | Should -Be 2 $good = $groups | Where-Object { $_.name -eq 'good-group' } $good.parseError | Should -BeNullOrEmpty $good.ruleCount | Should -Be 2 $good.ruleCollectionCount | Should -Be 1 $bad = $groups | Where-Object { $_.name -eq 'bad-group' } $bad.parseError | Should -Not -BeNullOrEmpty $bad.ruleCount | Should -Be 0 $bad.ruleCollectionCount | Should -Be 0 } It 'warns about the malformed group by name, referencing AB#400, without losing the well-formed group' { $result = Invoke-Collect -Categories @('Networking') -WarningVariable warnings -WarningAction SilentlyContinue ($warnings -join "`n") | Should -Match 'bad-group' ($warnings -join "`n") | Should -Match 'AB#400' @($result.networking.firewallPolicyRuleGroups | Where-Object { $_.name -eq 'good-group' }) | Should -Not -BeNullOrEmpty } } Describe 'Invoke-Collect -- AB#401 empty-data guard' { It 'warns with a diagnostic hint when literally no resources are returned for any query' { Mock Search-AzGraph { return @() } Invoke-Collect -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Match 'no resources were returned' ($warnings -join "`n") | Should -Match 'Reader role' } It 'names the management group in the hint when one was supplied' { Mock Search-AzGraph { return @() } Invoke-Collect -ManagementGroupId 'contoso-root-mg' -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Match "management group 'contoso-root-mg'" } It 'does not warn when at least one query returns data' { Mock Search-AzGraph { if ($Query -match 'microsoft\.resources/subscriptions"') { return Get-MockSubscriptions } return @() } Invoke-Collect -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Not -Match 'no resources were returned' } } |