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