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