tests/CollectorDefinitionSchema.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# AB#5661 -- the schema-validation gate for declarative collector definitions. A `.psd1` definition is data that is executed, and almost every way of getting one wrong fails SILENTLY: an Export column no field produces is a blank column, a SetupVariable the preamble stopped assigning is an empty join, a definition that has drifted from its collector is two paths quietly running different source. None of those is a crash, and none of them is caught by the equivalence suite when the drift happens to be behaviour-preserving on the fixture -- Compute/AvailabilitySets.psd1 sat drifted for a whole release with a green equivalence test. Two halves, deliberately: * the checks FIRE -- scripts/Test-ScoutCollectorDefinition.ps1 exits non-zero, and names the file, for each kind of breakage. Proven by writing a deliberately broken definition into a throwaway tree and running the real script against it. A gate nobody has ever seen fail is not a gate. * the real tree PASSES -- every definition under manifests/collectors satisfies all of it. The same script runs as its own CI step (.github/workflows/ci.yml) so a violation is annotated on the offending file in the PR diff rather than buried in a Pester summary; this file is what makes it run locally too. #> BeforeAll { $script:RepoRoot = Split-Path -Parent $PSScriptRoot $script:Validator = Join-Path $script:RepoRoot 'scripts/Test-ScoutCollectorDefinition.ps1' # A private sandbox root. NOT under $env:TEMP\AZSC_*: about eighteen test files share a fixed # path there and Remove-Item it in their own BeforeAll. $script:Sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("azsc-defschema-" + [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $script:Sandbox -Force function New-BrokenDefinitionTree { <# Write ONE definition into a fresh throwaway tree and run the real validator over it. Returns the exit code and the captured output, so a test can assert both that it failed and that it said why. #> param([Parameter(Mandatory)][string]$Body) $Root = Join-Path $script:Sandbox ([guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path (Join-Path $Root 'Probe') -Force Set-Content -LiteralPath (Join-Path $Root 'Probe/Broken.psd1') -Value $Body -Encoding utf8 # -SkipDriftCheck / -SkipAllowListStaleCheck: a throwaway tree has no SourceCollector in # this repo, and contains none of the five allow-listed blank columns, so both of those # checks would fire for reasons that have nothing to do with what is being probed. $Output = & pwsh -NoProfile -File $script:Validator -DefinitionRoot $Root ` -SkipDriftCheck -SkipAllowListStaleCheck 2>&1 | Out-String [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $Output } } # A definition that is valid in every respect, used as the base each probe breaks ONE thing in. # If this did not pass, every "it fails" assertion below would be vacuous. $script:ValidBody = @' @{ ResourceTypes = @('microsoft.sql/servers') ResourceTypeMatching = 'Grouped' RowLoopVariable = '1' Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'Name'; Expression = '$1.NAME' } ) Export = @{ WorksheetName = 'Probe' Columns = @('Name') # Explicit and EMPTY. Omitting the key falls back to the loader's historic default of # @('Tag Name', 'Tag Value'), and this probe declares neither as a Field -- which the # validator correctly reports as two columns that would be blank under -IncludeTags. Every # generated definition writes TagColumns explicitly, so the default only bites hand-written # ones like this. TagColumns = @() } SourceCollector = 'Modules/Public/InventoryModules/Databases/SQLSERVER.ps1' } '@ } AfterAll { if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) { Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue } } Describe 'Collector definition validation (AB#5661) -- the gate fires' { It 'passes a definition that is correct in every respect' { $Result = New-BrokenDefinitionTree -Body $script:ValidBody $Result.ExitCode | Should -Be 0 -Because "otherwise every failure assertion below is vacuous. Output:`n$($Result.Output)" } It 'fails a definition that is not valid PowerShell data at all' { $Result = New-BrokenDefinitionTree -Body '@{ ResourceTypes = @( this is not a psd1' $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'Broken.psd1' } It 'fails a definition whose Export names a column no Field produces' { # THE defect this whole file exists for: Select-Object does not require the property to # exist, so this ships as a permanently blank column rather than as an error. $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "Columns = @\('Name'\)", "Columns = @('Name', 'Nmae')") $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match "Nmae" $Result.Output | Should -Match 'BLANK column' } It 'fails a definition whose ResourceTypeMatching is not a recognised value' { # Not a fallback to the default: the two modes produce different ROW ORDER for a multi-type # collector, so guessing reorders a worksheet. $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "'Grouped'", "'Ungrouped'") $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'ResourceTypeMatching' } It 'fails a definition with no ResourceTypes' { $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "@\('microsoft.sql/servers'\)", '@()') $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'ResourceTypes' } It 'fails a definition that declares the same Field name twice' { $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "Name = 'Name'; Expression = '\`$1.NAME'", "Name = 'ID'; Expression = '`$1.NAME'") $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'more than once' } It 'fails a definition whose TagColumnsBefore names a column that does not exist' { $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "Columns = @\('Name'\)", "Columns = @('Name')`n TagColumnsBefore = 'Not A Column'") $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'TagColumnsBefore' } It 'fails a definition whose SetupVariables the SetupPreamble never assigns' { # Written out in full rather than patched into $ValidBody with -replace: the replacement # side of -replace treats `$` as a group reference, so a body containing `$Resources` and # `$_` comes out mangled and the definition then fails for the wrong reason. $Body = @' @{ ResourceTypes = @('microsoft.sql/servers') ResourceTypeMatching = 'Grouped' RowLoopVariable = '1' SetupPreamble = '$Partners = $Resources | Where-Object { $_.TYPE -eq ''microsoft.sql/servers/databases'' }' SetupVariables = @('Partners', 'NeverAssigned') Fields = @( @{ Name = 'ID'; Expression = '$1.id' } @{ Name = 'Name'; Expression = '$1.NAME' } ) Export = @{ WorksheetName = 'Probe' Columns = @('Name') TagColumns = @() } SourceCollector = 'Modules/Public/InventoryModules/Databases/SQLSERVER.ps1' } '@ $Result = New-BrokenDefinitionTree -Body $Body $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'NeverAssigned' } It 'fails a SetupPreamble with no SetupVariables, and SetupVariables with no SetupPreamble' { # Both directions, because either one alone is a section that reads as if it were doing # something and is not -- the same reason a FilterPreamble without a filter is an error. $Orphaned = $script:ValidBody -replace "RowLoopVariable = '1'", "RowLoopVariable = '1'`n SetupPreamble = '`$X = 1'" (New-BrokenDefinitionTree -Body $Orphaned).ExitCode | Should -Be 1 $Dangling = $script:ValidBody -replace "RowLoopVariable = '1'", "RowLoopVariable = '1'`n SetupVariables = @('X')" (New-BrokenDefinitionTree -Body $Dangling).ExitCode | Should -Be 1 } It 'fails a definition whose SetupVariables carry a leading dollar sign' { # `Get-Variable -Name '$X'` looks for a variable literally called '$X' and finds nothing -- # a name that never binds, silently. $Body = $script:ValidBody -replace "RowLoopVariable = '1'", @" RowLoopVariable = '1' SetupPreamble = '`$X = 1' SetupVariables = @('`$X') "@ $Result = New-BrokenDefinitionTree -Body $Body $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'dollar sign' } It 'fails a definition whose SourceCollector does not exist' { $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace 'Databases/SQLSERVER.ps1', 'Databases/NoSuchCollector.ps1') $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'NoSuchCollector' } It 'fails a definition whose Preamble does not parse' { $Result = New-BrokenDefinitionTree -Body ($script:ValidBody -replace "RowLoopVariable = '1'", "RowLoopVariable = '1'`n Preamble = 'if (`$true) { '") $Result.ExitCode | Should -Be 1 $Result.Output | Should -Match 'Preamble' } } Describe 'Collector definition validation (AB#5661) -- the real tree passes' { It 'every definition under manifests/collectors passes every check, including the drift check' { # The drift check is NOT skipped here. It regenerates each definition from its own # SourceCollector and compares byte for byte, which is the only thing in the repo that # notices a collector being edited without its definition being regenerated. $Output = & pwsh -NoProfile -File $script:Validator 2>&1 | Out-String $Code = $LASTEXITCODE $Code | Should -Be 0 -Because "collector definitions must be valid and current:`n$Output" } } |