tests/Report.InventoryDrift.Tests.ps1
|
#Requires -Version 7.0 #Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# Pester tests for src/report/Get-ScoutInventoryDrift.ps1 — cross-run RESOURCE/inventory drift tracking (ADO Story AB#326). Builds small synthetic collect.json-shaped objects (the canonical shape Invoke-Collect.ps1 documents) and points -HistoryPath at a throwaway folder under $env:TEMP so runs never touch a real assessment's .scout-history. No Azure connection required. #> BeforeAll { $script:Root = Split-Path $PSScriptRoot -Parent . "$script:Root/src/report/Get-ScoutInventoryDrift.ps1" function New-InventoryTestCollect { # A minimal but multi-shape collect object: a top-level array # (subscriptions), a nested-object-with-array (networking. # virtualNetworks, compute.virtualMachines), and a resource whose # only identity-ish field is 'nsg' + 'resourceGroup' (nsgPublicInbound # never carries a 'name' field in the real collector) — exercises the # non-'name' branch of the identity-field priority list. param( [array] $VirtualNetworks = @(), [array] $VirtualMachines = @(), [array] $NsgRules = @(), [array] $Subscriptions = @() ) [pscustomobject]@{ subscriptions = $Subscriptions networking = [pscustomobject]@{ virtualNetworks = $VirtualNetworks nsgPublicInbound = $NsgRules } compute = [pscustomobject]@{ virtualMachines = $VirtualMachines } _meta = [pscustomobject]@{ generatedOn = '2026-01-01T00:00:00Z'; scope = 'All' } } } function New-Vnet { param($Name, $Rg = 'rg1', $Sub = 'sub1', $PeeringCount = 0) [pscustomobject]@{ name = $Name; resourceGroup = $Rg; subscriptionId = $Sub; peeringCount = $PeeringCount; ddosEnabled = $false } } function New-Vm { param($Name, $Rg = 'rg1', $Sub = 'sub1', $Size = 'Standard_D2s_v3') [pscustomobject]@{ name = $Name; resourceGroup = $Rg; subscriptionId = $Sub; size = $Size; zoneRedundant = $false } } function New-NsgRule { param($Nsg, $Rg = 'rg1', $Rule = 'AllowAny', $Port = '*') [pscustomobject]@{ nsg = $Nsg; resourceGroup = $Rg; rule = $Rule; port = $Port } } function Get-DriftEntry($List, $Id) { return $List | Where-Object Id -eq $Id } } Describe 'Get-ScoutInventoryDrift — first-ever run (baseline)' { BeforeAll { $script:HistoryPath = Join-Path $env:TEMP "ScoutInvDriftTest_$([guid]::NewGuid().ToString('N'))" $collect1 = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'vnetA'), (New-Vnet 'vnetB')) -VirtualMachines @((New-Vm 'vm1')) $script:Drift1 = Get-ScoutInventoryDrift -Collect $collect1 -HistoryPath $script:HistoryPath -RunId 'run1' } AfterAll { if (Test-Path $script:HistoryPath) { Remove-Item $script:HistoryPath -Recurse -Force -ErrorAction SilentlyContinue } } It 'returns an explicit baseline result, not an error, on the first run' { $script:Drift1.IsBaseline | Should -BeTrue $script:Drift1.PreviousRunId | Should -BeNullOrEmpty } It 'reports NO drift on the baseline run — everything is baseline, not Added' { $script:Drift1.Added.Count | Should -Be 0 $script:Drift1.Removed.Count | Should -Be 0 $script:Drift1.Changed.Count | Should -Be 0 $script:Drift1.Summary.Added | Should -Be 0 $script:Drift1.Summary.Removed | Should -Be 0 $script:Drift1.Summary.Changed | Should -Be 0 $script:Drift1.Summary.Unchanged | Should -Be 0 } It 'still counts the current inventory in the summary' { $script:Drift1.Summary.TotalCurrent | Should -Be 3 $script:Drift1.Summary.TotalPrevious | Should -Be 0 } It 'creates the inventory-history file and folder' { (Join-Path $script:HistoryPath 'inventory-history.json') | Should -Exist } } Describe 'Get-ScoutInventoryDrift — second run: added / removed / changed / unchanged' { BeforeAll { $script:HistoryPath = Join-Path $env:TEMP "ScoutInvDriftTest_$([guid]::NewGuid().ToString('N'))" # run1: vnetA (peeringCount 1), vnetB, vm1 unchanged-to-be, one nsg rule $collect1 = New-InventoryTestCollect ` -VirtualNetworks @((New-Vnet 'vnetA' -PeeringCount 1), (New-Vnet 'vnetB')) ` -VirtualMachines @((New-Vm 'vm1')) ` -NsgRules @((New-NsgRule -Nsg 'nsg1')) Get-ScoutInventoryDrift -Collect $collect1 -HistoryPath $script:HistoryPath -RunId 'run1' | Out-Null # run2: vnetA peeringCount changes 1->2 (Changed), vnetB removed, # vm1 unchanged, vm2 is new (Added), nsg rule unchanged. $collect2 = New-InventoryTestCollect ` -VirtualNetworks @((New-Vnet 'vnetA' -PeeringCount 2)) ` -VirtualMachines @((New-Vm 'vm1'), (New-Vm 'vm2')) ` -NsgRules @((New-NsgRule -Nsg 'nsg1')) $script:Drift2 = Get-ScoutInventoryDrift -Collect $collect2 -HistoryPath $script:HistoryPath -RunId 'run2' } AfterAll { if (Test-Path $script:HistoryPath) { Remove-Item $script:HistoryPath -Recurse -Force -ErrorAction SilentlyContinue } } It 'is not a baseline and points PreviousRunId at the prior run' { $script:Drift2.IsBaseline | Should -BeFalse $script:Drift2.PreviousRunId | Should -Be 'run1' } It 'detects the new virtual machine as Added' { $script:Drift2.Summary.Added | Should -Be 1 $addedIds = $script:Drift2.Added.Id ($addedIds -match 'vm2') | Should -Not -BeNullOrEmpty ($script:Drift2.Added | Where-Object { $_.Id -match 'vm2' }).Path | Should -Be 'compute.virtualMachines' } It 'detects the dropped virtual network as Removed' { $script:Drift2.Summary.Removed | Should -Be 1 $removedIds = $script:Drift2.Removed.Id ($removedIds -match 'vnetB') | Should -Not -BeNullOrEmpty ($script:Drift2.Removed | Where-Object { $_.Id -match 'vnetB' }).Fields.name | Should -Be 'vnetB' } It 'detects the peeringCount property change on vnetA as Changed, with the changed field named' { $script:Drift2.Summary.Changed | Should -Be 1 $changedEntry = $script:Drift2.Changed | Where-Object { $_.Id -match 'vnetA' } $changedEntry | Should -Not -BeNullOrEmpty $peeringChange = $changedEntry.Changes | Where-Object Field -eq 'peeringCount' $peeringChange | Should -Not -BeNullOrEmpty $peeringChange.PreviousValue | Should -Be 1 $peeringChange.CurrentValue | Should -Be 2 } It 'does not report an unrelated field as changed' { $changedEntry = $script:Drift2.Changed | Where-Object { $_.Id -match 'vnetA' } ($changedEntry.Changes | Where-Object Field -eq 'ddosEnabled') | Should -BeNullOrEmpty } It 'rolls unaffected resources (vm1, nsg1) up as Unchanged, not into Added/Removed/Changed' { $script:Drift2.Summary.Unchanged | Should -Be 2 (@($script:Drift2.Added.Id) -match 'vm1\|') | Should -BeNullOrEmpty (@($script:Drift2.Changed.Id) -match 'vm1\|') | Should -BeNullOrEmpty } It 'a rerun of the same RunId replaces its own history record instead of duplicating it' { Get-ScoutInventoryDrift -Collect $collect2 -HistoryPath $script:HistoryPath -RunId 'run2' | Out-Null $historyRaw = Get-Content (Join-Path $script:HistoryPath 'inventory-history.json') -Raw | ConvertFrom-Json -Depth 100 @($historyRaw | Where-Object RunId -eq 'run2').Count | Should -Be 1 } } Describe 'Get-ScoutInventoryDrift — stable id matching' { BeforeAll { $script:HistoryPath = Join-Path $env:TEMP "ScoutInvDriftTest_$([guid]::NewGuid().ToString('N'))" } AfterAll { if (Test-Path $script:HistoryPath) { Remove-Item $script:HistoryPath -Recurse -Force -ErrorAction SilentlyContinue } } It 'matches the same resource across runs even when array order changes' { $collect1 = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'vnetA'), (New-Vnet 'vnetB'), (New-Vnet 'vnetC')) Get-ScoutInventoryDrift -Collect $collect1 -HistoryPath $script:HistoryPath -RunId 'order1' | Out-Null # Same three vnets, reversed order, no field changes — should be entirely Unchanged. $collect2 = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'vnetC'), (New-Vnet 'vnetB'), (New-Vnet 'vnetA')) $drift = Get-ScoutInventoryDrift -Collect $collect2 -HistoryPath $script:HistoryPath -RunId 'order2' $drift.Summary.Added | Should -Be 0 $drift.Summary.Removed | Should -Be 0 $drift.Summary.Changed | Should -Be 0 $drift.Summary.Unchanged | Should -Be 3 } It 'does not confuse two resources with the same name in different resource groups' { $collect1 = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'shared-name' -Rg 'rgA'), (New-Vnet 'shared-name' -Rg 'rgB')) $baseline = Get-ScoutInventoryDrift -Collect $collect1 -HistoryPath $script:HistoryPath -RunId 'sameNameBaseline' $baseline.Summary.TotalCurrent | Should -Be 2 # Only the rgB copy changes; the rgA copy must be recognized as Unchanged, not confused with rgB. $collect2 = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'shared-name' -Rg 'rgA'), (New-Vnet 'shared-name' -Rg 'rgB' -PeeringCount 5)) $drift = Get-ScoutInventoryDrift -Collect $collect2 -HistoryPath $script:HistoryPath -RunId 'sameNameNext' $drift.Summary.Changed | Should -Be 1 $drift.Summary.Unchanged | Should -Be 1 $changedEntry = $drift.Changed | Select-Object -First 1 # Changed entries carry Changes (a field-level diff), not the raw Fields # bag Added/Removed entries carry — confirms the two shapes stay distinct. $changedEntry.PSObject.Properties.Name | Should -Not -Contain 'Fields' $changedEntry.Id | Should -Match 'rgB' } It 'keys a resource identified only by a non-name field (nsg) correctly' { $collect1 = New-InventoryTestCollect -NsgRules @((New-NsgRule -Nsg 'nsg-only-id')) Get-ScoutInventoryDrift -Collect $collect1 -HistoryPath $script:HistoryPath -RunId 'nsgBaseline' | Out-Null $collect2 = New-InventoryTestCollect -NsgRules @((New-NsgRule -Nsg 'nsg-only-id' -Port '443')) $drift = Get-ScoutInventoryDrift -Collect $collect2 -HistoryPath $script:HistoryPath -RunId 'nsgNext' $drift.Summary.Changed | Should -Be 1 $portChange = ($drift.Changed | Select-Object -First 1).Changes | Where-Object Field -eq 'port' $portChange.PreviousValue | Should -Be '*' $portChange.CurrentValue | Should -Be '443' } } Describe 'Get-ScoutInventoryDrift — malformed/corrupt history is tolerated' { BeforeAll { $script:HistoryPath = Join-Path $env:TEMP "ScoutInvDriftTest_$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $script:HistoryPath -Force | Out-Null 'this is not { valid json at all' | Out-File (Join-Path $script:HistoryPath 'inventory-history.json') -Encoding utf8 $collect = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'vnetA')) $script:DriftBlock = { Get-ScoutInventoryDrift -Collect $collect -HistoryPath $script:HistoryPath -RunId 'run1' } } AfterAll { if (Test-Path $script:HistoryPath) { Remove-Item $script:HistoryPath -Recurse -Force -ErrorAction SilentlyContinue } } It 'does not throw on a malformed history file' { $script:DriftBlock | Should -Not -Throw } It 'treats malformed history as a baseline' { $result = & $script:DriftBlock $result.IsBaseline | Should -BeTrue $result.PreviousRunId | Should -BeNullOrEmpty } It 'overwrites the malformed file with valid JSON afterward' { & $script:DriftBlock | Out-Null { Get-Content (Join-Path $script:HistoryPath 'inventory-history.json') -Raw | ConvertFrom-Json -Depth 100 } | Should -Not -Throw } } Describe 'Get-ScoutInventoryDrift — parameter validation' { It 'throws when -Collect is not supplied' { { Get-ScoutInventoryDrift -Collect $null -HistoryPath $env:TEMP -RunId 'run1' } | Should -Throw } It 'throws when -RunId is not supplied' { $collect = New-InventoryTestCollect -VirtualNetworks @((New-Vnet 'vnetA')) { Get-ScoutInventoryDrift -Collect $collect -HistoryPath $env:TEMP -RunId '' } | Should -Throw } } |