Files
AutopilotMonitor/scripts/Bootstrap/Install-AutopilotMonitor.Tests.ps1
T
Oliver Kieselbach 40323ca57e Bootstrap hardening after a security review, and the MSI channel installs again (D-377)
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.

- Installer 2.8 extracts the agent ZIP with its own containment check
  (Expand-AgentPayload): every entry is resolved against the payload folder
  before the first write, a rooted or escaping name refuses the package. The
  inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
  (measured), so the finding was not exploitable as reported; the guarantee is
  now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
  (Set-Location): on the MSI channel the location pointed at the deleted Msi
  folder and the agent start failed with error 267 since 2.7 went live. When
  the agent program never started, the Agent folder is removed again so a later
  run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
  loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
  its run folder is still a real folder of ours, opens the download for reading
  before the signature and identity checks and holds it until the run ends, so
  what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
  with another owner or a reparse point is not taken as protected, reparse points
  are refused, the base is created protected, install mode protects base and
  Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
  user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
  (ElevatedFact for the owner assignment), MSI location contract; harness
  scenarios for an escaping package (installer and OOBE script), the hour rule,
  the Program Files layout and an agent that does not start, with the install
  stand-in starting a real program from PowerShell's location. OOBE golden
  regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:07:51 +02:00

1160 lines
53 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 '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\.8"\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'
}
}