Files
AutopilotMonitor/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1
T
Oliver Kieselbach f44e8aadb0 Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.

- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
  deferred SYSTEM action, started in System32, reads the copy once, checks our signature
  and the publisher pin on those bytes and runs those bytes from memory, so a file a user
  planted at that path leads to nothing and no handle keeps the installer from removing
  the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
  agent-present branch (install and skip clear the base already) and an agent folder that
  is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
  in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
  asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
  read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
  agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
  directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
  marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:01:15 +02:00

1307 lines
59 KiB
PowerShell

# Pester tests (Pester 5+) for Install-AutopilotMonitor.ps1.
#
# The script under test is dot-sourced; its entry guard prevents the bootstrap
# from executing, so loading it here is side-effect free. External state (WMI,
# registry, scheduled tasks, WinRT) is mocked; folders, files and junctions are real,
# under Pester's $TestDrive.
#
# Folders created here belong to the test account. Run locally without elevation, that
# account may not make Administrators the owner, and its folders are never "ours". Tests
# that need folders we own mock Test-IsOwnedByUs and New-ProtectedDirectorySecurity.
#
# Run under Windows PowerShell 5.1 to match the IME runtime:
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
#
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
BeforeAll {
$script:InstallerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
. $script:InstallerPath
function New-FakeSignature {
param([string]$Status, [string]$Subject)
$cert = if ($Subject) {
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
} else { $null }
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
}
function New-FakePayload {
param([string[]]$Files)
$dir = Join-Path $TestDrive ([guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $dir -Force | Out-Null
foreach ($f in $Files) { Set-Content -Path (Join-Path $dir $f) -Value 'x' }
return $dir
}
# What Test-FileCatalog -Detailed returns, reduced to the fields the check reads.
function New-FakeCatalogResult {
param(
[string]$Status = 'Valid',
[object]$Signature,
[hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' },
[hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }
)
return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems }
}
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
New-Item -ItemType Directory -Path $p -Force | Out-Null
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
return $p
}
# A ZIP with exactly these entry names, for the containment checks.
function New-ZipWithEntries {
param([string]$Path, [string[]]$Names)
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($name in $Names) {
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
}
} finally { $archive.Dispose() }
}
function New-CategoryError {
param([System.Management.Automation.ErrorCategory]$Category)
return New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception 'simulated'), 'Simulated', $Category, $null)
}
# Stand-in for the protected DACL: protected like the real one, but full control for the
# test account and no owner change, so a non-elevated run can create and clean it up.
function New-TestDirectorySecurity {
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetAccessRuleProtection($true, $false)
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')))
return $security
}
function New-TestFolder {
param([string]$Name)
$path = Join-Path $TestDrive ("$Name-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $path -Force | Out-Null
return $path
}
# A directory junction pointing at a folder with a sentinel file: whatever the code under
# test does, the sentinel must survive.
function New-SentinelJunction {
param([string]$Link)
$target = New-TestFolder 'junction-target'
Set-Content -Path (Join-Path $target 'sentinel.txt') -Value 'must survive'
cmd.exe /c mklink /J "$Link" "$target" | Out-Null
return $target
}
function Get-ErrorRecords {
param([scriptblock]$Script)
return @(& $Script 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
}
}
Describe 'Test-IsCloudPc' {
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
# the Windows365 registry key AND the CloudManagedDesktopExtension service
# (both required).
BeforeEach {
Mock Write-Log { }
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
}
It 'detects a Cloud PC when both markers are present' {
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect with the service alone' {
Mock Test-RegistryKey64 { $false }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect a plain device (no markers)' {
Mock Test-RegistryKey64 { $false }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'still evaluates markers when the WMI identity query fails' {
Mock Get-CimInstance { throw 'RPC server unavailable' }
Mock Test-RegistryKey64 { $true } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
}
Describe 'Get-OobeState' {
It 'returns a non-empty state string and never throws' {
Get-OobeState | Should -Not -BeNullOrEmpty
}
}
Describe 'Get-RegistryValue64 and Test-RegistryKey64' {
# The guards must read what the 64-bit agent wrote, also from a 32-bit host. These run
# against keys every Windows has.
It 'reads a value from the 64-bit view' {
Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'CurrentBuild' | Should -Not -BeNullOrEmpty
}
# ProgramFilesDir is "Program Files" in the 64-bit view and "Program Files (x86)" in the
# 32-bit one; Intune starts scripts in a 32-bit host by default.
It 'reads the 64-bit view from a 32-bit host' {
$host32 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
$command = ". '{0}'; [Environment]::Is64BitProcess; Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion' -Name 'ProgramFilesDir'" -f $script:InstallerPath
$out = @(& $host32 -NoProfile -ExecutionPolicy Bypass -Command $command)
$out[0] | Should -Be 'False'
$out[1] | Should -Be $env:ProgramW6432
}
It 'returns null for a missing key or value' {
Get-RegistryValue64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' -Name 'Deployed' | Should -BeNullOrEmpty
Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'NoSuchValue' | Should -BeNullOrEmpty
}
It 'tells an existing key from a missing one' {
Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows NT\CurrentVersion' | Should -BeTrue
Test-RegistryKey64 -SubKey 'SOFTWARE\AutopilotMonitor-NoSuchKey' | Should -BeFalse
}
}
Describe 'Get-RealUserProfilePaths' {
BeforeEach {
Mock Write-Log { }
}
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
Mock Get-CimInstance {
@(
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
)
}
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\UserOne')
}
It 'falls back to the filesystem view when the WMI query fails' {
Mock Get-CimInstance { throw 'WMI broken' }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
[pscustomobject]@{ FullName = 'C:\Users\Default' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\SomeUser')
}
It 'returns an empty result when only special/system profiles exist' {
Mock Get-CimInstance { @() }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
)
}
@(Get-RealUserProfilePaths).Count | Should -Be 0
}
}
Describe 'Get-RelaxDecision' {
BeforeEach {
Mock Write-Log { }
# Defaults: physical device, OOBE long done. Individual tests override.
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
}
Context 'Windows 365 Cloud PC' {
BeforeEach {
Mock Test-IsCloudPc { $true }
}
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
$p = New-FakeProfile 'CloudPcUser' 0.6
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
$d.IsCloudPc | Should -BeTrue
}
It 'does not relax for an old profile (productive Cloud PC)' {
$p = New-FakeProfile 'CloudPcUser' 30
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'does not relax for two profiles' {
$p1 = New-FakeProfile 'UserA' 2
$p2 = New-FakeProfile 'UserB' 400
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
Mock Get-OobeState { 'Unavailable' }
$p = New-FakeProfile 'CpcUser' 1
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
}
It 'does not relax for a profile with a future creation time (clock skew)' {
$p = New-FakeProfile 'SkewUser' -10
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
Context 'OOBE InProgress (Windows Backup for Organizations)' {
It 'relaxes for a single fresh profile during OOBE' {
Mock Get-OobeState { 'InProgress' }
$p = New-FakeProfile 'RestoreUser' 3
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
}
}
Context 'no trigger' {
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
$p = New-FakeProfile 'SomeUser' 2
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'stays inactive with no profiles at all' {
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
}
Describe 'Get-BootstrapDecision' {
BeforeEach {
Mock Write-Log { }
# Baseline: clean device during Device ESP -- no marker, no profiles, no
# logged-on user, 1h uptime. Tests override selectively.
Mock Get-RegistryValue64 { $null }
Mock Get-RealUserProfilePaths { @() }
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
}
It 'installs on a clean ESP device' {
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeTrue
$d.ReasonCode | Should -BeNullOrEmpty
$d.RelaxActive | Should -BeFalse
}
It 'skips when the Deployed marker exists, read through the 64-bit view (guard 1)' {
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'AlreadyDeployed'
}
It 'skips a productive device with an old profile (guard 2)' {
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'ProfilesFound'
}
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
# First-connect user is already visible in LogonUI on W365 -- must not block either.
Mock Get-RegistryValue64 { 'AzureAD\CloudPcUser' } -ParameterFilter { $SubKey -like '*LogonUI*' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'skips when a real user has logged on and no relax applies, read through the 64-bit view (guard 3)' {
Mock Get-RegistryValue64 { 'AzureAD\SomeUser' } -ParameterFilter { $SubKey -eq 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -and $Name -eq 'LastLoggedOnUser' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
}
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
Mock Get-RegistryValue64 { 'defaultuser0' } -ParameterFilter { $SubKey -like '*LogonUI*' }
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue
}
It 'treats an unreadable marker as absent for the decision (the agent check decides fail-safe)' {
Mock Get-RegistryValue64 { throw 'access denied' }
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15).Install | Should -BeTrue
}
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
# W365 reality: provisioned days ago, running headless ever since; the user's
# first connect just created the profile. Uptime is meaningless here.
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Get-OobeState { 'InProgress' }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
}
Describe 'logging' {
# Regression guard: Write-Log runs inside functions that RETURN decision
# objects. If it ever emits to the output stream again (Write-Output), the
# log lines leak into the return values and turn them into arrays. These
# tests deliberately do NOT mock Write-Log.
BeforeEach {
# Plain local assignments: PowerShell's dynamic scoping makes Write-Log resolve these
# instead of the ProgramData path and the state defined by the dot-sourced script.
$LogFile = Join-Path $TestDrive "bootstrap-$([guid]::NewGuid().ToString('N')).log"
$LogState = [pscustomobject]@{ Ready = $false; Pending = (New-Object System.Collections.Generic.List[string]) }
}
It 'Get-BootstrapDecision returns exactly one object with real logging' {
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' }
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15)
$d.Count | Should -Be 1
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
}
It 'keeps lines in memory until the log file is opened, then writes them first' {
Write-Log 'before the folder exists'
Test-Path $LogFile | Should -BeFalse
$LogState.Pending.Count | Should -Be 1
Open-LogFile
Write-Log 'after'
$lines = @(Get-Content $LogFile)
$lines.Count | Should -Be 2
$lines[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] before the folder exists$'
$lines[1] | Should -Match 'after$'
$LogState.Pending.Count | Should -Be 0
}
It 'writes the version line in the format the agent reads' {
Write-Log "Bootstrap script version: v$ScriptVersion"
Open-LogFile
Get-Content $LogFile -Raw | Should -Match 'Bootstrap script version: v\d+(\.\d+){0,3}\b'
}
}
Describe 'Get-AgentPresence' {
BeforeAll {
# Get-ScheduledTask is a CDXML function: a module-qualified call inside its mock still
# reaches the mock, the command object does not.
$script:realGetScheduledTask = Get-Command -Name Get-ScheduledTask -Module ScheduledTasks
}
BeforeEach {
Mock Start-Sleep { }
Mock Get-RegistryValue64 { $null }
Mock Get-ScheduledTask { throw (New-CategoryError -Category ObjectNotFound) }
$AgentBinPath = Join-Path $TestDrive "AgentBin-$([guid]::NewGuid().ToString('N'))"
}
It 'finds no agent when marker, executable and task are all absent' {
Get-AgentPresence | Should -BeNullOrEmpty
}
It 'names the deployment marker' {
Mock Get-RegistryValue64 { '2026-08-01T10:00:00Z' } -ParameterFilter { $SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed' }
Get-AgentPresence | Should -Be 'deployment marker'
}
It 'names the agent executable' {
New-Item -ItemType Directory -Path $AgentBinPath -Force | Out-Null
Set-Content -Path (Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
Get-AgentPresence | Should -Be 'agent executable'
}
It 'names the agent task' {
Mock Get-ScheduledTask { [pscustomobject]@{ TaskName = 'AutopilotMonitor-Agent' } }
Get-AgentPresence | Should -Be 'agent task'
}
It 'counts an unreadable marker as present' {
Mock Get-RegistryValue64 { throw 'Requested registry access is not allowed.' }
Get-AgentPresence | Should -BeLike 'deployment marker unreadable*'
}
It 'counts an agent executable it cannot check as present' {
Mock Get-Item { throw (New-CategoryError -Category PermissionDenied) } -ParameterFilter { $LiteralPath -like '*AutopilotMonitor.Agent.exe' }
Get-AgentPresence | Should -BeLike 'agent executable unreadable*'
}
It 'counts a task query that fails for another reason as present, after one retry' {
Mock Get-ScheduledTask { throw (New-CategoryError -Category ResourceUnavailable) }
Get-AgentPresence | Should -BeLike 'agent task unreadable*'
Should -Invoke Get-ScheduledTask -Times 2 -Exactly
}
It 'decides "no task" by the error category, not the message' {
# The real cmdlet on a name that cannot exist: its message is localized, its category is not.
Mock Get-ScheduledTask { & $script:realGetScheduledTask -TaskName "AutopilotMonitor-NoSuchTask-$([guid]::NewGuid().ToString('N'))" -ErrorAction Stop }
Get-AgentPresence | Should -BeNullOrEmpty
}
}
Describe 'Test-IsOwnedByUs' {
It 'accepts SYSTEM as owner' {
Test-IsOwnedByUs $env:ProgramData | Should -BeTrue
}
It 'refuses another owner (TrustedInstaller owns the Windows folder)' {
Test-IsOwnedByUs $env:SystemRoot | Should -BeFalse
}
It 'refuses what it cannot read' {
Test-IsOwnedByUs (Join-Path $TestDrive 'does-not-exist') | Should -BeFalse
}
}
Describe 'Test-IsReparsePoint' {
It 'tells a junction from a folder' {
$folder = New-TestFolder 'plain'
$link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))"
$null = New-SentinelJunction -Link $link
Test-IsReparsePoint $folder | Should -BeFalse
Test-IsReparsePoint $link | Should -BeTrue
}
}
Describe 'Get-FolderState' {
It 'reports a missing folder, also below a missing parent' {
Get-FolderState (Join-Path $TestDrive 'nope') | Should -Be 'Missing'
Get-FolderState (Join-Path $TestDrive 'nope\deeper') | Should -Be 'Missing'
}
It 'reports a file where the folder should be' {
$file = Join-Path $TestDrive "file-$([guid]::NewGuid().ToString('N'))"
Set-Content -Path $file -Value 'x'
Get-FolderState $file | Should -Be 'File'
}
It 'reports a link without judging its target' {
Mock Test-IsOwnedByUs { $true }
$link = Join-Path $TestDrive "link-$([guid]::NewGuid().ToString('N'))"
$null = New-SentinelJunction -Link $link
Get-FolderState $link | Should -Be 'Link'
}
It 'reports a folder by its owner' {
$folder = New-TestFolder 'owned'
Mock Test-IsOwnedByUs { $true }
Get-FolderState $folder | Should -Be 'Ours'
Mock Test-IsOwnedByUs { $false }
Get-FolderState $folder | Should -Be 'Foreign'
}
}
Describe 'New-RunFolderName' {
It 'is 8 lowercase hex characters and changes on every call' {
$a = New-RunFolderName
$b = New-RunFolderName
$a | Should -Match '^[0-9a-f]{8}$'
$b | Should -Match '^[0-9a-f]{8}$'
$a | Should -Not -Be $b
}
}
Describe 'New-RunFolder' {
BeforeEach {
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:parent = New-TestFolder 'runs'
}
It 'creates prefix plus 8 hex characters with the protected DACL' {
$path = New-RunFolder -Parent $script:parent -Prefix 'Agent-Install-'
Split-Path $path -Leaf | Should -Match '^Agent-Install-[0-9a-f]{8}$'
(Get-Acl -LiteralPath $path).AreAccessRulesProtected | Should -BeTrue
Should -Invoke New-ProtectedDirectorySecurity -Times 1 -Exactly
}
It 'never uses an existing name' {
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('aaaaaaaa', 'bbbbbbbb'))
Mock New-RunFolderName { $script:names.Dequeue() }
$taken = Join-Path $script:parent 'Loader-aaaaaaaa'
New-Item -ItemType Directory -Path $taken | Out-Null
Set-Content -Path (Join-Path $taken 'planted.txt') -Value 'x'
$path = New-RunFolder -Parent $script:parent -Prefix 'Loader-'
Split-Path $path -Leaf | Should -Be 'Loader-bbbbbbbb'
@(Get-ChildItem -LiteralPath $path -Force).Count | Should -Be 0
}
It 'never uses a junction planted at the name' {
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('cccccccc', 'dddddddd'))
Mock New-RunFolderName { $script:names.Dequeue() }
$target = New-SentinelJunction -Link (Join-Path $script:parent 'Loader-cccccccc')
$path = New-RunFolder -Parent $script:parent -Prefix 'Loader-'
Split-Path $path -Leaf | Should -Be 'Loader-dddddddd'
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'gives up after three names that are not ours' {
Mock Test-IsOwnedByUs { $false }
{ New-RunFolder -Parent $script:parent -Prefix 'Loader-' } | Should -Throw '*Could not create a run folder*'
}
}
Describe 'Remove-OwnTree' {
BeforeEach {
Mock Start-Sleep { }
Mock Test-IsOwnedByUs { $true }
$script:root = New-TestFolder 'tree'
}
It 'removes an own tree with whatever is in it, also files no version knows yet' {
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs\deeper') -Force | Out-Null
Set-Content -Path (Join-Path $script:root 'Logs\bootstrap-script.log') -Value 'x'
Set-Content -Path (Join-Path $script:root 'Logs\deeper\future-file.dat') -Value 'x'
Set-Content -Path (Join-Path $script:root 'Install-AutopilotMonitor.ps1') -Value 'x'
$left = Remove-OwnTree -Path $script:root
Test-Path $script:root | Should -BeFalse
$left.Foreign.Count | Should -Be 0
$left.Locked.Count | Should -Be 0
}
It 'removes a junction as a link and never follows it, at the top and deep in the tree' {
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
$top = New-SentinelJunction -Link (Join-Path $script:root 'Agent')
$deep = New-SentinelJunction -Link (Join-Path $script:root 'Logs\loop')
$left = Remove-OwnTree -Path $script:root
Test-Path $script:root | Should -BeFalse
Test-Path (Join-Path $top 'sentinel.txt') | Should -BeTrue
Test-Path (Join-Path $deep 'sentinel.txt') | Should -BeTrue
$left.Locked.Count | Should -Be 0
}
It 'survives a junction that points back at the tree itself' {
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
cmd.exe /c mklink /J "$(Join-Path $script:root 'Logs\cycle')" "$script:root" | Out-Null
$left = Remove-OwnTree -Path $script:root
Test-Path $script:root | Should -BeFalse
$left.Locked.Count | Should -Be 0
}
It 'leaves another account''s entries alone, and with them their folder' {
New-Item -ItemType Directory -Path (Join-Path $script:root 'Logs') -Force | Out-Null
Set-Content -Path (Join-Path $script:root 'Logs\own.log') -Value 'x'
Set-Content -Path (Join-Path $script:root 'Logs\foreign-planted.dll') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
$left = Remove-OwnTree -Path $script:root
Test-Path (Join-Path $script:root 'Logs\foreign-planted.dll') | Should -BeTrue
Test-Path (Join-Path $script:root 'Logs\own.log') | Should -BeFalse
$left.Foreign | Should -Be @((Join-Path $script:root 'Logs\foreign-planted.dll'))
}
It 'removes a read-only file' {
$file = Join-Path $script:root 'readonly.txt'
Set-Content -Path $file -Value 'x'
Set-ItemProperty -Path $file -Name IsReadOnly -Value $true
$null = Remove-OwnTree -Path $script:root
Test-Path $script:root | Should -BeFalse
}
It 'reports a file in use, keeps going, and writes nothing to the error stream' {
$locked = Join-Path $script:root 'in-use.log'
Set-Content -Path $locked -Value 'x'
Set-Content -Path (Join-Path $script:root 'other.txt') -Value 'x'
$handle = [System.IO.File]::Open($locked, 'Open', 'Read', 'None')
try {
$errors = Get-ErrorRecords { $script:left = Remove-OwnTree -Path $script:root }
} finally {
$handle.Dispose()
}
$errors.Count | Should -Be 0
$script:left.Locked | Should -Contain $locked
Test-Path (Join-Path $script:root 'other.txt') | Should -BeFalse
Should -Invoke Start-Sleep -Times 2 -Exactly
}
It 'keeps the root folder when asked to' {
Set-Content -Path (Join-Path $script:root 'x.txt') -Value 'x'
$null = Remove-OwnTree -Path $script:root -KeepRoot
Test-Path $script:root | Should -BeTrue
@(Get-ChildItem -LiteralPath $script:root -Force).Count | Should -Be 0
}
}
Describe 'Initialize-AgentBase' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
}
It 'creates a missing folder with the protected DACL' {
Initialize-AgentBase -Path $script:base
Test-Path $script:base | Should -BeTrue
(Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue
}
It 'protects and empties an existing folder of ours, keeping the folder' {
New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-script.log') -Value 'old'
Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'old'
Initialize-AgentBase -Path $script:base
Test-Path $script:base | Should -BeTrue
@(Get-ChildItem -LiteralPath $script:base -Force).Count | Should -Be 0
(Get-Acl -LiteralPath $script:base).AreAccessRulesProtected | Should -BeTrue
}
It 'refuses when another account''s entries remain' {
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'foreign-planted.dll') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*holds entries another account created*'
Test-Path (Join-Path $script:base 'foreign-planted.dll') | Should -BeTrue
}
It 'refuses a folder another account owns, without touching it' {
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x'
Mock Test-IsOwnedByUs { $false }
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*not a folder that SYSTEM or administrators created (Foreign)*'
Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue
}
It 'refuses a link and leaves its target alone' {
$target = New-SentinelJunction -Link $script:base
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*(Link)*'
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'refuses a file' {
Set-Content -Path $script:base -Value 'x'
{ Initialize-AgentBase -Path $script:base } | Should -Throw '*(File)*'
}
}
Describe 'Remove-AgentBaseOnSkip' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:base = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
}
It 'has nothing to do when the folder does not exist' {
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*does not exist*'
}
It 'removes a folder of ours with everything a bootstrap left in it' {
New-Item -ItemType Directory -Path (Join-Path $script:base 'Agent') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:base 'Msi') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:base 'Logs') -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'Logs\bootstrap-loader.log') -Value 'x'
Set-Content -Path (Join-Path $script:base 'Msi\Start-AutopilotMonitor.ps1') -Value 'x'
Set-Content -Path (Join-Path $script:base 'Install-AutopilotMonitor.ps1') -Value 'x'
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*removed.'
Test-Path $script:base | Should -BeFalse
}
It 'keeps the folder when another account''s entry is in it, and says so' {
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'foreign-planted.txt') -Value 'x'
Set-Content -Path (Join-Path $script:base 'own.txt') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*foreign-*' }
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*kept: 1 entries of another account*'
Test-Path (Join-Path $script:base 'foreign-planted.txt') | Should -BeTrue
Test-Path (Join-Path $script:base 'own.txt') | Should -BeFalse
}
It 'leaves a folder another account owns as it is' {
New-Item -ItemType Directory -Path $script:base -Force | Out-Null
Set-Content -Path (Join-Path $script:base 'theirs.txt') -Value 'x'
Mock Test-IsOwnedByUs { $false }
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Foreign)*'
Test-Path (Join-Path $script:base 'theirs.txt') | Should -BeTrue
}
It 'leaves a link and its target as they are' {
$target = New-SentinelJunction -Link $script:base
Remove-AgentBaseOnSkip -Path $script:base | Should -BeLike '*left as it is (Link)*'
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-AgentBaseOnSkip -Path $script:base } | Should -Not -Throw
}
}
Describe 'Remove-MsiTransportFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
}
It 'has nothing to do without an agent folder or without the copy' {
Remove-MsiTransportFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
}
It 'removes the copy and keeps an agent folder that holds anything else' {
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
Test-Path $MsiTransportPath | Should -BeFalse
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
}
It 'removes the copy and with it an agent folder that is then empty' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
Test-Path $AgentBasePath | Should -BeFalse
}
It 'leaves a copy that is not ours, and says so' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'touches nothing below an agent folder that is not ours' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $false }
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'never follows a link planted as the copy' {
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
$target = New-SentinelJunction -Link $MsiTransportPath
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-MsiTransportFolder } | Should -Not -Throw
}
}
Describe 'Remove-LegacyMsiLoaderFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:realProgramW6432 = $env:ProgramW6432
$script:realProgramFiles = $env:ProgramFiles
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
}
AfterEach {
$env:ProgramW6432 = $script:realProgramW6432
$env:ProgramFiles = $script:realProgramFiles
}
It 'has nothing to do where MSI 1.3 never ran' {
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
Test-Path $script:company | Should -BeTrue
}
It 'removes what MSI 1.3 installed, and the company folder with it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
Test-Path $script:company | Should -BeFalse
}
It 'keeps the company folder when anything else is in it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
Test-Path (Split-Path $script:loader) | Should -BeFalse
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
}
It 'reads the 64-bit Program Files, also from the 32-bit host' {
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
New-Item -ItemType File -Path $script:loader -Force | Out-Null
New-Item -ItemType File -Path $other -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
Test-Path $script:company | Should -BeFalse
Test-Path $other | Should -BeTrue
}
It 'leaves a folder that is not ours' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Mock Test-IsOwnedByUs { $false }
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
Test-Path $script:loader | Should -BeTrue
}
It 'never follows a link' {
$target = New-SentinelJunction -Link $script:company
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
}
}
Describe 'Get-AgentPayloadRejections' {
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
# the branches; one test runs the real cmdlet over a real (unsigned) catalog.
BeforeEach {
Mock Write-Log { }
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
$script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat')
}
It 'accepts a payload whose catalog is signed by us and matches every file' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) }
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty
}
It 'asks for the detailed result and skips the catalog itself' {
Mock Test-FileCatalog {
$script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path }
New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours)
}
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null
$script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat')
$script:call.Detailed | Should -BeTrue
$script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat')
$script:call.Path | Should -Be $script:dir
}
It 'refuses a payload without catalog and does not run the check at all' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Test-FileCatalog { throw 'must not run' }
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*'
Should -Invoke Test-FileCatalog -Times 0
}
It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' {
Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*signature status NotSigned*'
}
It 'refuses a catalog whose signature does not validate' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) }
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1
}
It 'refuses a validly signed catalog from another publisher' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*does not match*'
}
It 'names the missing, extra and changed files of a payload that differs' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) `
-CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } `
-PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' }
}
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*missing: c.config;*'
$r[0] | Should -BeLike '*extra: evil.dll;*'
$r[0] | Should -BeLike '*changed: b.dll)*'
}
It 'reports a bad signature and a differing payload together' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) `
-PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' }
}
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2
}
It 'refuses when the catalog check itself throws' {
Mock Test-FileCatalog { throw 'access denied' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*failed to run*access denied*'
}
It 'refuses when the catalog check returns nothing' {
Mock Test-FileCatalog { }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r[0] | Should -BeLike '*returned nothing*'
}
It 'returns a single rejection as an array' {
Mock Test-FileCatalog { throw 'boom' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
($r -is [array]) | Should -BeTrue
}
It 'catches an extra file with the real cmdlet over a real catalog' {
# Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock.
$dir = New-FakePayload -Files @('a.txt', 'b.txt')
$catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat"
New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat')
Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted'
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
($r -join ' | ') | Should -BeLike '*extra: extra.txt;*'
($r -join ' | ') | Should -BeLike '*signature status NotSigned*'
}
}
Describe 'Install-VerifiedAgentPayload' {
BeforeEach {
Mock Start-Sleep { }
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
$script:staging = New-TestFolder 'Agent-Install'
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
}
It 'moves an accepted payload in as the agent folder' {
Mock Get-AgentPayloadRejections { , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
Test-Path (Join-Path $script:staging 'Agent') | Should -BeFalse
}
It 'checks the extracted payload in the run folder, never the agent folder' {
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
$script:checked | Should -Be (Join-Path $script:staging 'Agent')
}
It 'refuses a payload that fails the check and creates no agent folder' {
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
Should -Throw '*Payload verification FAILED*evil.dll*'
Test-Path $script:target | Should -BeFalse
}
It 'never merges into an agent folder that is already there' {
New-Item -ItemType Directory -Path $script:target | Out-Null
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
Mock Get-AgentPayloadRejections { , @() }
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } | Should -Throw
@(Get-ChildItem $script:target | ForEach-Object Name) | Should -Be @('stale.dll')
}
}
Describe 'Expand-AgentPayload' {
BeforeEach {
$script:zip = Join-Path $TestDrive "package-$([guid]::NewGuid().ToString('N')).zip"
$script:area = New-TestFolder 'area'
$script:destination = Join-Path $script:area 'Agent'
}
It 'extracts files and subfolders into the destination' {
New-ZipWithEntries -Path $script:zip -Names @('AutopilotMonitor.Agent.exe', 'sub/inner.dll', 'a..b.txt')
Expand-AgentPayload -ZipPath $script:zip -Destination $script:destination
$extracted = @(Get-ChildItem -LiteralPath $script:destination -Recurse -File | ForEach-Object { $_.FullName.Substring($script:destination.Length + 1) } | Sort-Object)
$extracted | Should -Be @(@('AutopilotMonitor.Agent.exe', 'sub\inner.dll', 'a..b.txt') | Sort-Object)
}
It 'refuses a package with an entry <Case>, writing nothing' -TestCases @(
@{ Case = 'above the destination'; Name = '..\outside.txt' }
@{ Case = 'climbing out of a subfolder'; Name = 'sub/../../outside.txt' }
@{ Case = 'with a rooted name'; Name = 'C:\outside.txt' }
@{ Case = 'with a leading separator'; Name = '/outside.txt' }
@{ Case = 'with a drive-relative name'; Name = 'C:outside.txt' }
) {
New-ZipWithEntries -Path $script:zip -Names @('AutopilotMonitor.Agent.exe', $Name)
{ Expand-AgentPayload -ZipPath $script:zip -Destination $script:destination } | Should -Throw '*outside the payload folder*'
Test-Path -LiteralPath $script:destination | Should -BeFalse
@(Get-ChildItem -LiteralPath $script:area -Recurse -Force).Count | Should -Be 0
}
}
Describe 'Invoke-AgentInstallMode' {
It 'returns only the exit code; the program''s output goes to the host' {
$r = @(Invoke-AgentInstallMode -ExePath "$env:SystemRoot\System32\cmd.exe" -Arguments @('/c', 'echo from-agent & exit 3'))
$r.Count | Should -Be 1
$r[0] | Should -Be 3
}
}
Describe 'Read-LoaderLogs' {
BeforeEach {
$script:realProgramData = $env:ProgramData
$env:ProgramData = New-TestFolder 'ProgramData'
$AgentLogPath = Join-Path $env:ProgramData 'AutopilotMonitor\Logs'
$LegacyInstallerPath = Join-Path $env:ProgramData 'AutopilotMonitor\Install-AutopilotMonitor.ps1'
}
AfterEach {
$env:ProgramData = $script:realProgramData
}
It 'takes nothing from a run folder that is not ours' {
Mock Test-IsOwnedByUs { $false }
$run = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
New-Item -ItemType Directory -Path $run | Out-Null
Set-Content -Path (Join-Path $run 'bootstrap-loader.log') -Value 'planted'
@(Read-LoaderLogs -ScriptPath (Join-Path $run 'Install-AutopilotMonitor.ps1') -ScriptRoot $run).Count | Should -Be 0
}
It 'takes the whole log of loader 1.3 or later from the run folder it started this script from' {
Mock Test-IsOwnedByUs { $true }
$run = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
New-Item -ItemType Directory -Path $run | Out-Null
Set-Content -Path (Join-Path $run 'bootstrap-msi.log') -Value '[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.3 started (user=SYSTEM, pid=1) ==='
$logs = @(Read-LoaderLogs -ScriptPath (Join-Path $run 'Install-AutopilotMonitor.ps1') -ScriptRoot $run)
$logs.Count | Should -Be 1
$logs[0].Name | Should -Be 'bootstrap-msi.log'
$logs[0].Text | Should -BeLike '*bootstrap loader v1.3 started*'
}
It 'takes only the last start line of an older loader, rebuilt from the pattern' {
New-Item -ItemType Directory -Path $AgentLogPath -Force | Out-Null
Set-Content -Path (Join-Path $AgentLogPath 'bootstrap-loader.log') -Value @(
'[2026-10-01 09:00:00] === Autopilot Monitor bootstrap loader v1.1 started (user=SYSTEM, pid=1) ==='
'anything else a user may have planted here'
'[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (user=SYSTEM, pid=2) ==='
'[2026-10-22 10:00:01] Signature accepted'
)
$logs = @(Read-LoaderLogs -ScriptPath $LegacyInstallerPath -ScriptRoot (Split-Path $LegacyInstallerPath))
$logs.Count | Should -Be 1
$logs[0].Name | Should -Be 'bootstrap-loader.log'
$logs[0].Text | Should -Be "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===`r`n"
}
It 'takes nothing when the script was assigned directly' {
$ime = New-TestFolder 'IMEScripts'
@(Read-LoaderLogs -ScriptPath (Join-Path $ime 'x.ps1') -ScriptRoot $ime).Count | Should -Be 0
}
}
Describe 'Remove-LegacyInstallerCopy' {
BeforeEach {
$LegacyInstallerPath = Join-Path (New-TestFolder 'Base') 'Install-AutopilotMonitor.ps1'
Set-Content -Path $LegacyInstallerPath -Value 'x'
}
It 'deletes this script when it runs from the path older loaders use, ignoring case' {
Remove-LegacyInstallerCopy -ScriptPath $LegacyInstallerPath.ToUpperInvariant()
Test-Path $LegacyInstallerPath | Should -BeFalse
}
It 'leaves every other path alone (IME copy, manual run)' {
$other = Join-Path $TestDrive 'Install-AutopilotMonitor.ps1'
Set-Content -Path $other -Value 'x'
Remove-LegacyInstallerCopy -ScriptPath $other
Test-Path $other | Should -BeTrue
Test-Path $LegacyInstallerPath | Should -BeTrue
}
It 'never throws' {
{ Remove-LegacyInstallerCopy -ScriptPath $null } | Should -Not -Throw
{ Remove-LegacyInstallerCopy -ScriptPath 'C:\does\not\exist.ps1' } | Should -Not -Throw
}
}
Describe 'Remove-StaleLoaderRunFolders' {
BeforeEach {
Mock Start-Sleep { }
$script:realProgramData = $env:ProgramData
$env:ProgramData = New-TestFolder 'ProgramData'
}
AfterEach {
$env:ProgramData = $script:realProgramData
}
It 'removes the run folders of earlier loader runs and keeps the current one' {
Mock Test-IsOwnedByUs { $true }
$current = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-11111111'
$stale = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-22222222'
New-Item -ItemType Directory -Path $current, $stale | Out-Null
Set-Content -Path (Join-Path $stale 'bootstrap-loader.log') -Value 'x'
[System.IO.Directory]::SetCreationTimeUtc($stale, [System.DateTime]::UtcNow.AddHours(-2))
Remove-StaleLoaderRunFolders -ScriptRoot $current
Test-Path $current | Should -BeTrue
Test-Path $stale | Should -BeFalse
}
It 'keeps a run folder younger than an hour: a loader may still be running there' {
Mock Test-IsOwnedByUs { $true }
$young = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-55555555'
New-Item -ItemType Directory -Path $young | Out-Null
Remove-StaleLoaderRunFolders -ScriptRoot $null
Test-Path $young | Should -BeTrue
}
It 'leaves alone what is not ours or is a link' {
Mock Test-IsOwnedByUs { $false }
$foreign = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-33333333'
New-Item -ItemType Directory -Path $foreign | Out-Null
$target = New-SentinelJunction -Link (Join-Path $env:ProgramData 'AutopilotMonitor-Loader-44444444')
Remove-StaleLoaderRunFolders -ScriptRoot $null
Test-Path $foreign | Should -BeTrue
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
}
Describe 'script text' {
BeforeAll {
$script:text = Get-Content -LiteralPath $script:InstallerPath -Raw
$tokens = $null
$parseErrors = $null
$script:ast = [System.Management.Automation.Language.Parser]::ParseFile($script:InstallerPath, [ref]$tokens, [ref]$parseErrors)
}
It 'declares the role and version the loader checks, one line each' {
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
}
It 'never stages anything in the shared temp folder' {
$script:text | Should -Not -Match 'env:TEMP'
}
# Whether every entry stays inside the payload folder is this script's own check.
It 'never extracts with Expand-Archive' {
$expands = $script:ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Expand-Archive' }, $true)
@($expands).Count | Should -Be 0
}
It 'never deletes recursively except through Remove-OwnTree' {
$removeItems = $script:ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -in @('Remove-Item', 'ri', 'rm', 'del', 'rd', 'rmdir', 'erase') }, $true)
@($removeItems).Count | Should -Be 0
$script:text | Should -Not -Match 'Delete\([^)]*\$true\)'
}
It 'carries each shared-block marker exactly once' {
([regex]::Matches($script:text, '### BEGIN shared install helpers')).Count | Should -Be 1
([regex]::Matches($script:text, '### END shared install helpers')).Count | Should -Be 1
}
}
Describe 'publisher pin' {
It 'pins the expected publisher by subject, not by thumbprint' {
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
}
It 'names the payload catalog the release pipeline signs' {
$AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat'
}
}