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"
}
}
}
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Runs the collector test suite (Pester): the shared module, each collector against its mock,
and the installers.
.DESCRIPTION
Needs PowerShell 7.2+ and Pester 5.5+:
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser
Each collector runs in its own process, by default under the same PowerShell running the
tests. On Windows, use -CollectorShell to run them under Windows PowerShell 5.1 instead, as
the MARS, WSB and VB365 servers do.
Exits non-zero if any test fails, so it can gate a commit or CI job.
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
# powershell.exe / pwsh / a full path: the shell the collectors and installers run under
[string]$CollectorShell,
# Test files or folders to run; defaults to every *.Tests.ps1 here
[string[]]$Path = @($PSScriptRoot),
[ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')][string]$Output = 'Detailed'
)
$ErrorActionPreference = 'Stop'
$pester = Get-Module -ListAvailable -Name Pester | Where-Object Version -GE '5.5' |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $pester) {
throw 'Pester 5.5 or newer is needed: Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser'
}
Import-Module $pester
if ($CollectorShell) {
$resolved = Get-Command $CollectorShell -ErrorAction SilentlyContinue
if (-not $resolved) { throw "Shell not found: $CollectorShell" }
$env:COLLECTOR_TEST_SHELL = $resolved.Source
}
$shellName = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { "this PowerShell ($($PSVersionTable.PSVersion))" }
Write-Host "Collectors run under: $shellName"
# All temp folders for this run, removed at the end
$env:COLLECTOR_TEST_TEMP = Join-Path ([IO.Path]::GetTempPath()) ("backup-dashboard-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try {
$config = New-PesterConfiguration
$config.Run.Path = $Path
$config.Run.PassThru = $true
$config.Output.Verbosity = $Output
$result = Invoke-Pester -Configuration $config
} finally {
Remove-Item -Recurse -Force $env:COLLECTOR_TEST_TEMP -ErrorAction SilentlyContinue
Remove-Item env:COLLECTOR_TEST_TEMP
}
exit $(if ($result.Result -eq 'Passed') { 0 } else { 1 })
+95
View File
@@ -0,0 +1,95 @@
# Azure Backup (MARS agent) collector, against collectors/mars/tests/MockMars.ps1 (shapes from a
# real agent, 2.0.9274: job times are UTC values with no UTC marking, ErrorInfo is always present).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
# MARS reports job times in UTC; the API should get them with +00:00
function Utc([datetime]$Local) { ConvertTo-ApiTime $Local.ToUniversalTime() }
}
Describe 'MARS collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector mars -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, named after it' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Azure Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Azure Backup (MARS)'
}
It 'computes the next run from the schedule (daily at 21:00)' {
$next = (Get-Date).Date.AddHours(21)
if ((Get-Date) -ge $next) { $next = $next.AddDays(1) }
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'treats job times as UTC (MARS gives UTC values without marking them)' {
$byStart.Keys | Should -Contain (Utc $now.AddHours(-12))
$byStart[(Utc $now.AddHours(-12))].finished_at | Should -Be (Utc $now.AddHours(-11.5))
}
It 'reports a completed backup as success, despite its (always present) ErrorInfo with code 0' {
$run = $byStart[(Utc $now.AddHours(-12))]
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.size_bytes | Should -Be 523407872
$run.details.datasources | Should -Be @('E:\')
}
It 'reports a completed backup with failed files as a warning, with the count and log' {
$run = $byStart[(Utc $now.AddHours(-36))]
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*E:\: 12 file(s) failed to back up*'
$run.message | Should -BeLike '*Failed file log: C:\Program Files\Microsoft Azure Recovery Services Agent\Temp\Errors.log*'
$run.details.failed_files | Should -Be 12
$run.details.failed_bytes | Should -Be 40MB
}
It 'reports a failed backup with its error code and parameters' {
$run = $byStart[(Utc $now.AddHours(-30))]
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Error code 100050 (Volume=E:)*'
}
It 'reports a backup in progress as running, without a finish time' {
$run = $byStart[(Utc $now.AddMinutes(-10))]
$run.status | Should -Be 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
}
It 'ignores restores and backups outside the lookback window' {
$runs.Count | Should -Be 4
}
It 'uses JobName from the config when set' {
$named = Invoke-Collector mars -Config ($config + @{ JobName = 'File Server' }) -DryRun
$named.Reports.job.name | Select-Object -Unique | Should -Be 'File Server'
$named.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector mars -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector mars -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run on the first run' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 4
}
It 'on the next run, resends only the backup in progress' {
$first.State.Count | Should -Be 3
$secondRequests.Count | Should -Be 1
$secondRequests[0].Payload.run.status | Should -Be 'running'
}
}
}
+136
View File
@@ -0,0 +1,136 @@
# Tests for collectors/common/BackupDashboard.Collector.psm1, the code all collectors share.
BeforeAll {
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'common/BackupDashboard.Collector.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
function New-TempConfig([hashtable]$Values, [hashtable]$State) {
$dir = New-TestDir 'module'
$path = Join-Path $dir 'test-collector.json'
$Values | ConvertTo-Json | Set-Content -Path $path
if ($State) { $State | ConvertTo-Json | Set-Content -Path (Join-Path $dir 'test-collector.state.json') }
return $path
}
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
}
Describe 'Get-FirstValue' {
It 'returns the first property that has a value' {
Get-FirstValue ([pscustomobject]@{ A = $null; B = ''; C = 'yes' }) @('A', 'B', 'C') | Should -Be 'yes'
}
It 'treats an empty collection as no value' {
Get-FirstValue ([pscustomobject]@{ A = @(); B = 'yes' }) @('A', 'B') | Should -Be 'yes'
}
It 'returns a one-item list of objects (which [string] would turn into an empty string)' {
$item = [pscustomobject]@{ Name = 'E:\' }
$result = @(Get-FirstValue ([pscustomobject]@{ List = @($item) }) @('List'))
$result.Count | Should -Be 1
$result[0].Name | Should -Be 'E:\'
}
It 'returns $null for a missing property or object' {
Get-FirstValue ([pscustomobject]@{ A = 1 }) @('Nope') | Should -BeNullOrEmpty
Get-FirstValue $null @('A') | Should -BeNullOrEmpty
}
It 'keeps $false (a real value, not "missing")' {
Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled') | Should -BeFalse
$null -eq (Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled')) | Should -BeFalse
}
}
Describe 'ConvertTo-IsoTime' {
It 'gives local times their offset' {
$local = [datetime]::new(2026, 10, 8, 21, 0, 0, [DateTimeKind]::Local)
ConvertTo-IsoTime $local | Should -Be ([DateTimeOffset]$local).ToString("yyyy-MM-dd'T'HH:mm:sszzz")
}
It 'gives UTC times +00:00' {
ConvertTo-IsoTime ([datetime]::new(2026, 10, 8, 3, 0, 5, [DateTimeKind]::Utc)) | Should -Be '2026-10-08T03:00:05+00:00'
}
It 'treats "not set" values as no time' {
ConvertTo-IsoTime ([datetime]::MinValue) | Should -BeNullOrEmpty
ConvertTo-IsoTime ([datetime]::new(1601, 1, 1)) | Should -BeNullOrEmpty # MARS
ConvertTo-IsoTime ([datetime]::new(1900, 1, 1)) | Should -BeNullOrEmpty # Veeam
ConvertTo-IsoTime $null | Should -BeNullOrEmpty
}
}
Describe 'Initialize-Collector' {
It 'requires ApiUrl, ApiKey and Tenant by default' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path } | Should -Throw "*missing 'Tenant'*"
}
It 'accepts a custom set of required keys' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path -RequiredKeys 'ApiUrl', 'ApiKey' } | Should -Not -Throw
}
It 'resolves LookbackHours: parameter, then config, then 48' {
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig $config)).LookbackHours | Should -Be 48
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 }))).LookbackHours | Should -Be 720
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 })) -LookbackHours 12).LookbackHours | Should -Be 12
}
It 'loads runs already sent from the state file' {
$path = New-TempConfig $config @{ 'run-1' = [DateTimeOffset]::Now.ToUnixTimeSeconds() }
$null = Initialize-Collector -Name 'test' -ConfigPath $path
Test-RunSent 'run-1' | Should -BeTrue
Test-RunSent 'run-2' | Should -BeFalse
}
}
Describe 'Send-JobReport and Complete-Collector' {
BeforeEach {
$path = New-TempConfig $config @{
'recent' = [DateTimeOffset]::Now.AddHours(-10).ToUnixTimeSeconds()
'stale' = [DateTimeOffset]::Now.AddHours(-100).ToUnixTimeSeconds() # > 48h + 1 day ago
}
$statePath = Join-Path (Split-Path $path) 'test-collector.state.json'
$job = [ordered]@{ external_id = 'j1'; name = 'Job'; next_run_at = $null }
}
It 'records a finished run as sent, but not a running one' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'done' -StartedAt (Get-Date) 6>&1
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'running' }) -SentKey 'busy' -StartedAt (Get-Date) 6>&1
Test-RunSent 'done' | Should -BeTrue
Test-RunSent 'busy' | Should -BeFalse
}
It 'prints the payload in a dry run, with the job and run' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$printed = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'x' -StartedAt (Get-Date) 6>&1
$payload = "$printed" | ConvertFrom-Json
$payload.tenant | Should -Be 'Acme'
$payload.source_system | Should -Be 'Test'
$payload.job.external_id | Should -Be 'j1'
$payload.run.status | Should -Be 'success'
}
It 'saves the state, dropping runs older than the lookback window plus a day' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path
$null = Complete-Collector -Failures 0 6>&1
$saved = Get-Content $statePath -Raw | ConvertFrom-Json
$saved.PSObject.Properties.Name | Should -Be @('recent')
}
It "doesn't touch the state in a dry run" {
$before = Get-Content $statePath -Raw
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Complete-Collector -Failures 0 6>&1
Get-Content $statePath -Raw | Should -Be $before
}
It 'returns exit code 1 when anything failed, 0 otherwise' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
Complete-Collector -Failures 0 6>$null | Should -Be 0
Complete-Collector -Failures 2 6>$null | Should -Be 1
}
It 'logs a summary with or without a job count' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
"$(Complete-Collector -JobCount 3 -Failures 0 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 3 jobs, 0 runs, 0 failures$'
"$(Complete-Collector -Failures 1 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 0 runs, 1 failures$'
}
}
Describe 'Test-AuthRejected' {
It 'is true only for HTTP 401' {
$error401 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 401 } } }
$error500 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 500 } } }
$noResponse = [pscustomobject]@{ Exception = [pscustomobject]@{ Message = 'timeout' } }
Test-AuthRejected $error401 | Should -BeTrue
Test-AuthRejected $error500 | Should -BeFalse
Test-AuthRejected $noResponse | Should -BeFalse
}
}
+43
View File
@@ -0,0 +1,43 @@
# Collector tests
Pester tests for the collectors, the shared module and the installers. Each collector runs against
its mock (`collectors/<name>/tests/Mock*.ps1`, built from real servers' diagnostic output) and a
stand-in for the ingest API, so no backup product or dashboard is needed.
```powershell
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser # once
./collectors/tests/Invoke-CollectorTests.ps1 # everything, ~15 s
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
```
Needs PowerShell 7.2+ to run the tests. Collectors run in their own process, under the same
PowerShell by default. On Windows, run them under Windows PowerShell 5.1, as the MARS, WSB and
VB365 servers do:
```powershell
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
```
The runner exits non-zero if anything fails.
## What's covered
| File | Checks |
|---|---|
| `Module.Tests.ps1` | `BackupDashboard.Collector.psm1`: property lookup, time format, config and lookback, state (recording sent runs, pruning), dry runs, exit codes, 401 detection |
| `Veeam.Tests.ps1` | Next run from the live schedule (VBR 13), none for copy/disabled jobs; copy-job worker sessions; statuses and messages; state and resending; rejected key; missing config |
| `Mars.Tests.ps1` | UTC job times; ErrorCode 0 isn't an error; failed files → warning; next run from the schedule; JobName; state |
| `Wsb.Tests.ps1` | Culture-formatted times (en-US and en-GB); VMs nested in VirtualMachineList; HRESULTs; failure log only when relevant; running → finished merging on start time |
| `Vb365.Tests.ps1` | Organization → company mapping; `[Warning]`/`[Failed]` log prefixes; retries; "187.2 MB" sizes; state |
| `Installers.Tests.ps1` | Config written (incl. the VB365 map), files copied (collector + module), task registered (shell, interval, SYSTEM), refusals before changing anything |
Each of these was a real bug or a real-server finding. To check the suite still catches them,
put one back (e.g. read `$Job.ScheduleOptions.NextRun` instead of `GetScheduleOptions()` in the
Veeam collector) and run the tests: they should fail.
## Adding a collector
1. Write `collectors/<name>/tests/Mock<Name>.ps1` from the product's diagnostic output, taking
its time from `$env:MOCK_NOW` like the others.
2. Add `<name>` to `Invoke-Collector`'s `ValidateSet` in `TestHelpers.psm1`.
3. Add `<Name>.Tests.ps1`, one `It` per behaviour confirmed on a real server.
+166
View File
@@ -0,0 +1,166 @@
<#
Helpers for the collector tests: run a collector against its mock in a separate PowerShell
process, and stand in for the dashboard's ingest API.
Each collector run gets its own process so the mocks' global functions and the collector's
`exit` don't leak between tests. The shell is the one running the tests, or
$env:COLLECTOR_TEST_SHELL (e.g. powershell.exe, to run the collectors under Windows
PowerShell 5.1 like most servers do).
#>
$script:CollectorsDir = Split-Path -Parent $PSScriptRoot
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
# Temp folders for this test run live under one root, which Invoke-CollectorTests.ps1 removes
function New-TestDir([string]$Prefix = 'test') {
$root = if ($env:COLLECTOR_TEST_TEMP) { $env:COLLECTOR_TEST_TEMP } else { Join-Path ([IO.Path]::GetTempPath()) 'backup-dashboard-tests' }
$dir = Join-Path $root ("$Prefix-" + [guid]::NewGuid().ToString('N').Substring(0, 12))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}
function Get-TestShell {
if ($env:COLLECTOR_TEST_SHELL) { return $env:COLLECTOR_TEST_SHELL }
return (Get-Process -Id $PID).Path
}
<#
Pins the time the mocks are relative to (the top of the current hour) for this test session,
so a test can't fail because the hour turned over between the run and the assertions.
Returns it as a local DateTime.
#>
function Set-MockNow {
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
$env:MOCK_NOW = $now.ToString('o', $script:Invariant)
return [datetime]$env:MOCK_NOW
}
# The API's time format: "2026-10-08T21:00:00-06:00"
function ConvertTo-ApiTime([datetime]$Value) {
return ([DateTimeOffset]$Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
}
# JSON with date-looking strings left as strings (PowerShell 7.5+ would otherwise make them DateTimes)
function ConvertFrom-JsonText([string]$Json) {
if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {
return $Json | ConvertFrom-Json -DateKind String
}
return $Json | ConvertFrom-Json
}
function ConvertTo-PSLiteral([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" }
<#
Runs one collector against its mock. Returns:
ExitCode the collector's exit code (0 when it ends normally)
Reports in -DryRun, the payloads it would have sent
Log its log lines (timestamp stripped), from the console
State the sent-runs state file, as a hashtable (empty if none)
WorkDir pass back in to run again with the same config and state
#>
function Invoke-Collector {
param(
[Parameter(Mandatory, Position = 0)][ValidateSet('veeam', 'mars', 'wsb', 'vb365')][string]$Name,
[Parameter(Mandatory)][hashtable]$Config,
[string]$WorkDir,
[switch]$DryRun,
[string]$MockArgs = '',
[string]$Culture = 'en-US',
[string]$ExtraArgs = '',
[string]$ComputerName = 'SRV01'
)
$dir = Join-Path $script:CollectorsDir $Name
$script = (Get-ChildItem (Join-Path $dir 'Send-*.ps1')).FullName
$mock = (Get-ChildItem (Join-Path (Join-Path $dir 'tests') 'Mock*.ps1')).FullName
if (-not $WorkDir) {
$WorkDir = New-TestDir $Name
}
$configPath = Join-Path $WorkDir "$Name-collector.json"
$Config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
$command = @(
"[Globalization.CultureInfo]::CurrentCulture = '$Culture'"
". $(ConvertTo-PSLiteral $mock) $MockArgs"
"& $(ConvertTo-PSLiteral $script) -ConfigPath $(ConvertTo-PSLiteral $configPath) $(if ($DryRun) { '-DryRun' }) $ExtraArgs"
'exit $LASTEXITCODE'
) -join '; '
$savedComputer = $env:COMPUTERNAME
$env:COMPUTERNAME = $ComputerName
try {
$output = @(& (Get-TestShell) -NoProfile -NonInteractive -Command $command 2>&1 | ForEach-Object { "$_" })
$exitCode = $LASTEXITCODE
} finally {
$env:COMPUTERNAME = $savedComputer
}
$state = @{}
$statePath = Join-Path $WorkDir "$Name-collector.state.json"
if (Test-Path $statePath) {
$saved = ConvertFrom-JsonText (Get-Content $statePath -Raw)
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $state[$p.Name] = $p.Value } }
}
return [pscustomobject]@{
ExitCode = $exitCode
# Payloads are single-line JSON; a logged API error body spans several lines starting with '{'
Reports = @($output | Where-Object { $_ -match '^\{".*\}$' } | ForEach-Object { ConvertFrom-JsonText $_ })
Log = @($output | Where-Object { $_ -match '^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[' } | ForEach-Object { $_.Substring(20) })
Output = $output
State = $state
WorkDir = $WorkDir
}
}
<#
A stand-in for the ingest API on a free localhost port. Records every request; answers 401
for API keys containing "bad", 200 otherwise. Stop it with Stop-ApiStub.
#>
function Start-ApiStub {
$probe = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$probe.Start(); $port = $probe.LocalEndpoint.Port; $probe.Stop()
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$port/")
$listener.Start()
$requests = [Collections.Concurrent.ConcurrentQueue[object]]::new()
$job = Start-ThreadJob -ArgumentList $listener, $requests -ScriptBlock {
param($listener, $requests)
while ($listener.IsListening) {
try { $context = $listener.GetContext() } catch { break }
$body = [IO.StreamReader]::new($context.Request.InputStream, [Text.Encoding]::UTF8).ReadToEnd()
$auth = $context.Request.Headers['Authorization']
$requests.Enqueue([pscustomobject]@{
Path = $context.Request.Url.AbsolutePath; Auth = $auth
ContentType = $context.Request.ContentType; Body = $body
})
$rejected = $auth -match 'bad'
$reply = if ($rejected) { '{"detail":"Invalid or revoked API key"}' } else { '{"job_id":1,"run_id":1}' }
$bytes = [Text.Encoding]::UTF8.GetBytes($reply)
$context.Response.StatusCode = $(if ($rejected) { 401 } else { 200 })
$context.Response.ContentType = 'application/json'
$context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$context.Response.Close()
}
}
return [pscustomobject]@{ Url = "http://localhost:$port"; Listener = $listener; Requests = $requests; Job = $job }
}
# Takes the requests recorded since the last call; Body is the parsed JSON payload
function Get-StubRequests($Stub) {
$taken = @()
$item = $null
while ($Stub.Requests.TryDequeue([ref]$item)) {
$item | Add-Member -NotePropertyName Payload -NotePropertyValue (ConvertFrom-JsonText $item.Body) -Force
$taken += $item
}
return , $taken
}
function Stop-ApiStub($Stub) {
$Stub.Listener.Stop()
$Stub.Listener.Close()
$Stub.Job | Stop-Job -PassThru | Remove-Job -Force
}
Export-ModuleMember -Function New-TestDir, Set-MockNow, ConvertTo-ApiTime, ConvertFrom-JsonText, Invoke-Collector,
Start-ApiStub, Get-StubRequests, Stop-ApiStub
+116
View File
@@ -0,0 +1,116 @@
# Veeam Backup for Microsoft 365 collector, against collectors/vb365/tests/MockVb365.ps1 (shapes from
# a VB365 8.4 server: log items carry their status as a Title prefix, retries are separate sessions).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$base = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' }
function Get-JobRuns($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
function Get-Tenants($Reports) {
$map = @{}
foreach ($report in $Reports) { $map[$report.job.name] = $report.tenant }
return $map
}
}
Describe 'VB365 collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector vb365 -Config ($base + @{ Tenant = 'Default Co' }) -DryRun
$acme = Get-JobRuns $r.Reports 'Acme 365 Backup'
$blue = Get-JobRuns $r.Reports 'Bluewater 365 Backup'
}
It 'runs cleanly, reporting every job' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 3 jobs, 5 runs, 0 failures'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam Backup for M365'
}
It "uses each job's NextRun, and none for a disabled job" {
$next = $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))
($r.Reports | Where-Object { $_.job.name -eq 'Acme 365 Backup' })[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
($r.Reports | Where-Object { $_.job.name -eq 'Bluewater Old Mailboxes' })[0].job.next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its size ("187.2 MB" in bytes) and objects' {
$run = $acme | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(-1).AddHours(17))
$run.size_bytes | Should -Be ([long](187.2 * 1MB))
$run.details.processed_objects | Should -Be 568
$run.details.organization | Should -Be 'acme.onmicrosoft.com'
$run.message | Should -Be 'Job completed successfully.'
}
It 'reports a session in progress as running, without a finish time' {
$run = $acme | Where-Object status -EQ 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'builds the message from [Warning]/[Failed] log items, without the prefix' {
$run = $blue | Where-Object status -EQ 'warning'
$run.message | Should -BeLike 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size*'
# Literal check: in -like patterns, [Warning] is a character set
$run.message.Contains('[Warning]') | Should -BeFalse
}
It 'ignores [Success] log items, even ones mentioning "error"' {
($blue | Where-Object status -EQ 'warning').message | Should -Not -BeLike '*error-reports*'
}
It 'flags a session VB365 will retry, and links the retry back to it' {
$warning = $blue | Where-Object status -EQ 'warning'
$retry = $blue | Where-Object { $_.details.retry_of }
$warning.message | Should -BeLike '*Veeam will retry this job.'
$warning.details.will_retry | Should -BeTrue
$retry.status | Should -Be 'success'
$retry.details.retry_of | Should -Be $warning.external_id
}
It 'reports a failed session with its error' {
$run = $blue | Where-Object status -EQ 'failed'
$run.message | Should -BeLike '*(401) Unauthorized*'
}
It 'skips sessions outside the lookback window' {
Get-JobRuns $r.Reports 'Bluewater Old Mailboxes' | Should -BeNullOrEmpty
}
}
Context 'which company a job appears under' {
It 'uses OrganizationTenants (not case-sensitive), then Tenant for unmapped organizations' {
$r = Invoke-Collector vb365 -DryRun -Config ($base + @{
Tenant = 'Default Co'; OrganizationTenants = @{ 'ACME.onmicrosoft.com' = 'Acme Corp' } })
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'Acme Corp'
$tenants['Bluewater 365 Backup'] | Should -Be 'Default Co'
}
It "falls back to the organization's name with no mapping and no Tenant" {
$r = Invoke-Collector vb365 -Config $base -DryRun
$r.ExitCode | Should -Be 0
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'acme.onmicrosoft.com'
$tenants['Bluewater 365 Backup'] | Should -Be 'bluewater.onmicrosoft.com'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $base + @{ Tenant = 'Default Co' }; $live.ApiUrl = $stub.Url
$first = Invoke-Collector vb365 -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector vb365 -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every session, remembering the finished ones' {
$first.ExitCode | Should -Be 0
@($firstRequests | Where-Object { $_.Payload.run }).Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 3
}
}
}
+127
View File
@@ -0,0 +1,127 @@
# Veeam B&R collector, against collectors/veeam/tests/MockVeeam.ps1 (shapes from a VBR 13 server).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$dash = [char]0x2013 # job names use an en dash, as real ones often do
$fileServer = "File Server $dash Nightly"
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
function Get-Runs($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
}
Describe 'Veeam collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector veeam -Config $config -DryRun
$jobs = @{}
foreach ($report in $r.Reports) { $jobs[$report.job.name] = $report.job }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 5 jobs, 6 runs, 0 failures'
}
It 'reports every job, under the configured tenant' {
$jobs.Keys | Sort-Object | Should -Be @('Cloud Copy', $fileServer, 'Offsite Copy', 'Old Exchange', "SQL01 $dash Logs")
$r.Reports.tenant | Select-Object -Unique | Should -Be 'Acme'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam B&R'
}
It "reads the next run from the live schedule (VBR 13's ScheduleOptions property is empty)" {
$jobs[$fileServer].next_run_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(1).AddMinutes(15))
$jobs["SQL01 $dash Logs"].next_run_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(40))
}
It 'gives no next run to immediate-mode copy jobs (their NextRun is stale), disabled or unscheduled jobs' {
$jobs['Cloud Copy'].next_run_at | Should -BeNullOrEmpty
$jobs['Old Exchange'].next_run_at | Should -BeNullOrEmpty
$jobs['Offsite Copy'].next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its times and size' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-2))
$run.finished_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-1))
$run.size_bytes | Should -Be 128GB
$run.message | Should -Be 'Job completed successfully.'
$run.details.objects | Should -Be 2
}
It 'reports a session in progress as running, without a finish time' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'running'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-20))
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'names the object behind a warning' {
$run = Get-Runs $r.Reports "SQL01 $dash Logs"
$run.status | Should -Be 'warning'
$run.message | Should -BeLike 'SQL01: Warning - Failed to truncate transaction logs*'
$run.details.objects_warning | Should -Be 1
}
It "includes a failure's reason and failed log lines, but not successful ones" {
$run = Get-Runs $r.Reports 'Offsite Copy'
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Unable to connect to repository NAS02*'
$run.message | Should -BeLike '*Network path not found (0x80070035)*'
$run.message | Should -Not -BeLike '*Preparing backup chain*'
}
It "files copy jobs' worker sessions under the copy job, naming the source" {
$runs = Get-Runs $r.Reports 'Cloud Copy'
$runs.status | Sort-Object | Should -Be @('success', 'warning')
$runs.details.copied_from | Select-Object -Unique | Should -Be $fileServer
}
It "skips sessions outside the lookback window and from jobs it doesn't know" {
@($r.Reports | Where-Object run).Count | Should -Be 6
Get-Runs $r.Reports 'Old Exchange' | Should -BeNullOrEmpty
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url; $live.ApiKey = 'bdk_good'
$first = Invoke-Collector veeam -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector veeam -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'posts each report to /api/v1/ingest with the API key, as UTF-8 JSON' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 7
$firstRequests.Path | Select-Object -Unique | Should -Be '/api/v1/ingest'
$firstRequests.Auth | Select-Object -Unique | Should -Be 'Bearer bdk_good'
$firstRequests.ContentType | Select-Object -Unique | Should -Be 'application/json; charset=utf-8'
}
It 'keeps non-ASCII job names intact' {
$firstRequests.Payload.job.name | Should -Contain $fileServer
}
It 'remembers finished sessions, but not running ones' {
$first.State.Count | Should -Be 5
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 5
}
}
Context 'problems' {
It 'stops after the first job when the API key is rejected, exiting 1' {
$stub = Start-ApiStub
try {
$r = Invoke-Collector veeam -Config @{ ApiUrl = $stub.Url; ApiKey = 'bdk_bad'; Tenant = 'Acme' }
(Get-StubRequests $stub).Count | Should -Be 1
$r.ExitCode | Should -Be 1
$r.Log | Should -Contain '[ERROR] API key rejected; check ApiKey in the config. Stopping.'
} finally { Stop-ApiStub $stub }
}
It 'exits 2 when the config lacks a tenant' {
$r = Invoke-Collector veeam -Config @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' } -DryRun
$r.ExitCode | Should -Be 2
$r.Log[0] | Should -BeLike "*is missing 'Tenant'*"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
# Windows Server Backup collector, against collectors/wsb/tests/MockWsb.ps1 (shapes from a Server
# 2019 Hyper-V host: job times are culture-formatted local-time strings, HRESULTs are strings,
# VMs are nested under a VirtualMachineList item).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
$vms = @('AAOG-EDM-AD1', 'Host Component')
$hostComponentBytes = 447729
}
Describe 'Windows Server Backup collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector wsb -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
function At([double]$HoursAgo) { $byStart[(ConvertTo-ApiTime $now.AddHours(-$HoursAgo))] }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, with the next backup from Get-WBSummary' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'wsb:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Windows Server Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Windows Server Backup'
$next = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'reads job times as local time, and sends no run ID (WSB jobs have none; the API keys on start time)' {
(At 12).finished_at | Should -Be (ConvertTo-ApiTime $now.AddHours(-11.6))
$runs | ForEach-Object { $_.PSObject.Properties.Name | Should -Not -Contain 'external_id' }
}
It 'reports a Hyper-V backup with its VMs (nested in VirtualMachineList) and size' {
$run = At 12
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.details.items | Should -Be $vms
$run.size_bytes | Should -Be (124756706036 + $hostComponentBytes)
}
It "doesn't cite the failure log for a success (WSB fills it in regardless)" {
(At 12).message | Should -Not -BeLike '*Failure log*'
}
It 'reports a backup where one VM failed as a warning, naming the VM' {
$run = At 36
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*AAOG-EDM-AD1 - Access is denied.*'
$run.message | Should -BeLike '*Failure log: C:\Windows\Logs\WindowsServerBackup\Backup_Error.log*'
}
It 'reports a backup with an error HRESULT as failed, with the code in 0x8 form' {
$run = At 30
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*The backup storage location could not be found.*'
$run.message | Should -BeLike '*error 0x80042302*'
}
It 'reports a volume backup with its volumes and size' {
$run = At 20
$run.status | Should -Be 'success'
$run.details.items | Should -Be @('C:', 'D:')
$run.size_bytes | Should -Be (240GB)
}
It "ignores restores, old backups, and Get-WBJob's empty job when nothing is running" {
$runs.Count | Should -Be 4
}
It 'reads dd/MM dates correctly on servers with other regional settings' {
$gb = Invoke-Collector wsb -Config $config -DryRun -Culture 'en-GB'
($gb.Reports | ConvertTo-Json -Depth 6) | Should -Be ($r.Reports | ConvertTo-Json -Depth 6)
}
It 'reports a backup in progress as running, with no message' {
$running = Invoke-Collector wsb -Config $config -DryRun -MockArgs '-WithRunningJob'
$run = $running.Reports | Where-Object { $_.run.status -eq 'running' } | ForEach-Object run
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-10))
$run.message | Should -Be ''
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.details.items | Should -Be $vms
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector wsb -Config $live -MockArgs '-WithRunningJob'
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector wsb -Config $live -WorkDir $first.WorkDir -MockArgs '-RunningJobFinished'
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run, remembering the finished ones' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'sends the finished backup once it ends, with the same start time, so the API merges it into the running one' {
$running = $firstRequests.Payload.run | Where-Object status -EQ 'running'
$secondRequests.Count | Should -Be 1
$finished = $secondRequests[0].Payload.run
$finished.status | Should -Be 'success'
$finished.started_at | Should -Be $running.started_at
$second.State.Count | Should -Be 5
}
}
}