tests/Collect.RawInventory.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules Pester <# Pester tests for src/collect/Get-ScoutRawInventory.ps1 (AB#5639/5642, Epic AB#5638). Search-AzGraph is mocked throughout -- no live Azure connection is made. #> BeforeAll { $root = Split-Path $PSScriptRoot -Parent function Import-Module { param([Parameter(ValueFromRemainingArguments)] $Rest) } . "$root/src/collect/Get-ScoutRawInventory.ps1" . "$root/src/collect/ConvertFrom-ScoutInventory.ps1" . "$root/src/collect/Invoke-Collect.ps1" function New-MockSubscriptionRow { param([string] $Id) [pscustomobject]@{ id = "/subscriptions/$Id"; name = "sub-$Id"; type = 'microsoft.resources/subscriptions'; subscriptionId = $Id; properties = [pscustomobject]@{ state = 'Enabled' } } } } Describe 'Get-ScoutRawInventory -- table coverage' { It 'always queries resources, networkresources and resourcecontainers' { $script:queries = @() function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) $script:queries += $Query return @() } Get-ScoutRawInventory | Out-Null ($script:queries -join "`n") | Should -Match '(?m)^resources\b' ($script:queries -join "`n") | Should -Match '(?m)^networkresources\b' ($script:queries -join "`n") | Should -Match '(?m)^resourcecontainers\b' } It 'does not query SupportResources/recoveryservicesresources/desktopvirtualizationresources/advisorresources/securityresources unless asked' { $script:queries = @() function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) $script:queries += $Query return @() } Get-ScoutRawInventory | Out-Null ($script:queries -join "`n") | Should -Not -Match '(?m)^SupportResources\b' ($script:queries -join "`n") | Should -Not -Match '(?m)^recoveryservicesresources\b' ($script:queries -join "`n") | Should -Not -Match '(?m)^desktopvirtualizationresources\b' ($script:queries -join "`n") | Should -Not -Match '(?m)^advisorresources\b' ($script:queries -join "`n") | Should -Not -Match '(?m)^securityresources\b' } It 'queries every extra table when every -Include switch is supplied' { $script:queries = @() function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) $script:queries += $Query return @() } Get-ScoutRawInventory -IncludeSupportResources -IncludeBackupResources -IncludeDesktopVirtualization -IncludeAdvisories -IncludeSecurityCenter | Out-Null ($script:queries -join "`n") | Should -Match '(?m)^SupportResources\b' ($script:queries -join "`n") | Should -Match '(?m)^recoveryservicesresources\b' ($script:queries -join "`n") | Should -Match '(?m)^desktopvirtualizationresources\b' ($script:queries -join "`n") | Should -Match '(?m)^advisorresources\b' ($script:queries -join "`n") | Should -Match '(?m)^securityresources\b' } It 'returns the Start-AZTIGraphExtraction-compatible shape' { function Search-AzGraph { param([Parameter(ValueFromRemainingArguments)] $Rest) return @() } $result = Get-ScoutRawInventory $result.PSObject.Properties.Name | Should -Contain 'Resources' $result.PSObject.Properties.Name | Should -Contain 'ResourceContainers' $result.PSObject.Properties.Name | Should -Contain 'Advisories' $result.PSObject.Properties.Name | Should -Contain 'Security' } } Describe 'Get-ScoutRawInventory -- SkipToken paging' { It 'follows SkipToken across pages and stops when it is absent' { # Search-AzGraph really returns a collection that both enumerates as the row objects # AND carries a `.SkipToken` note property on the collection itself (see the # documented `$response = Search-AzGraph ...; ... -SkipToken $response.SkipToken` # pattern) -- Add-Member on an array instance reproduces that shape for the mock. $script:callNumber = 0 function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -notmatch '^resources\b') { return @() } $script:callNumber++ if ($script:callNumber -eq 1) { $page = @([pscustomobject]@{ id = 'r1' }) Add-Member -InputObject $page -NotePropertyName SkipToken -NotePropertyValue 'page2' -Force return , $page } $page = @([pscustomobject]@{ id = 'r2' }) Add-Member -InputObject $page -NotePropertyName SkipToken -NotePropertyValue $null -Force return , $page } $result = Get-ScoutRawInventory $script:callNumber | Should -Be 2 @($result.Resources | Where-Object { $_.id -in @('r1', 'r2') }).Count | Should -Be 2 } } Describe 'Get-ScoutRawInventory -- subscription batching' { It 'batches an explicit subscription list into groups of 1000' { $ids = 1..2500 | ForEach-Object { "sub-$_" } $script:batchSizes = @() function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resources\b') { $script:batchSizes += @($Subscription).Count } return @() } Get-ScoutRawInventory -SubscriptionIds $ids | Out-Null $script:batchSizes.Count | Should -Be 3 $script:batchSizes | Should -Be @(1000, 1000, 500) } It 'derives the subscription list from resourcecontainers when none is supplied' { function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resourcecontainers\b') { return @((New-MockSubscriptionRow -Id 'aaa'), (New-MockSubscriptionRow -Id 'bbb')) } return @() } $result = Get-ScoutRawInventory $result.ResourceContainers.Count | Should -Be 2 } It 'actually scopes the later tables to the derived subscription list' { # Regression guard: `@($SubscriptionIds)` on an unbound [string[]] parameter is # `@($null)`, whose .Count is 1 -- so the "no explicit list supplied" branch never # fired on the DEFAULT path and every table after resourcecontainers silently ran as # one un-batched tenant-wide call. The assertion above (container count only) passed # either way, which is exactly why this one checks the -Subscription argument. $script:resourceCallSubscriptions = @() function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resourcecontainers\b') { return @((New-MockSubscriptionRow -Id 'aaa'), (New-MockSubscriptionRow -Id 'bbb')) } if ($Query -match '^resources\b') { $script:resourceCallSubscriptions += , @($Subscription) } return @() } Get-ScoutRawInventory -WarningAction SilentlyContinue | Out-Null $script:resourceCallSubscriptions.Count | Should -Be 1 @($script:resourceCallSubscriptions[0] | Sort-Object) | Should -Be @('aaa', 'bbb') } } Describe 'Get-ScoutRawInventory -- throttling and error resilience' { It 'retries a throttled (429) response with backoff before succeeding' { $script:attempts = 0 Mock Start-Sleep { } function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resources\b') { $script:attempts++ if ($script:attempts -lt 2) { throw 'Status: 429 (Too Many Requests)' } return @([pscustomobject]@{ id = 'r1' }) } return @() } $result = Get-ScoutRawInventory $script:attempts | Should -Be 2 @($result.Resources) | Where-Object { $_.id -eq 'r1' } | Should -Not -BeNullOrEmpty } It 'warns and skips (without throwing) a non-throttling failure' { function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resources\b') { throw 'AuthorizationFailed: access denied' } return @() } Get-ScoutRawInventory -WarningVariable w -WarningAction SilentlyContinue | Out-Null ($w -join "`n") | Should -Match 'AuthorizationFailed' } It 'warns with a diagnostic hint when literally nothing came back' { function Search-AzGraph { param([Parameter(ValueFromRemainingArguments)] $Rest) return @() } Get-ScoutRawInventory -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join "`n") | Should -Match 'zero resources' } } Describe 'Get-ScoutRawInventory -- Invoke-Collect -FromInventory interoperability' { # The whole point of AB#5639 is that src/collect owns ONE pass whose rows feed # everything. `Get-ScoutRawInventory`'s comment-based help claims its output is a # drop-in `-FromInventory` argument; nothing asserted that, so this pins the # contract end to end (both functions mocked off Azure, no live call). It 'produces a row set Invoke-Collect can shape without going back to Resource Graph' { function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resourcecontainers\b') { return @([pscustomobject]@{ id = '/subscriptions/aaa'; name = 'demo-sub'; type = 'microsoft.resources/subscriptions' subscriptionId = 'aaa'; location = $null; resourceGroup = $null properties = [pscustomobject]@{ state = 'Enabled' }; tags = $null }) } if ($Query -match '^resources\b') { return @([pscustomobject]@{ id = '/subscriptions/aaa/rg/x/vnet1'; name = 'vnet1'; type = 'microsoft.network/virtualnetworks' subscriptionId = 'aaa'; resourceGroup = 'rg-net'; location = 'eastus' kind = $null; sku = $null; plan = $null; identity = $null; zones = $null extendedLocation = $null; managedBy = $null properties = [pscustomobject]@{ enableDdosProtection = $false virtualNetworkPeerings = @([pscustomobject]@{ name = 'peer1' }) subnets = @() } }) } return @() } $raw = Get-ScoutRawInventory -WarningAction SilentlyContinue $collect = Invoke-Collect -FromInventory $raw -Categories 'Networking' -WarningAction SilentlyContinue @($collect.subscriptions).Count | Should -Be 1 $collect.subscriptions[0].id | Should -Be 'aaa' $collect.subscriptions[0].name | Should -Be 'demo-sub' # The scalar the rules actually filter on has to survive the round trip, not just # the row count -- peeringCount is computed in KQL on the live path and in # PowerShell on this one, so it is the honest end-to-end check. @($collect.networking.virtualNetworks).Count | Should -Be 1 $collect.networking.virtualNetworks[0].name | Should -Be 'vnet1' $collect.networking.virtualNetworks[0].peeringCount | Should -Be 1 $collect.networking.virtualNetworks[0].ddosEnabled | Should -BeFalse } } Describe 'Get-ScoutRawInventory -- management group scoping' { It 'passes -ManagementGroup on the tenant-wide call when no subscription list is known yet' { function Search-AzGraph { param([string] $Query, [int] $First, [string] $SkipToken, [string] $ManagementGroup, [string[]] $Subscription, [string] $ErrorAction) if ($Query -match '^resourcecontainers\b') { $script:sawMg = $ManagementGroup } return @() } Get-ScoutRawInventory -ManagementGroupId 'contoso-root-mg' | Out-Null $script:sawMg | Should -Be 'contoso-root-mg' } } |