Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs
T
Oliver Kieselbach c91d8d4501 fix(agent): complete enrollments with zero user-targeted apps instead of failing them
IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.

IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.

Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.

ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.

Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:27:42 +02:00

240 lines
12 KiB
C#

using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
public class BuiltInRulesTests
{
[Fact]
public void BuiltInGatherRules_LoadsFromEmbeddedResource()
{
var rules = BuiltInGatherRules.GetAll();
Assert.NotNull(rules);
Assert.NotEmpty(rules);
Assert.Contains(rules, r => r.RuleId == "GATHER-DEVICE-001");
Assert.Contains(rules, r => r.RuleId == "GATHER-DEVICE-002");
foreach (var rule in rules)
{
Assert.False(string.IsNullOrEmpty(rule.RuleId), "RuleId must not be empty");
Assert.False(string.IsNullOrEmpty(rule.Title), $"Rule {rule.RuleId} is missing Title");
Assert.False(string.IsNullOrEmpty(rule.CollectorType), $"Rule {rule.RuleId} is missing CollectorType");
Assert.False(string.IsNullOrEmpty(rule.Target), $"Rule {rule.RuleId} is missing Target");
Assert.False(string.IsNullOrEmpty(rule.Trigger), $"Rule {rule.RuleId} is missing Trigger");
Assert.False(string.IsNullOrEmpty(rule.OutputEventType), $"Rule {rule.RuleId} is missing OutputEventType");
Assert.True(rule.IsBuiltIn, $"Rule {rule.RuleId} should have IsBuiltIn=true");
}
}
[Fact]
public void BuiltInAnalyzeRules_LoadsFromEmbeddedResource()
{
var rules = BuiltInAnalyzeRules.GetAll();
Assert.NotNull(rules);
Assert.True(rules.Count >= 18, $"Expected at least 18 rules, got {rules.Count}");
foreach (var rule in rules)
{
Assert.False(string.IsNullOrEmpty(rule.RuleId), "RuleId must not be empty");
Assert.False(string.IsNullOrEmpty(rule.Title), $"Rule {rule.RuleId} is missing Title");
Assert.False(string.IsNullOrEmpty(rule.Severity), $"Rule {rule.RuleId} is missing Severity");
Assert.False(string.IsNullOrEmpty(rule.Explanation), $"Rule {rule.RuleId} is missing Explanation");
Assert.NotNull(rule.Conditions);
Assert.NotEmpty(rule.Conditions);
Assert.True(rule.IsBuiltIn, $"Rule {rule.RuleId} should have IsBuiltIn=true");
}
}
[Fact]
public void BuiltInAnalyzeRules_TemplateVariablesAreValid()
{
var rules = BuiltInAnalyzeRules.GetAll();
var templateRules = rules.Where(r => r.TemplateVariables != null && r.TemplateVariables.Count > 0).ToList();
foreach (var rule in templateRules)
{
// Template rules should be disabled by default (they need configuration first)
Assert.False(rule.Enabled, $"Template rule {rule.RuleId} should be disabled by default");
foreach (var tv in rule.TemplateVariables)
{
Assert.False(string.IsNullOrEmpty(tv.Name), $"Rule {rule.RuleId}: TemplateVariable is missing Name");
Assert.False(string.IsNullOrEmpty(tv.Label), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Label");
Assert.False(string.IsNullOrEmpty(tv.Placeholder), $"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' is missing Placeholder");
// conditionIndex must reference a valid condition
Assert.True(tv.ConditionIndex >= 0 && tv.ConditionIndex < rule.Conditions.Count,
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has conditionIndex {tv.ConditionIndex} but rule only has {rule.Conditions.Count} conditions");
// Field must be one of the known fields
var validFields = new HashSet<string> { "value", "eventType", "dataField", "eventAFilterValue" };
Assert.True(validFields.Contains(tv.Field),
$"Rule {rule.RuleId}: TemplateVariable '{tv.Name}' has unknown field '{tv.Field}'");
// The placeholder should match the current value in the condition
var condition = rule.Conditions[tv.ConditionIndex];
var actualValue = tv.Field switch
{
"value" => condition.Value,
"eventType" => condition.EventType,
"dataField" => condition.DataField,
"eventAFilterValue" => condition.EventAFilterValue,
_ => null
};
Assert.Equal(tv.Placeholder, actualValue);
}
}
}
[Fact]
public void BuiltInAnalyzeRules_AutoLogon_rules_have_expected_shape()
{
var rules = BuiltInAnalyzeRules.GetAll();
// SEC-002 was removed: AutoLogon-enabled alone is the exact fingerprint of Windows' own ESP
// auto-logon during a normal Autopilot enrollment, so a broad "AutoLogon active" warning was
// a guaranteed false positive on every device. Only a plaintext password (SEC-003) is graded.
Assert.DoesNotContain(rules, r => r.RuleId == "ANALYZE-SEC-002");
// The non-discriminating sysinternals heuristic was removed everywhere — no surviving rule
// may reference it (it fired identically for native ESP auto-logon and a Sysinternals kiosk).
Assert.DoesNotContain(rules, r =>
r.Conditions != null && r.Conditions.Any(c => c.DataField == "checks.sysinternals_autologon_suspected"));
Assert.DoesNotContain(rules, r =>
r.ConfidenceFactors != null && r.ConfidenceFactors.Any(f => f.Signal == "sysinternals_suspected"));
// SEC-003: plaintext password, default-on, escalated. The sole default-on AutoLogon signal.
var sec003 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-003");
Assert.NotNull(sec003);
Assert.True(sec003!.Enabled, "ANALYZE-SEC-003 should be enabled by default");
Assert.Equal("high", sec003.Severity);
Assert.Contains(sec003.Conditions, c => c.DataField == "checks.default_password_present");
// SEC-004: kiosk allow-list TEMPLATE — disabled until configured.
var sec004 = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-SEC-004");
Assert.NotNull(sec004);
Assert.False(sec004!.Enabled, "ANALYZE-SEC-004 is a template and must ship disabled");
Assert.NotNull(sec004.TemplateVariables);
var tv = Assert.Single(sec004.TemplateVariables);
Assert.Equal("allowed_autologon_users", tv.Name);
Assert.Equal(1, tv.ConditionIndex);
Assert.Equal("value", tv.Field);
Assert.Equal("not_in", sec004.Conditions[1].Operator);
Assert.Equal("checks.default_user_name", sec004.Conditions[1].DataField);
}
[Fact]
public void BuiltInAnalyzeRules_ANALYZE_ID_001_IsTemplate()
{
var rules = BuiltInAnalyzeRules.GetAll();
var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ID-001");
Assert.NotNull(rule);
Assert.False(rule.Enabled, "ANALYZE-ID-001 should be disabled (it's a template)");
Assert.NotNull(rule.TemplateVariables);
Assert.Single(rule.TemplateVariables);
var tv = rule.TemplateVariables[0];
Assert.Equal("cert_subject", tv.Name);
Assert.Equal("Certificate Subject", tv.Label);
Assert.Equal(1, tv.ConditionIndex);
Assert.Equal("value", tv.Field);
Assert.Equal("CN=YOUR-CERTIFICATE-SUBJECT", tv.Placeholder);
}
[Fact]
public void BuiltInImeLogPatterns_LoadsFromEmbeddedResource()
{
var patterns = BuiltInImeLogPatterns.GetAll();
Assert.NotNull(patterns);
Assert.True(patterns.Count >= 47, $"Expected at least 47 patterns, got {patterns.Count}");
foreach (var pattern in patterns)
{
Assert.False(string.IsNullOrEmpty(pattern.PatternId), "PatternId must not be empty");
Assert.False(string.IsNullOrEmpty(pattern.Category), $"Pattern {pattern.PatternId} is missing Category");
Assert.False(string.IsNullOrEmpty(pattern.Pattern), $"Pattern {pattern.PatternId} is missing Pattern");
Assert.False(string.IsNullOrEmpty(pattern.Action), $"Pattern {pattern.PatternId} is missing Action");
Assert.False(string.IsNullOrEmpty(pattern.Description),
$"Pattern {pattern.PatternId} is missing Description");
Assert.True(pattern.IsBuiltIn, $"Pattern {pattern.PatternId} should have IsBuiltIn=true");
}
}
[Fact]
public void BuiltInImeLogPatterns_AllActionsAreKnown()
{
var knownActions = new HashSet<string>
{
"setCurrentApp", "updateStateInstalled", "updateStateDownloading",
"updateStateInstalling", "updateStateSkipped", "updateStateError",
"updateStatePostponed", "espPhaseDetected", "imeStarted",
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
"espTrackStatus", "updateName", "updateWin32AppState",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"resetPlatformScriptContext",
"healthScriptResult", "healthScriptDetectionResult",
"captureExitCode", "captureHResult",
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
"captureAttemptNumber", "captureDetectionResult"
};
var patterns = BuiltInImeLogPatterns.GetAll();
foreach (var pattern in patterns)
{
Assert.True(knownActions.Contains(pattern.Action),
$"Pattern {pattern.PatternId} has unknown action '{pattern.Action}'");
}
}
[Fact]
public void BuiltInImeLogPatterns_AllCategoriesAreValid()
{
var validCategories = new HashSet<string> { "always", "currentPhase", "otherPhases" };
var patterns = BuiltInImeLogPatterns.GetAll();
foreach (var pattern in patterns)
{
Assert.True(validCategories.Contains(pattern.Category),
$"Pattern {pattern.PatternId} has invalid category '{pattern.Category}'");
}
}
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
/// </summary>
[Fact]
public void BuiltInImeLogPatterns_ZeroUserAppsPattern_MatchesTheRealImeLine()
{
var pattern = Assert.Single(
BuiltInImeLogPatterns.GetAll(),
p => p.PatternId == "IME-USER-SESSION-ZERO-APPS");
Assert.Equal("userSessionZeroApps", pattern.Action);
Assert.Equal("always", pattern.Category);
var regex = new Regex(pattern.Pattern, RegexOptions.CultureInvariant);
var real = "[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
var match = regex.Match(real);
Assert.True(match.Success, "zero-app user session line must match");
Assert.Equal("1", match.Groups["sessionId"].Value);
Assert.Equal("4a1ba5db-44ed-4e69-8477-47ed88e76020", match.Groups["userId"].Value);
// Must not swallow a session that DOES have apps — that would fabricate completion
// evidence while enforcement is still running.
Assert.DoesNotMatch(regex,
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.DoesNotMatch(regex,
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
}
}