Files
Backup-Dashboard/collectors/tests/TestHelpers.psm1
T
kyleandClaude Opus 5.5 c5fe69a813 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]>
2026-10-08 13:36:27 -06:00

167 lines
7.1 KiB
PowerShell

<#
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