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:
@@ -138,6 +138,12 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
|
|||||||
# → http://localhost:8000
|
# → http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./collectors/tests/Invoke-CollectorTests.ps1
|
||||||
|
```
|
||||||
|
|
||||||
## Deployment (Linux, nginx)
|
## Deployment (Linux, nginx)
|
||||||
|
|
||||||
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
|
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||||
|
|
||||||
# Fixed to the top of the hour, and IDs derived from times, so repeated runs see the same jobs
|
# Fixed to the top of the hour, and IDs derived from times, so repeated runs see the same jobs
|
||||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||||
|
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||||
$notSet = [datetime]::new(1601, 1, 1)
|
$notSet = [datetime]::new(1601, 1, 1)
|
||||||
function New-StableGuid([string]$Seed) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||||
|
|
||||||
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
|
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
|
||||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||||
|
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||||
function New-StableGuid([string]$Seed) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
|
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
|
||||||
# jobs and sessions, like a real server
|
# jobs and sessions, like a real server
|
||||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||||
|
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||||
function New-StableGuid([string]$Seed) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
#
|
#
|
||||||
# . ./tests/MockWsb.ps1 # nothing running
|
# . ./tests/MockWsb.ps1 # nothing running
|
||||||
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
|
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
|
||||||
|
# . ./tests/MockWsb.ps1 -RunningJobFinished # that same backup, now finished
|
||||||
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
||||||
param([switch]$WithRunningJob)
|
param([switch]$WithRunningJob, [switch]$RunningJobFinished)
|
||||||
|
|
||||||
# Fixed to the top of the hour so repeated runs see the same jobs
|
# Fixed to the top of the hour so repeated runs see the same jobs
|
||||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
|
||||||
|
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
|
||||||
|
|
||||||
# Same format as the real cmdlet: short date + short time in the current culture
|
# Same format as the real cmdlet: short date + short time in the current culture
|
||||||
function Format-WbTime($Value) {
|
function Format-WbTime($Value) {
|
||||||
@@ -83,6 +85,9 @@ $global:MockWsbPrevious = @(
|
|||||||
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
|
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
|
||||||
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
|
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
|
||||||
)
|
)
|
||||||
|
if ($RunningJobFinished) {
|
||||||
|
$global:MockWsbPrevious += New-MockJob 'Backup' $now.AddMinutes(-10) $now.AddMinutes(-2) 'Completed' -Items @(New-VmList 10GB)
|
||||||
|
}
|
||||||
|
|
||||||
$global:MockWsbSummary = [pscustomobject]@{
|
$global:MockWsbSummary = [pscustomobject]@{
|
||||||
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
||||||
|
|||||||
Reference in New Issue
Block a user