mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
1c5f69a9fe
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup (and the Corporate Identifier fallback) structurally reject them and Cloud PC first-connect enrollment could not be monitored. New stage-3 fallback in the device-validation chain: extract the Intune device id from the chain-validated MDM client certificate's Subject CN (field-verified: CN == managedDevice id == cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq). Only service-provisioned Cloud PCs have such an object — a regular enrolled non-Autopilot device still ends in the 403, and local W365 markers are never consulted for auth. - Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll, GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All - Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract: 30/5 min cache, 2 attempts, transient->503; GUID gate before the OData filter; exact-match guard; Graph 403 = definitive "grant missing", not transient), SecurityValidator CN extraction + fallback stage + hard gate, wiring through the 5 agent endpoints, TableConfigRepository persistence - Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously comment-only lock-step) - Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the Cloud-PC relax is active — Cloud PCs run headless for days before first connect; the fresh-profile window is the time anchor there (+ Pester tests) - Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label, privacy-page validation claim generalized (Last updated bumped) - Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet cap, none added). .NET build/tests and Pester run in CI — SDK download and pwsh are unavailable in this sandbox's network policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv * w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase" Companion to the CloudPc validator: the agent now classifies Windows 365 Cloud PCs locally with the bootstrap's field-verified marker AND (HKLM\SOFTWARE\Microsoft\Windows365 key + installed CloudManagedDesktopExtension service, probed via its Services registry key so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves false. Never an auth input - backend admission stays exclusively the cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are independent and may disagree. Two transport rails: - Session metadata: SessionRegistration.IsCloudPc -> Sessions table (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern) -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search filter end to end (SearchSessions + QueryRawSessions + pagination fingerprint + all three in-memory predicates; MCP search_sessions / query_raw_sessions params + summary projection; web type, Cloud PC pill, SessionInfoCard enrollment label, CSV export appended column). - Decision engine: isCloudPc rides the EnrollmentFactsObserved payload, recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc and annotates the scenario profile reason cloud_pc_first_connect:no_device_esp_expected - the engine-recorded expectation that Device-ESP already ran headless at provisioning and the session starts at Account Setup. Deliberately reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME- classified (Classic), EspConfig keeps configured-FirstSync semantics. Positive marker surfaces as cloud_pc_marker in the audit-trail census. Updater idempotency: the cloudPcAnnotates gate keeps the generic v1 reason branch from re-claiming the slot on repeat posts (would bump EvidenceOrdinal every post). Additive-nullable observation - no state schema bump (v4 convention), serializer roundtrip pinned. Tests: CloudPcDetector resolver (marker AND, throwing probe), registration body, facts-signal payload shape, updater annotation/idempotency/ self-deploying interplay, reducer observation both values + census fold, serializer roundtrip, index projection superset, sticky-true both directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154, vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99). Docs: cloudpc-device-validation.md follow-up section replaced with the shipped mechanism + log entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
308 lines
15 KiB
PowerShell
308 lines
15 KiB
PowerShell
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
|
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
|
|
#
|
|
# 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, filesystem, WinRT) is mocked; profile directories are real folders
|
|
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
|
|
#
|
|
# 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 'Install-AutopilotMonitor-dev.ps1')
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
Describe 'Test-IsCloudPc' {
|
|
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
|
|
# 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-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\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-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
|
Mock Get-Service { $null }
|
|
Test-IsCloudPc | Should -BeFalse
|
|
}
|
|
|
|
It 'does not detect with the service alone' {
|
|
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
|
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
|
Test-IsCloudPc | Should -BeFalse
|
|
}
|
|
|
|
It 'does not detect a plain device (no markers)' {
|
|
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
|
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-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\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-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\LeonGottschalk' }
|
|
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
|
|
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
|
|
)
|
|
}
|
|
Mock Get-ChildItem {
|
|
@(
|
|
[pscustomobject]@{ FullName = 'C:\Users\LeonGottschalk' }
|
|
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
|
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
|
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
|
)
|
|
}
|
|
$result = @(Get-RealUserProfilePaths)
|
|
$result | Should -Be @('C:\Users\LeonGottschalk')
|
|
}
|
|
|
|
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 (W365 field case)' {
|
|
$p = New-FakeProfile 'LeonGottschalk' 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 'LeonGottschalk' 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, v2.2 behaviour)' {
|
|
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, agent not installed. Tests override selectively.
|
|
Mock Get-ItemProperty { $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' }
|
|
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
|
|
}
|
|
|
|
It 'installs on a clean ESP device' {
|
|
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
|
$d.Install | Should -BeTrue
|
|
$d.ReasonCode | Should -BeNullOrEmpty
|
|
$d.RelaxActive | Should -BeFalse
|
|
}
|
|
|
|
It 'skips when the Deployed marker exists (guard 1)' {
|
|
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
|
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
|
$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 -AgentBinPath $script:cleanAgentBin
|
|
$d.Install | Should -BeFalse
|
|
$d.ReasonCode | Should -Be 'ProfilesFound'
|
|
}
|
|
|
|
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
|
|
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 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-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\LeonGottschalk' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
|
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
|
$d.Install | Should -BeTrue
|
|
$d.RelaxActive | Should -BeTrue
|
|
}
|
|
|
|
It 'skips when a real user has logged on and no relax applies (guard 3)' {
|
|
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
|
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
|
$d.Install | Should -BeFalse
|
|
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
|
|
}
|
|
|
|
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
|
|
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
|
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).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 -AgentBinPath $script:cleanAgentBin
|
|
$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, v2.3-dev.3)' {
|
|
# 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 -AgentBinPath $script:cleanAgentBin
|
|
$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 -AgentBinPath $script:cleanAgentBin
|
|
$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 -AgentBinPath $script:cleanAgentBin
|
|
$d.Install | Should -BeFalse
|
|
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
|
}
|
|
|
|
It 'skips when the agent binary is already present (guard 5)' {
|
|
$binPath = Join-Path $TestDrive 'AgentBinExisting'
|
|
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
|
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
|
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
|
|
$d.Install | Should -BeFalse
|
|
$d.ReasonCode | Should -Be 'AgentPresent'
|
|
}
|
|
}
|
|
|
|
Describe 'logging purity' {
|
|
# 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. This
|
|
# test deliberately does NOT mock Write-Log.
|
|
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
|
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
|
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
|
|
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
|
|
# the ProgramData path defined by the dot-sourced script.
|
|
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
|
|
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
|
|
$d.Count | Should -Be 1
|
|
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
|
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
|
}
|
|
}
|