diff --git a/README.md b/README.md index eac2eda..9916f15 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload # → http://localhost:8000 ``` +Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)): + +```powershell +./collectors/tests/Invoke-CollectorTests.ps1 +``` + ## 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`. diff --git a/collectors/mars/tests/MockMars.ps1 b/collectors/mars/tests/MockMars.ps1 index 4578ced..9858221 100644 --- a/collectors/mars/tests/MockMars.ps1 +++ b/collectors/mars/tests/MockMars.ps1 @@ -7,7 +7,8 @@ # ./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 -$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) function New-StableGuid([string]$Seed) { [guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed))) diff --git a/collectors/tests/Installers.Tests.ps1 b/collectors/tests/Installers.Tests.ps1 new file mode 100644 index 0000000..c0f58a9 --- /dev/null +++ b/collectors/tests/Installers.Tests.ps1 @@ -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" + } + } +} diff --git a/collectors/tests/Invoke-CollectorTests.ps1 b/collectors/tests/Invoke-CollectorTests.ps1 new file mode 100644 index 0000000..1e774fb --- /dev/null +++ b/collectors/tests/Invoke-CollectorTests.ps1 @@ -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 }) diff --git a/collectors/tests/Mars.Tests.ps1 b/collectors/tests/Mars.Tests.ps1 new file mode 100644 index 0000000..9b825f4 --- /dev/null +++ b/collectors/tests/Mars.Tests.ps1 @@ -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' + } + } +} diff --git a/collectors/tests/Module.Tests.ps1 b/collectors/tests/Module.Tests.ps1 new file mode 100644 index 0000000..e9a8d4e --- /dev/null +++ b/collectors/tests/Module.Tests.ps1 @@ -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 + } +} diff --git a/collectors/tests/README.md b/collectors/tests/README.md new file mode 100644 index 0000000..2b7e7f5 --- /dev/null +++ b/collectors/tests/README.md @@ -0,0 +1,43 @@ +# Collector tests + +Pester tests for the collectors, the shared module and the installers. Each collector runs against +its mock (`collectors//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//tests/Mock.ps1` from the product's diagnostic output, taking + its time from `$env:MOCK_NOW` like the others. +2. Add `` to `Invoke-Collector`'s `ValidateSet` in `TestHelpers.psm1`. +3. Add `.Tests.ps1`, one `It` per behaviour confirmed on a real server. diff --git a/collectors/tests/TestHelpers.psm1 b/collectors/tests/TestHelpers.psm1 new file mode 100644 index 0000000..5f75ba7 --- /dev/null +++ b/collectors/tests/TestHelpers.psm1 @@ -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 diff --git a/collectors/tests/Vb365.Tests.ps1 b/collectors/tests/Vb365.Tests.ps1 new file mode 100644 index 0000000..bcece00 --- /dev/null +++ b/collectors/tests/Vb365.Tests.ps1 @@ -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 reception@bluewater.ca 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 + } + } +} diff --git a/collectors/tests/Veeam.Tests.ps1 b/collectors/tests/Veeam.Tests.ps1 new file mode 100644 index 0000000..7386140 --- /dev/null +++ b/collectors/tests/Veeam.Tests.ps1 @@ -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'*" + } + } +} diff --git a/collectors/tests/Wsb.Tests.ps1 b/collectors/tests/Wsb.Tests.ps1 new file mode 100644 index 0000000..82e69b7 --- /dev/null +++ b/collectors/tests/Wsb.Tests.ps1 @@ -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 + } + } +} diff --git a/collectors/vb365/tests/MockVb365.ps1 b/collectors/vb365/tests/MockVb365.ps1 index 5b3446d..e237df2 100644 --- a/collectors/vb365/tests/MockVb365.ps1 +++ b/collectors/vb365/tests/MockVb365.ps1 @@ -8,7 +8,8 @@ # ./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 -$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) { [guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed))) } diff --git a/collectors/veeam/tests/MockVeeam.ps1 b/collectors/veeam/tests/MockVeeam.ps1 index 855ba9b..3a1e092 100644 --- a/collectors/veeam/tests/MockVeeam.ps1 +++ b/collectors/veeam/tests/MockVeeam.ps1 @@ -6,7 +6,8 @@ # 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 -$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) { [guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed))) } diff --git a/collectors/wsb/tests/MockWsb.ps1 b/collectors/wsb/tests/MockWsb.ps1 index cc18374..164d907 100644 --- a/collectors/wsb/tests/MockWsb.ps1 +++ b/collectors/wsb/tests/MockWsb.ps1 @@ -6,11 +6,13 @@ # # . ./tests/MockWsb.ps1 # nothing running # . ./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 -param([switch]$WithRunningJob) +param([switch]$WithRunningJob, [switch]$RunningJobFinished) # 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 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 '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]@{ NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))