Add a Pester test suite for the collectors

84 tests in collectors/tests, run with Invoke-CollectorTests.ps1 (~15 s,
PowerShell 7.2+ and Pester 5.5+):

- Module.Tests: the shared module (property lookup, time format,
  config/lookback, sent-run state and pruning, dry runs, exit codes,
  401 detection)
- Veeam/Mars/Wsb/Vb365.Tests: each collector run in its own process
  against its mock and a stand-in ingest API, asserting the behaviours
  confirmed on real servers: VBR 13 live schedule, copy jobs, MARS UTC
  times and ErrorCode 0, WSB culture-formatted times and nested VMs,
  VB365 organization mapping, log prefixes and retries, state and
  resending, rejected keys
- Installers.Tests: config, files and scheduled task from each
  installer, with stand-ins for icacls / Task Scheduler / the key prompt

Collectors run under the test runner's PowerShell, or another shell via
-CollectorShell (e.g. powershell.exe, to test under Windows PowerShell
5.1 like the MARS, WSB and VB365 servers). The mocks take their "now"
from MOCK_NOW so expectations can't drift with the clock; the WSB mock
gains -RunningJobFinished for the running-to-finished case.

Each of 12 past bugs, put back one at a time, fails the test for its
behaviour (and the runner exits 1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 13:36:27 -06:00
co-authored by Claude Opus 5.5
parent 2fa9eea274
commit c5fe69a813
14 changed files with 1011 additions and 5 deletions
+139
View File
@@ -0,0 +1,139 @@
# The collector installers, with stand-ins for the Windows-only commands they call (icacls, Task
# Scheduler, Unblock-File, the API key prompt). Checks what each would write and register.
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$collectorsDir = Split-Path -Parent $PSScriptRoot
$shell = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { (Get-Process -Id $PID).Path }
$pwshPath = (Get-Process -Id $PID).Path # stands in for an installed PowerShell 7
# Runs one installer in a fresh process and sandbox; returns what it wrote and registered
function Invoke-Installer {
param([string]$Collector, [string]$Arguments, [switch]$NoProductModule)
$sandbox = New-TestDir 'installer'
$src = Join-Path $sandbox 'collectors'
Copy-Item -Path $collectorsDir -Destination $src -Recurse
$installer = (Get-ChildItem (Join-Path $src $Collector) -Filter 'Install-*.ps1').FullName
# The test runner isn't elevated
(Get-Content $installer) -notmatch '^#Requires -RunAsAdministrator' | Set-Content $installer
foreach ($d in 'ProgramData', 'ProgramFiles', 'SystemRoot') { New-Item -ItemType Directory -Path (Join-Path $sandbox $d) | Out-Null }
$record = Join-Path $sandbox 'record.txt'
$stubs = @'
function global:Record($what) { Add-Content $env:REC_FILE $what }
function global:Read-Host { param([switch]$AsSecureString, $Prompt) ConvertTo-SecureString 'bdk_testkey' -AsPlainText -Force }
function global:icacls.exe { Record "icacls $($args -join ' ')"; $global:LASTEXITCODE = 0 }
function global:Unblock-File { param($Path) Record "unblock $(Split-Path -Leaf $Path)" }
function global:Get-Module { param([switch]$ListAvailable, $Name) if (-not $env:STUB_NO_MODULE) { [pscustomobject]@{ Name = $Name } } }
function global:New-ScheduledTaskAction { param($Execute, $Argument) Record "exe $Execute"; Record "args $Argument" }
function global:New-ScheduledTaskTrigger { param([switch]$Once, $At, $RepetitionInterval) Record "every $([int]$RepetitionInterval.TotalMinutes)" }
function global:New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) Record "principal $UserId $LogonType $RunLevel" }
function global:New-ScheduledTaskSettingsSet { param($ExecutionTimeLimit, $MultipleInstances, [switch]$StartWhenAvailable) Record "settings $MultipleInstances" }
function global:Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, $Description, [switch]$Force) Record "task $TaskName" }
'@
$env:REC_FILE = $record
$env:STUB_NO_MODULE = if ($NoProductModule) { '1' } else { '' }
$saved = @{ ProgramData = $env:ProgramData; ProgramFiles = $env:ProgramFiles; SystemRoot = $env:SystemRoot }
try {
foreach ($d in $saved.Keys) { Set-Item "env:$d" (Join-Path $sandbox $d) }
Push-Location (Split-Path $installer)
$output = @(& $shell -NoProfile -NonInteractive -Command "$stubs; & '$installer' $Arguments" 2>&1 | ForEach-Object { "$_" })
} finally {
Pop-Location
foreach ($d in $saved.Keys) { Set-Item "env:$d" $saved[$d] }
}
$configFile = Get-ChildItem (Join-Path (Join-Path $sandbox 'ProgramData') 'BackupDashboard') -Filter '*-collector.json' -ErrorAction SilentlyContinue
$installDir = Join-Path (Join-Path $sandbox 'ProgramFiles') 'BackupDashboard'
return [pscustomobject]@{
Output = $output -join "`n"
Config = if ($configFile) { Get-Content $configFile.FullName -Raw | ConvertFrom-Json } else { $null }
Files = @(Get-ChildItem $installDir -ErrorAction SilentlyContinue | ForEach-Object Name | Sort-Object)
Record = @(if (Test-Path $record) { Get-Content $record })
Sandbox = $sandbox
}
}
}
Describe 'Collector installers' {
Context 'Veeam' {
BeforeAll { $i = Invoke-Installer veeam "-ApiUrl https://dash.example.com/ -Tenant 'Acme Corp' -PwshPath '$pwshPath'" }
It 'writes the config, including the API key from the prompt' {
$i.Config.ApiUrl | Should -Be 'https://dash.example.com'
$i.Config.ApiKey | Should -Be 'bdk_testkey'
$i.Config.Tenant | Should -Be 'Acme Corp'
$i.Config.SourceSystem | Should -Be 'Veeam B&R'
$i.Config.LookbackHours | Should -Be 48
}
It 'locks the config folder to SYSTEM and Administrators' {
$i.Record | Where-Object { $_ -like 'icacls*' } | Should -BeLike '*/inheritance:r /grant:r `*S-1-5-18:(OI)(CI)F `*S-1-5-32-544:(OI)(CI)F'
}
It 'installs the collector and the shared module side by side, unblocked' {
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-VeeamBackupStatus.ps1')
$i.Record | Should -Contain 'unblock BackupDashboard.Collector.psm1'
$i.Record | Should -Contain 'unblock Send-VeeamBackupStatus.ps1'
}
It 'registers an hourly SYSTEM task running the collector under PowerShell 7' {
$i.Record | Should -Contain "exe $pwshPath"
$i.Record | Where-Object { $_ -like 'args*' } | Should -BeLike '*-File "*Send-VeeamBackupStatus.ps1"'
$i.Record | Should -Contain 'every 60'
$i.Record | Should -Contain 'principal S-1-5-18 ServiceAccount Highest'
$i.Record | Should -Contain 'settings IgnoreNew'
$i.Record | Should -Contain 'task Backup Dashboard - Veeam collector'
}
It 'refuses a PowerShell 7 path that does not exist, before changing anything' {
$bad = Invoke-Installer veeam "-ApiUrl https://dash.example.com -Tenant Acme -PwshPath '/no/such/pwsh.exe'"
$bad.Output | Should -BeLike '*PowerShell 7 not found*'
$bad.Config | Should -BeNullOrEmpty
$bad.Files | Should -BeNullOrEmpty
}
}
Context 'MARS' {
It 'writes JobName, runs hourly under Windows PowerShell, and honours -IntervalMinutes' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant 'Acme Corp' -JobName 'File Server' -IntervalMinutes 30"
$i.Config.JobName | Should -Be 'File Server'
$i.Config.SourceSystem | Should -Be 'Azure Backup (MARS)'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-MarsBackupStatus.ps1')
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*WindowsPowerShell*powershell.exe'
$i.Record | Should -Contain 'every 30'
$i.Record | Should -Contain 'task Backup Dashboard - MARS collector'
}
It 'refuses to install where the MARS agent is missing, before changing anything' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
$i.Output | Should -BeLike '*MARS agent*not installed*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'Windows Server Backup' {
It 'warns when the dashboard URL is not HTTPS' {
$i = Invoke-Installer wsb "-ApiUrl http://dash.example.com -Tenant 'Acme Corp'"
$i.Output | Should -BeLike '*not HTTPS*'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-WsbBackupStatus.ps1')
$i.Record | Should -Contain 'task Backup Dashboard - WSB collector'
}
It 'refuses to install without the Windows Server Backup feature' {
$i = Invoke-Installer wsb "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
# (PowerShell wraps long error messages, so match a short part)
$i.Output | Should -BeLike '*WindowsServerBackup PowerShell module is not available*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'VB365' {
It 'writes the organization-to-company map, with no Tenant unless given' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -OrganizationTenants @{ 'acme.onmicrosoft.com' = 'Acme Corp'; 'blue.onmicrosoft.com' = 'Bluewater' }"
$i.Config.OrganizationTenants.'acme.onmicrosoft.com' | Should -Be 'Acme Corp'
$i.Config.OrganizationTenants.'blue.onmicrosoft.com' | Should -Be 'Bluewater'
$i.Config.PSObject.Properties.Name | Should -Not -Contain 'Tenant'
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*powershell.exe'
}
It 'uses PowerShell 7 when given -PwshPath' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -Tenant 'Solo Co' -PwshPath '$pwshPath'"
$i.Config.Tenant | Should -Be 'Solo Co'
$i.Record | Should -Contain "exe $pwshPath"
}
}
}