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]>
167 lines
7.1 KiB
PowerShell
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
|