mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
f44e8aadb0
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>
482 lines
25 KiB
PowerShell
482 lines
25 KiB
PowerShell
# Pester tests (Pester 5+) for the stage-1 loader Start-AutopilotMonitor.ps1.
|
|
#
|
|
# The script under test is dot-sourced; its entry guard prevents the loader from
|
|
# executing, so loading it here is side-effect free. Network and signature checks
|
|
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
|
# does it refuse, how often does it retry, and what does it leave behind.
|
|
#
|
|
# 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 {
|
|
. (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1')
|
|
|
|
function New-FakeSignature {
|
|
param([string]$Status, [string]$Subject)
|
|
$cert = if ($Subject) {
|
|
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
|
|
} else { $null }
|
|
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
|
}
|
|
|
|
# Arguments as PowerShell hands them to a script, names marked as names; an array of
|
|
# plain strings would bind by position instead.
|
|
function ConvertTo-ScriptArguments { return , $args }
|
|
|
|
function Get-ScriptAst {
|
|
param([string]$Path)
|
|
$tokens = $null; $errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
|
return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors }
|
|
}
|
|
|
|
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
|
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
|
}
|
|
|
|
Describe 'Test-BootstrapSignature' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
}
|
|
|
|
It 'accepts a valid signature from the expected publisher' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
|
}
|
|
|
|
# An EV subject is a line of street address and registration numbers; the log gets the CN.
|
|
It 'logs the signer CN, not the whole subject' {
|
|
Mock Get-AuthenticodeSignature {
|
|
New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach am Main, C=DE, SERIALNUMBER=HRB 12381'
|
|
}
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
|
Should -Invoke Write-Log -ParameterFilter { $Message -eq 'Signature status: Valid. Signer: glueckkanja AG' }
|
|
}
|
|
|
|
It 'refuses an unsigned script' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a tampered script (hash mismatch)' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a signature whose chain does not validate' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
# The whole point of the pinned publisher: a validly signed script from someone else
|
|
# is exactly what an attacker with any code-signing certificate can produce.
|
|
It 'refuses a valid signature from a different publisher' {
|
|
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses when the signature check itself fails' {
|
|
Mock Get-AuthenticodeSignature { throw 'file not found' }
|
|
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
|
}
|
|
}
|
|
|
|
Describe 'Get-InstallerIdentity' {
|
|
BeforeAll {
|
|
$script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"'
|
|
}
|
|
|
|
It 'reads the role and the version of LF text' {
|
|
$id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n")
|
|
$id.Role | Should -BeExactly 'AutopilotMonitor-Installer'
|
|
$id.Version | Should -Be ([version]'2.7')
|
|
}
|
|
|
|
It 'reads CRLF text that ends in an Authenticode signature block' {
|
|
$text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n"
|
|
(Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7')
|
|
}
|
|
|
|
It 'compares versions as numbers, so 2.10 is above 2.7' {
|
|
$id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n")
|
|
$id.Version -gt [version]'2.7' | Should -BeTrue
|
|
}
|
|
|
|
It 'returns nothing for <Case>' -TestCases @(
|
|
@{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') }
|
|
@{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') }
|
|
@{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') }
|
|
@{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') }
|
|
@{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') }
|
|
@{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') }
|
|
@{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') }
|
|
@{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') }
|
|
) {
|
|
Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty
|
|
}
|
|
|
|
# The pair the loader and the installer in this repository must keep: the published
|
|
# installer has to pass the loader that ships with it.
|
|
It 'accepts the installer in this repository' {
|
|
$id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath))
|
|
$id.Role | Should -BeExactly $ExpectedInstallerRole
|
|
$id.Version -ge $MinimumInstallerVersion | Should -BeTrue
|
|
}
|
|
}
|
|
|
|
Describe 'Test-InstallerIdentity' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
$script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1')
|
|
}
|
|
|
|
It 'accepts version <Version>' -TestCases @(@{ Version = '2.8' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeTrue
|
|
}
|
|
|
|
# Every installer the publisher ever signed keeps a valid signature; the minimum keeps the
|
|
# older ones from running.
|
|
It 'refuses an installer below the minimum version' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.8*' }
|
|
}
|
|
|
|
# The publisher signs other scripts too.
|
|
It 'refuses another role' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.8`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a script without the identity lines' {
|
|
[System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n")
|
|
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
|
}
|
|
|
|
It 'refuses a file it cannot read' {
|
|
Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse
|
|
}
|
|
}
|
|
|
|
Describe 'Get-VerifiedBootstrapScript' {
|
|
BeforeEach {
|
|
Mock Write-Log { }
|
|
Mock Start-Sleep { }
|
|
Mock Invoke-WebRequest { [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
|
Mock Test-InstallerIdentity { $true }
|
|
# The test account owns the folder; the loader asks for SYSTEM or Administrators.
|
|
Mock Test-IsOwnedByUs { $true }
|
|
$script:run = Join-Path $TestDrive ('run-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
New-Item -ItemType Directory -Path $script:run | Out-Null
|
|
$script:dest = Join-Path $script:run 'Install-AutopilotMonitor.ps1'
|
|
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
|
}
|
|
AfterEach {
|
|
if ($RunState.Installer) { $RunState.Installer.Dispose() }
|
|
}
|
|
|
|
It 'returns after the first attempt when the download verifies' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
|
}
|
|
|
|
It 'gives up after three attempts when verification keeps failing' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
|
}
|
|
|
|
# A truncated or proxy-mangled body fails verification, so the retry has to cover the
|
|
# download AND the check, not just the transport.
|
|
It 'retries a failed verification and accepts a later attempt' {
|
|
$script:calls = 0
|
|
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
|
}
|
|
|
|
It 'retries a transport failure' {
|
|
$script:downloads = 0
|
|
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' }; [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
|
}
|
|
|
|
It 'refuses a validly signed script that is not the current installer' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Mock Test-InstallerIdentity { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
|
}
|
|
|
|
# The identity lines count only once the signature proves they are the publisher's.
|
|
It 'reads the identity only after the signature passed' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Test-InstallerIdentity -Times 0 -Exactly
|
|
}
|
|
|
|
It 'stops without downloading when the run folder is no longer a folder of ours' {
|
|
Mock Test-IsOwnedByUs { $false }
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
Should -Invoke Invoke-WebRequest -Times 0 -Exactly
|
|
}
|
|
|
|
# The file that was checked is the file that runs: nobody can replace it in between.
|
|
It 'keeps the verified file open, so it cannot be replaced or deleted' {
|
|
Mock Test-BootstrapSignature { $true }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
|
$RunState.Installer | Should -Not -BeNullOrEmpty
|
|
{ [System.IO.File]::WriteAllText($script:dest, 'replaced') } | Should -Throw
|
|
{ [System.IO.File]::Delete($script:dest) } | Should -Throw
|
|
[System.IO.File]::ReadAllText($script:dest) | Should -Be 'downloaded'
|
|
}
|
|
|
|
It 'holds nothing when no attempt verifies' {
|
|
Mock Test-BootstrapSignature { $false }
|
|
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
|
$RunState.Installer | Should -BeNullOrEmpty
|
|
{ [System.IO.File]::Delete($script:dest) } | Should -Not -Throw
|
|
}
|
|
}
|
|
|
|
Describe 'Get-DownloadSecurityProtocol' {
|
|
It 'keeps the system default, which includes TLS 1.3' {
|
|
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault)
|
|
}
|
|
|
|
It 'adds TLS 1.2 to an explicit list' {
|
|
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12')
|
|
}
|
|
}
|
|
|
|
Describe 'Remove-LoaderRunFiles' {
|
|
BeforeEach {
|
|
Mock Write-Host { }
|
|
$run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N'))
|
|
New-Item -ItemType Directory -Path $run | Out-Null
|
|
$installer = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
|
$log = Join-Path $run 'bootstrap-loader.log'
|
|
Set-Content -Path $installer -Value 'downloaded'
|
|
Set-Content -Path $log -Value 'log'
|
|
$RunState = [pscustomobject]@{ LogFile = $log; Installer = $null }
|
|
}
|
|
|
|
It 'removes the download, the log and the folder' {
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
|
Test-Path -LiteralPath $run | Should -BeFalse
|
|
$RunState.LogFile | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'releases the held download before deleting it' {
|
|
$RunState.Installer = New-Object System.IO.FileStream($installer, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
|
Test-Path -LiteralPath $run | Should -BeFalse
|
|
$RunState.Installer | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'keeps the log and the folder with -KeepLog, but never the download' {
|
|
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog
|
|
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
}
|
|
|
|
It 'leaves a folder that holds anything else, without throwing' {
|
|
Set-Content -Path (Join-Path $run 'other.txt') -Value 'other'
|
|
{ Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw
|
|
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt')
|
|
Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' }
|
|
}
|
|
}
|
|
|
|
Describe 'Start-BootstrapLoader' {
|
|
BeforeEach {
|
|
$script:savedProgramData = $env:ProgramData
|
|
$env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N'))
|
|
New-Item -ItemType Directory -Path $env:ProgramData | Out-Null
|
|
$script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
|
$script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt')
|
|
|
|
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
|
$InstallerArguments = @()
|
|
|
|
Mock Write-Host { }
|
|
Mock Start-Sleep { }
|
|
Mock Test-IsElevated { $true }
|
|
# A standard user cannot make Administrators the owner, so the run folder is a plain
|
|
# one here; New-RunFolder itself is tested with the installer, which shares it.
|
|
Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder }
|
|
}
|
|
|
|
AfterEach {
|
|
$env:ProgramData = $script:savedProgramData
|
|
}
|
|
|
|
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
|
Mock Test-IsElevated { $false }
|
|
$stderr = New-Object System.IO.StringWriter
|
|
$previous = [Console]::Error
|
|
[Console]::SetError($stderr)
|
|
try { Start-BootstrapLoader } finally { [Console]::SetError($previous) }
|
|
$stderr.ToString() | Should -Match 'not running as SYSTEM or administrator'
|
|
Should -Invoke New-RunFolder -Times 0 -Exactly
|
|
@(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0
|
|
}
|
|
|
|
It 'runs nothing and keeps only its log when no download verifies' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false }
|
|
Start-BootstrapLoader
|
|
Test-Path -LiteralPath $script:marker | Should -BeFalse
|
|
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed'
|
|
}
|
|
|
|
It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' {
|
|
$agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log'
|
|
New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null
|
|
Set-Content -Path $agentLog -Value 'agent'
|
|
Mock Get-VerifiedBootstrapScript {
|
|
[System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n")
|
|
$true
|
|
}
|
|
$InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24
|
|
|
|
Start-BootstrapLoader
|
|
|
|
Get-Content -LiteralPath $script:marker | Should -Be 'default 24'
|
|
Test-Path -LiteralPath $script:runFolder | Should -BeFalse
|
|
Get-Content -LiteralPath $agentLog | Should -Be 'agent'
|
|
}
|
|
|
|
It 'keeps its log but not the installer when the installer fails' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true }
|
|
Start-BootstrapLoader
|
|
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)'
|
|
}
|
|
|
|
It 'keeps its log when the installer throws' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true }
|
|
Start-BootstrapLoader
|
|
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke'
|
|
}
|
|
|
|
# An installer that ends without an exit code must not inherit a 0 from earlier work.
|
|
It 'does not take an earlier exit code for the installer''s' {
|
|
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true }
|
|
$global:LASTEXITCODE = 0
|
|
Start-BootstrapLoader
|
|
Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue
|
|
}
|
|
|
|
It 'names its version first in its log, in the form the installer carries over' {
|
|
Mock Get-VerifiedBootstrapScript { $false }
|
|
Start-BootstrapLoader
|
|
$first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0]
|
|
$first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started'
|
|
}
|
|
}
|
|
|
|
Describe 'loader contract' {
|
|
BeforeAll {
|
|
$script:loader = Get-ScriptAst $script:loaderPath
|
|
}
|
|
|
|
It 'parses without errors' {
|
|
$script:loader.Errors.Count | Should -Be 0
|
|
}
|
|
|
|
# Unbound arguments reach the installer only while the script is not an advanced one.
|
|
It 'stays a plain script, so unbound arguments are kept for the installer' {
|
|
$script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0
|
|
@($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty
|
|
}
|
|
|
|
# The six functions also run in the installer and, embedded, in the OOBE script.
|
|
It 'carries the shared functions word for word as the installer has them' {
|
|
$installer = Get-ScriptAst $script:installerPath
|
|
foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) {
|
|
$mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
|
$theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
|
$mine | Should -Not -BeNullOrEmpty -Because $name
|
|
$mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name
|
|
}
|
|
}
|
|
|
|
It 'calls no command beyond the few it needs' {
|
|
$own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name })
|
|
$allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host')
|
|
$used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique)
|
|
@($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty
|
|
}
|
|
|
|
It 'deletes only the download, its log and its own empty folder' {
|
|
$deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true))
|
|
$deletes.Count | Should -Be 3
|
|
foreach ($delete in $deletes) {
|
|
if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') {
|
|
$delete.Arguments[1].Extent.Text | Should -Be '$false'
|
|
} else {
|
|
$delete.Expression.Extent.Text | Should -Be '[System.IO.File]'
|
|
}
|
|
}
|
|
}
|
|
|
|
It 'never names the agent folder' {
|
|
$strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value })
|
|
@($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty
|
|
}
|
|
}
|
|
|
|
Describe 'publish contract' {
|
|
BeforeAll {
|
|
$script:loaderText = Get-Content $script:loaderPath -Raw
|
|
}
|
|
|
|
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
|
# literal and fails the publish when it is gone. Catching that here means the PR fails
|
|
# instead of the publish.
|
|
It 'carries the dev-render anchor the publisher substitutes' {
|
|
$anchor = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
|
$script:loaderText.Contains($anchor) | Should -BeTrue
|
|
}
|
|
|
|
# The publisher parses this to write version.json.loaderVersion and to run the
|
|
# version-bump guard.
|
|
It 'declares a parsable ScriptVersion' {
|
|
$script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue
|
|
}
|
|
|
|
It 'pins the expected publisher by subject, not by thumbprint' {
|
|
$script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue
|
|
}
|
|
|
|
# The bootstrap MSI installs this same file and runs it with -LogFileName so the delivery
|
|
# channel stays visible in a diagnostics package. Renaming the parameter on either side
|
|
# would leave the MSI channel silently logging under the wrong name.
|
|
It 'keeps the -LogFileName contract the bootstrap MSI relies on' {
|
|
$script:loaderText -match '\[string\]\$LogFileName' | Should -BeTrue
|
|
|
|
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
|
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
|
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
|
}
|
|
|
|
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
|
|
# the installer removes it. A standard user could have created that folder first, so the
|
|
# action never runs the file as such: it checks the signature on the bytes it read and runs
|
|
# those bytes, from System32.
|
|
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
|
|
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
|
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
|
|
$wxs | Should -Not -Match 'ProgramFiles'
|
|
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
|
|
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
|
|
}
|
|
}
|