mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-12 20:51:56 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01e7abc2b | |||
| 512b145484 | |||
| 8ece30631a | |||
| c9078d00b6 | |||
| 1ff68dc0af | |||
| 7c8204357b | |||
| dfcf7ad8ba | |||
| 54d4c0bd4c | |||
| 11bc0ec2ab | |||
| 7e9b0cea54 | |||
| c115cb354e | |||
| 9cfe3fb74d | |||
| 892ff4c1e1 | |||
| 2cb181734a | |||
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 | |||
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 |
+1
-1
Submodule .claude/commands updated: dbacde8e0d...7fee9d0e6e
+1
-1
Submodule internal updated: 1a3979773f...e6a23a2a1f
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+23
-4
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
|
||||
TimeSpan? drainInterval = null,
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null) =>
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
TimeSpan? collectorHostStopTimeout = null) =>
|
||||
new EnrollmentOrchestrator(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
|
||||
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
|
||||
agentMaxLifetime: agentMaxLifetime);
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
collectorHostStopTimeout: collectorHostStopTimeout);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
public void Dispose()
|
||||
{
|
||||
Watcher.Dispose();
|
||||
ReleaseProcessesAndFiles();
|
||||
}
|
||||
|
||||
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
|
||||
public void ReleaseProcessesAndFiles()
|
||||
{
|
||||
foreach (var p in Running)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(new object[] { first.Id, second.Id }, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var child = f.Spawn(trusted: true);
|
||||
|
||||
// The watcher gets a Process object that already carries a handler. .NET runs that one
|
||||
// first and holds its own lock on the Process object for as long as it delivers Exited.
|
||||
var delivering = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
f.Watcher.ProcessSource = () =>
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
return new[] { handle };
|
||||
};
|
||||
f.Watcher.TryAttachNow();
|
||||
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => f.Watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
|
||||
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
|
||||
// hang the test run instead of failing this test.
|
||||
f.ReleaseProcessesAndFiles();
|
||||
Assert.True(returned,
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
|
||||
{
|
||||
|
||||
+32
@@ -2,11 +2,14 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
|
||||
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
|
||||
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
|
||||
var start = Task.Run(() => watcher.Start());
|
||||
try
|
||||
{
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
start.Wait(TimeSpan.FromSeconds(10));
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
|
||||
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
|
||||
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
|
||||
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
|
||||
/// already gone when the watcher subscribed.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
|
||||
public sealed class OfficeProcessWatcherTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<Process> _spawned = new List<Process>();
|
||||
|
||||
public OfficeProcessWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _spawned)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
|
||||
private Process Spawn()
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Assert.NotNull(p);
|
||||
_spawned.Add(p!);
|
||||
return p!;
|
||||
}
|
||||
|
||||
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
|
||||
private static Process HandleOn(Process child)
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
|
||||
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
|
||||
{
|
||||
watcher.WorkerSource = () => new[] { handle };
|
||||
watcher.Start(armWmi: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var started = new ManualResetEventSlim(false);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Started += (_, __) => started.Set();
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
var child = Spawn();
|
||||
|
||||
StartWith(watcher, HandleOn(child));
|
||||
|
||||
Assert.True(started.IsSet, "Started was not raised by the startup probe");
|
||||
Assert.True(watcher.IsActive);
|
||||
|
||||
child.Kill();
|
||||
|
||||
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
|
||||
{
|
||||
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
|
||||
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
|
||||
// the Process object for as long as it delivers Exited.
|
||||
using var delivering = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.True(dispose.Wait(Timeout),
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
using var exitDelivered = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => exitDelivered.Set();
|
||||
child.Kill();
|
||||
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
|
||||
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
|
||||
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Assert.True(stopped.Wait(Timeout),
|
||||
"the window never closed: the worker exited before the watcher subscribed to Exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
|
||||
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
|
||||
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
|
||||
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
|
||||
/// after it returns.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
|
||||
public sealed class RegistryChangeWatcherTests : IDisposable
|
||||
{
|
||||
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
|
||||
private const string TargetSubKey = RootSubKey + @"\Target";
|
||||
private const uint RegNotifyThreadAgnostic = 0x10000000;
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public RegistryChangeWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
DeleteTestKeys();
|
||||
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKeys();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKeys()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void CreateTarget()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
|
||||
}
|
||||
|
||||
private static void WriteValue(string name)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue(name, Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryChangeWatcher NewWatcher() =>
|
||||
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_when_a_value_is_written_under_the_target()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_once_a_missing_target_key_appears()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start(); // target absent: watches the parent for a new child key
|
||||
CreateTarget();
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
Assert.True(entered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_handler_may_dispose_its_own_watcher()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var disposedInHandler = new ManualResetEventSlim(false);
|
||||
int calls = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
watcher.Dispose();
|
||||
disposedInHandler.Set();
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
|
||||
|
||||
WriteValue("Second");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var firstEntered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
using var second = new ManualResetEventSlim(false);
|
||||
int calls = 0, running = 0, maxRunning = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref running);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxRunning)))
|
||||
Interlocked.CompareExchange(ref maxRunning, now, seen);
|
||||
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
firstEntered.Set();
|
||||
release.Wait(Timeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
second.Set();
|
||||
}
|
||||
Interlocked.Decrement(ref running);
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
WriteValue("DuringHandler");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
|
||||
|
||||
release.Set();
|
||||
|
||||
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
|
||||
Assert.Equal(1, Volatile.Read(ref maxRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_watch_is_not_tied_to_the_arming_thread()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
|
||||
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
|
||||
// the event when such a thread ends, which looks like a change that never happened.
|
||||
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
|
||||
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
|
||||
/// flight. The callback runs on a real registry watcher over the signed-in user's
|
||||
/// <c>HKU\<sid>\SOFTWARE</c> (writable without elevation); the key probes are injected.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
|
||||
public sealed class RealmJoinWatcherStopTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
|
||||
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_the_key_appeared_callback_is_running()
|
||||
{
|
||||
var sid = WindowsIdentity.GetCurrent().User!.Value;
|
||||
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
|
||||
|
||||
var appearedProbe = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
int armed = 0;
|
||||
bool KeyExists(RegistryHive hive, string subPath)
|
||||
{
|
||||
// Everything is "absent" while the watcher arms. Afterwards the user-scope
|
||||
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
|
||||
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
|
||||
return false;
|
||||
appearedProbe.Set();
|
||||
release.Wait(Timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
var watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: (_, __) => Array.Empty<string>(),
|
||||
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
|
||||
{
|
||||
snapshot = default!;
|
||||
return false;
|
||||
},
|
||||
keyExists: KeyExists,
|
||||
readVersionInfo: () => default,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => null);
|
||||
|
||||
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
|
||||
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
|
||||
watcher.ArmHku(sid);
|
||||
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
Volatile.Write(ref armed, 1);
|
||||
|
||||
Task stop;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
|
||||
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
|
||||
|
||||
stop = Task.Run(() => watcher.Stop());
|
||||
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = stop.Wait(Timeout);
|
||||
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
|
||||
if (returned) watcher.Dispose();
|
||||
Assert.True(returned,
|
||||
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
|
||||
{
|
||||
// The integrity report goes out post-config, seconds before the registration is refused.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
|
||||
Assert.Equal(410, client.Reports[1].HttpStatusCode);
|
||||
|
||||
// Key dedup still applies: a second one is held back.
|
||||
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
|
||||
{
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
|
||||
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
|
||||
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
|
||||
/// as before.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EnrollmentOrchestratorCollectorStopTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private sealed class Host : ICollectorHost
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly ManualResetEventSlim? _hangUntil;
|
||||
private readonly Exception? _throwOnStop;
|
||||
private int _stopCalls;
|
||||
private int _disposeCalls;
|
||||
|
||||
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
|
||||
{
|
||||
Name = name;
|
||||
_order = order;
|
||||
_hangUntil = hangUntil;
|
||||
_throwOnStop = throwOnStop;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
|
||||
|
||||
public void Start() { }
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
lock (_order) _order.Add(Name);
|
||||
if (_throwOnStop != null) throw _throwOnStop;
|
||||
_hangUntil?.Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory : IComponentFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ICollectorHost> _hosts;
|
||||
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
|
||||
}
|
||||
|
||||
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
|
||||
sut.SpoolForTests!.Peek(1000)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
|
||||
.Select(i => JObject.Parse(i.PayloadJson))
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var order = new List<string>();
|
||||
var before = new Host("Before", order);
|
||||
var hanging = new Host("Hanging", order, hangUntil: release);
|
||||
var after = new Host("After", order);
|
||||
var sut = rig.Build(
|
||||
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
|
||||
collectorHostStopTimeout: Bound);
|
||||
sut.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var stop = Task.Run(() => sut.StopCollectorHosts());
|
||||
|
||||
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
|
||||
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
|
||||
|
||||
JObject? evt = null;
|
||||
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
|
||||
"the hanging host was not reported");
|
||||
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
|
||||
var data = (JObject)evt["Data"]!;
|
||||
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
|
||||
Assert.Equal("Hanging", (string?)data["host"]);
|
||||
Assert.Equal(1, (int)data["timeoutSeconds"]!);
|
||||
|
||||
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
|
||||
var fullStop = Task.Run(() => sut.Stop());
|
||||
Assert.True(fullStop.Wait(Wait), "Stop never returned");
|
||||
Assert.Equal(1, before.DisposeCalls);
|
||||
Assert.Equal(0, hanging.DisposeCalls);
|
||||
Assert.Equal(1, after.DisposeCalls);
|
||||
Assert.Equal(1, hanging.StopCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
var order = new List<string>();
|
||||
var first = new Host("First", order);
|
||||
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
|
||||
var last = new Host("Last", order);
|
||||
var hosts = new[] { first, throwing, last };
|
||||
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
|
||||
sut.Start();
|
||||
|
||||
sut.StopCollectorHosts();
|
||||
sut.StopCollectorHosts(); // idempotent
|
||||
|
||||
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
|
||||
|
||||
Thread.Sleep(200);
|
||||
Assert.Null(StopTimeoutEvent(sut));
|
||||
|
||||
sut.Stop();
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
|
||||
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
|
||||
/// must never wait for a watcher callback, and a completion that coincides with a registry
|
||||
/// callback must not leave the host unable to stop.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
|
||||
public sealed class OfficeInstallDetectorHostTests : IDisposable
|
||||
{
|
||||
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly string _installPath;
|
||||
private Func<OfficeC2RSnapshot> _snapshot;
|
||||
|
||||
public OfficeInstallDetectorHostTests()
|
||||
{
|
||||
DeleteTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
|
||||
_installPath = Path.Combine(_tmp.Path, "Office");
|
||||
Directory.CreateDirectory(_installPath);
|
||||
_snapshot = Idle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKey();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKey()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void TouchRegistry()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private OfficeInstallDetectorHost NewHost() =>
|
||||
new OfficeInstallDetectorHost(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
settleSeconds: 0,
|
||||
statePersistence: null,
|
||||
resumeState: null,
|
||||
registryHive: RegistryHive.CurrentUser,
|
||||
registrySubKey: RegistrySubKey,
|
||||
snapshotProvider: () => _snapshot());
|
||||
|
||||
// No install scenario, no install path: the detector stays idle and the host keeps
|
||||
// raising OfficeExpected on registry changes.
|
||||
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
|
||||
|
||||
private OfficeC2RSnapshot Installing()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
InstallationPath = _installPath,
|
||||
VersionToReport = "16.0.1.1",
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_a_registry_handler_is_running()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
// 2) The install starts and completes meanwhile: the binary watcher sees the core
|
||||
// binary, the detector reports completed, the host tears its watchers down.
|
||||
_snapshot = Installing;
|
||||
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
|
||||
|
||||
var versionDir = Path.Combine(_installPath, "root", "Office16");
|
||||
Directory.CreateDirectory(versionDir);
|
||||
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
|
||||
Assert.True(
|
||||
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
|
||||
"the install was not reported as completed");
|
||||
|
||||
// 3) The agent stops while that registry callback is still running.
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited behind the completion's teardown, which waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
|
||||
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
|
||||
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
|
||||
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
|
||||
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
|
||||
/// </summary>
|
||||
public sealed class SessionGoneAtRegistrationTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly SessionIdPersistence _persistence;
|
||||
private readonly string _stateDir;
|
||||
private readonly List<string> _sequence = new List<string>();
|
||||
|
||||
public SessionGoneAtRegistrationTests()
|
||||
{
|
||||
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
|
||||
_persistence = new SessionIdPersistence(_tmp.Path);
|
||||
_persistence.GetOrCreate(_logger);
|
||||
_stateDir = Path.Combine(_tmp.Path, "State");
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private sealed class RecordingEmergencyReporter : EmergencyReporter
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
|
||||
new List<(AgentErrorType, string, int?, int)>();
|
||||
|
||||
public RecordingEmergencyReporter(List<string> sequence)
|
||||
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null,
|
||||
RegistrationFailureSummary? priorRegistrationFailure = null)
|
||||
{
|
||||
Reports.Add((errorType, message, httpStatusCode, attempts));
|
||||
_sequence.Add("report");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private CleanupService CleanupFactory()
|
||||
{
|
||||
_sequence.Add("cleanup");
|
||||
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
|
||||
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
|
||||
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
|
||||
}
|
||||
|
||||
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
|
||||
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
|
||||
{
|
||||
_persistence.SaveWhiteGloveComplete(_logger);
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
|
||||
var reporter = new RecordingEmergencyReporter(_sequence);
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
// The report leaves before the cleanup deletes the agent.
|
||||
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
|
||||
|
||||
var report = Assert.Single(reporter.Reports);
|
||||
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
Assert.Contains("410", report.Message);
|
||||
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
|
||||
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
Assert.False(_persistence.IsWhiteGloveResume());
|
||||
Assert.Null(_persistence.LoadSessionCreatedAt());
|
||||
// The next start ends at the bootstrap guard, exactly as after the emergency break.
|
||||
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
|
||||
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ends_monitoring_without_a_reporter()
|
||||
{
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
|
||||
|
||||
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
|
||||
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Equal(new[] { "cleanup" }, _sequence);
|
||||
Assert.True(MarkerExists);
|
||||
Assert.False(_persistence.SessionExists());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionRotationReasons.SessionGone)]
|
||||
[InlineData(SessionRotationReasons.OwnerMismatch)]
|
||||
public void Agent_started_names_why_the_session_was_rotated(string reason)
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
|
||||
|
||||
Assert.Equal(true, data["sessionRotated"]);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
|
||||
Assert.Equal(reason, data["sessionRotationReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
|
||||
{
|
||||
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
|
||||
|
||||
Assert.Equal(false, data["sessionRotated"]);
|
||||
Assert.False(data.ContainsKey("sessionRotationReason"));
|
||||
}
|
||||
|
||||
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
new InformationalEventPost(sink, SystemClock.Instance, _logger),
|
||||
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
|
||||
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
|
||||
"2.0.test",
|
||||
remoteConfigService: null,
|
||||
_logger,
|
||||
rotatedFromSessionId: rotatedFromSessionId,
|
||||
sessionRotationReason: sessionRotationReason);
|
||||
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(1, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
// ── 410 at registration: the backend deleted the session ────────────
|
||||
|
||||
private static BackendSessionGoneException SessionGone()
|
||||
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(410, result.HttpStatusCode);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
Assert.Null(result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var originalSessionId = config.SessionId;
|
||||
var rotatedTo = Guid.NewGuid().ToString();
|
||||
var seenSessionIds = new System.Collections.Generic.List<string>();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(
|
||||
attempt => attempt == 1
|
||||
? throw SessionGone()
|
||||
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
|
||||
onRegister: r => seenSessionIds.Add(r.SessionId));
|
||||
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
|
||||
clock: SystemClock.Instance, logger: logger);
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
authFailureTracker: tracker,
|
||||
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_gone_after_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
var rotations = 0;
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(1, rotations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => throw new IOException("disk full"),
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Null(result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw SessionGone());
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateOnSessionGone: true);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
|
||||
+31
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
// ── 410: session gone, only when the backend said so ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_410_on_registration_is_session_gone()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(410, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
|
||||
{
|
||||
// Answered on the network path: the caller would end monitoring on a session-gone
|
||||
// signal, so it must stay an ordinary, retried failure.
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Gone,
|
||||
"<html><head><title>Gone</title></head><body>Gone</body></html>",
|
||||
"text/html",
|
||||
fromBackend: false);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
+11
-6
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Process attached;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
attached = _imeProcess;
|
||||
_imeProcess = null;
|
||||
}
|
||||
|
||||
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
|
||||
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
|
||||
if (attached != null)
|
||||
{
|
||||
try { attached.Exited -= OnImeExited; } catch { }
|
||||
try { attached.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-19
@@ -194,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// subtree) into RJ's lifecycle.
|
||||
private bool _packageWatchersArmed;
|
||||
private bool _disposed;
|
||||
// Set by Stop under _stateLock: a registry callback that is still running then must not
|
||||
// arm or attach another watcher.
|
||||
private bool _stopped;
|
||||
|
||||
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
|
||||
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
|
||||
@@ -228,6 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
|
||||
lock (_stateLock) { _stopped = false; }
|
||||
|
||||
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
|
||||
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
|
||||
@@ -277,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
_stopped = true;
|
||||
|
||||
Take(ref _servicesAppearanceWatcher, watchers);
|
||||
Take(ref _machineSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _userSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _uninstallAppearanceWatcher, watchers);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
|
||||
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
|
||||
// still running, and the appearance callback takes _stateLock.
|
||||
foreach (var watcher in watchers)
|
||||
{
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -391,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Action clearSlot,
|
||||
Action onAppeared)
|
||||
{
|
||||
lock (_stateLock) { if (_stopped) return; }
|
||||
try
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
@@ -440,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_serviceRealmjoinWatcher != null) return;
|
||||
if (_stopped || _serviceRealmjoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -486,7 +501,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_machineRealmJoinWatcher != null) return;
|
||||
if (_stopped || _machineRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets with every package sub-key already present at arming time.
|
||||
@@ -529,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_userRealmJoinWatcher != null) return;
|
||||
if (_stopped || _userRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
|
||||
@@ -711,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
if (_disposed || _stopped || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -941,12 +956,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DisposeAndNull(ref RegistryWatcher? w)
|
||||
// Caller holds _stateLock; the taken watchers are disposed after it is released.
|
||||
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
|
||||
{
|
||||
if (w == null) return false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
if (slot == null) return;
|
||||
taken.Add(slot);
|
||||
slot = null;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
|
||||
+237
-127
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
+16
-12
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
TryArm();
|
||||
raise = TryArm();
|
||||
}
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void TryArm()
|
||||
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
|
||||
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
|
||||
private bool TryArm()
|
||||
{
|
||||
if (_disposed || _fsw != null) return;
|
||||
if (_disposed || _fsw != null) return false;
|
||||
|
||||
// Initial scan — the binaries may already be present (armed late / update over existing).
|
||||
if (CoreBinariesPresent())
|
||||
{
|
||||
Raise();
|
||||
return;
|
||||
}
|
||||
if (CoreBinariesPresent()) return true;
|
||||
|
||||
if (!Directory.Exists(_installationPath))
|
||||
{
|
||||
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
|
||||
|
||||
// Race: a binary may have appeared between the scan above and arming — re-scan once.
|
||||
if (CoreBinariesPresent()) Raise();
|
||||
return CoreBinariesPresent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArmRetry(object? state)
|
||||
{
|
||||
lock (_lock) { TryArm(); }
|
||||
bool raise;
|
||||
lock (_lock) { raise = TryArm(); }
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
|
||||
|
||||
+132
-52
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
|
||||
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
|
||||
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
|
||||
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
|
||||
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => Start(armWmi: true);
|
||||
|
||||
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
|
||||
internal void Start(bool armWmi)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
// 1) Startup probe — catch a worker already running before the subscription exists. It
|
||||
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
|
||||
// reported even when the subscription below never completes.
|
||||
ProbeRunningWorkers();
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
|
||||
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
|
||||
// after another on it).
|
||||
if (armWmi) Task.Run(() => ArmStartTrace());
|
||||
}
|
||||
|
||||
private void ProbeRunningWorkers()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
foreach (var proc in WorkerSource())
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
private void ArmStartTrace()
|
||||
{
|
||||
ManagementEventWatcher? watcher = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
watcher = new ManagementEventWatcher(new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
|
||||
watcher.EventArrived += OnProcessStartTrace;
|
||||
watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
if (watcher != null) TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
// Published only once it is armed, so Dispose never meets a half-started subscription; one
|
||||
// that completes after Dispose is taken down right here.
|
||||
bool keep;
|
||||
lock (_lock)
|
||||
{
|
||||
keep = !_disposed;
|
||||
if (keep) _startWatcher = watcher;
|
||||
}
|
||||
if (!keep)
|
||||
{
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
|
||||
// A worker may have started between the probe in Start and the subscription.
|
||||
ProbeRunningWorkers();
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -128,39 +181,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool tracked = false;
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
if (!_disposed && !_workers.ContainsKey(proc.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
tracked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
if (tracked)
|
||||
{
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!tracked)
|
||||
{
|
||||
// Disposed, already tracked under another handle, or gone before the hookup.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +231,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
|
||||
// .NET raises Exited once per Process object. A worker that exited before the hookup
|
||||
// above was subscribed would stay tracked forever and the window would never close.
|
||||
if (HasExitedSafe(proc))
|
||||
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
@@ -178,11 +245,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
|
||||
// and TrackWorker's own re-check can deliver the same exit a second time.
|
||||
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
|
||||
return;
|
||||
_workers.Remove(SafePid(proc));
|
||||
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
@@ -200,6 +268,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
@@ -260,31 +331,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
private static bool HasExitedSafe(Process proc)
|
||||
{
|
||||
try { return proc.HasExited; } catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
Process[] workers;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
CancelSettleTimer();
|
||||
workers = new Process[_workers.Count];
|
||||
_workers.Values.CopyTo(workers, 0);
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
try { worker.Exited -= OnWorkerExited; } catch { }
|
||||
try { worker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-28
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
|
||||
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
|
||||
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
|
||||
/// an owner that disposes this watcher under a lock its handler also takes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
// The registration must outlive the thread that issued it: re-arming happens on rotating
|
||||
// ThreadPool threads, and without this flag Windows signals the event when such a thread
|
||||
// ends — reported here as a change that never happened.
|
||||
ThreadAgnostic = 0x10000000,
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
private const RegNotifyFilter PrimaryFilter =
|
||||
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
|
||||
// Cheap: direct-child create/delete only.
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly RegistryHive _hive;
|
||||
private readonly string _hiveLabel;
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
: this(RegistryHive.LocalMachine, targetSubKey, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
|
||||
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_hive = hive;
|
||||
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The filter the full target watch is armed with (tests).</summary>
|
||||
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
raise = ArmBestEffort(targetIsNews: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
|
||||
/// for it — after releasing the lock.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
/// <param name="targetIsNews">False when finding the target right away is the expected start
|
||||
/// state; true when the watch was waiting for it.</param>
|
||||
private bool ArmBestEffort(bool targetIsNews)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
if (_disposed || _baseKey == null) return false;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
|
||||
return false;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
return recheck != null && ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
bool raise;
|
||||
bool rearmAfterHandler;
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
rearmAfterHandler = _mode == WatchMode.Primary;
|
||||
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
|
||||
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
|
||||
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
|
||||
if (!rearmAfterHandler) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Re-arm on the same target key, and only now: no wait was registered while the handler
|
||||
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
|
||||
// registration at once.
|
||||
ArmNotify(subtree: true, PrimaryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Never called with _lock held.
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
|
||||
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
ThrowOnSessionGone(response, correlationId);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
|
||||
/// session was deleted or is being deleted. Only a response that echoes this request's
|
||||
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
|
||||
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
|
||||
/// </summary>
|
||||
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
if (response.StatusCode != HttpStatusCode.Gone)
|
||||
return;
|
||||
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
|
||||
return;
|
||||
|
||||
throw new BackendSessionGoneException(
|
||||
"Backend returned 410 Gone: the session was deleted or is being deleted.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
|
||||
/// or a non-string value yields null, never an exception.
|
||||
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
|
||||
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
|
||||
/// </summary>
|
||||
public class BackendSessionGoneException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (410).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendSessionGoneException(string message, int statusCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
|
||||
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return;
|
||||
}
|
||||
|
||||
// The session-unknown report is terminal and sent at most once per process (the
|
||||
// A session-gone report is terminal and sent at most once per process (the
|
||||
// termination follows immediately): a routine report minutes earlier must not cost
|
||||
// the last word about the session. Only the cooldown is waived; key dedup and the
|
||||
// per-session maximum still apply.
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|
||||
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
|
||||
if (!exemptFromCooldown && _lastReportTime != null &&
|
||||
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
|
||||
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
|
||||
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
|
||||
/// this only ever ends the wait for a host that hangs.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _stateDirectory;
|
||||
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly int _quarantineThreshold;
|
||||
private readonly TimeSpan _drainInterval;
|
||||
private readonly TimeSpan _terminalDrainTimeout;
|
||||
private readonly TimeSpan _collectorHostStopTimeout;
|
||||
private readonly TimeSpan? _agentMaxLifetime;
|
||||
private readonly int _uploadBatchSize;
|
||||
|
||||
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private SessionTraceOrdinalProvider? _traceCounter;
|
||||
private SignalIngress? _ingress;
|
||||
private IReadOnlyList<ICollectorHost>? _collectorHosts;
|
||||
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
|
||||
// so nothing calls into them again. Locked on itself.
|
||||
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
|
||||
|
||||
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
|
||||
|
||||
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null,
|
||||
TimeSpan? collectorHostStopTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_quarantineThreshold = quarantineThreshold;
|
||||
_drainInterval = drainInterval ?? DefaultDrainInterval;
|
||||
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
|
||||
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
|
||||
|
||||
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
|
||||
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
|
||||
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
|
||||
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
|
||||
/// <para>
|
||||
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
|
||||
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
|
||||
/// self-destruct come after this call, so it has to return whatever a collector does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void StopCollectorHosts()
|
||||
{
|
||||
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
|
||||
RunHostStepBounded(host, "stop", h => h.Stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
|
||||
/// <see cref="_collectorHostStopTimeout"/> for it.
|
||||
/// </summary>
|
||||
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
|
||||
{
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
if (_unresponsiveHosts.Contains(host)) return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
try { action(host); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
})
|
||||
{
|
||||
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
|
||||
// be the very thing that is stuck. Background, so it cannot keep the process alive.
|
||||
IsBackground = true,
|
||||
Name = $"CollectorHost.{step}:{host.Name}",
|
||||
};
|
||||
try { worker.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (worker.Join(_collectorHostStopTimeout))
|
||||
{
|
||||
if (failure == null) return;
|
||||
if (step == "stop")
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
|
||||
else
|
||||
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
_unresponsiveHosts.Add(host);
|
||||
}
|
||||
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
|
||||
"termination continues without it.");
|
||||
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
|
||||
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
|
||||
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
|
||||
/// Dispose is logged only — the ingress is already down by then.)
|
||||
/// </summary>
|
||||
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
|
||||
{
|
||||
var ingress = _ingress;
|
||||
if (ingress == null) return;
|
||||
try
|
||||
{
|
||||
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = Shared.Models.EventSeverity.Warning,
|
||||
Source = "EnrollmentOrchestrator",
|
||||
Phase = Shared.Models.EnrollmentPhase.Unknown,
|
||||
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
|
||||
Timestamp = _clock.UtcNow,
|
||||
Data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "collector_host_stop_timeout",
|
||||
["host"] = hostName,
|
||||
["timeoutSeconds"] = timeoutSeconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
|
||||
|
||||
// 7) Dispose aller Disposables.
|
||||
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
|
||||
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
|
||||
if (_collectorHosts != null)
|
||||
{
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort */ }
|
||||
RunHostStepBounded(host, "dispose", h => h.Dispose());
|
||||
}
|
||||
}
|
||||
try { _transport?.Dispose(); } catch { /* best-effort */ }
|
||||
|
||||
+64
-23
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
// Guards the fields below, nothing else. Never held across a call into a watcher or the
|
||||
// detector: their callbacks come back into this host and take it.
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private readonly OfficeInstallStateData? _resumeState;
|
||||
private readonly RegistryHive _registryHive;
|
||||
private readonly string _registrySubKey;
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private OfficeBinaryWatcher? _binaryWatcher;
|
||||
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
|
||||
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence = null,
|
||||
OfficeInstallStateData? resumeState = null)
|
||||
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
|
||||
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: points the registry watcher at a key a test may write to and feeds the
|
||||
/// detector synthetic snapshots. Production uses the constructor above.
|
||||
/// </summary>
|
||||
internal OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence,
|
||||
OfficeInstallStateData? resumeState,
|
||||
RegistryHive registryHive,
|
||||
string registrySubKey,
|
||||
Func<OfficeC2RSnapshot>? snapshotProvider)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_resumeState = resumeState;
|
||||
_registryHive = registryHive;
|
||||
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
|
||||
onInstallationPathObserved: OnInstallationPathObserved,
|
||||
statePersistence: statePersistence);
|
||||
statePersistence: statePersistence)
|
||||
{
|
||||
SnapshotProvider = snapshotProvider,
|
||||
};
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
}
|
||||
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
lock (_lock)
|
||||
{
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
|
||||
registryWatcher.Changed += OnRegistryChanged;
|
||||
lock (_lock) { _registryWatcher = registryWatcher; }
|
||||
registryWatcher.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
|
||||
bool raise;
|
||||
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
|
||||
if (raise)
|
||||
// A callback can still be running after Dispose returned — Dispose never waits for one.
|
||||
if (raise && Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
|
||||
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnInstallationPathObserved(string? installationPath)
|
||||
{
|
||||
OfficeBinaryWatcher watcher;
|
||||
lock (_lock)
|
||||
{
|
||||
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
|
||||
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
|
||||
if (Volatile.Read(ref _disposed) == 1) return;
|
||||
|
||||
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher.Start();
|
||||
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
watcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher = watcher;
|
||||
}
|
||||
|
||||
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
|
||||
// ends in DisposeWatchers.
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnBinaryAppeared(object? sender, EventArgs e)
|
||||
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
RegistryChangeWatcher? registryWatcher;
|
||||
OfficeBinaryWatcher? binaryWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { _registryWatcher.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
if (_binaryWatcher != null)
|
||||
{
|
||||
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { _binaryWatcher.Dispose(); } catch { }
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
registryWatcher = _registryWatcher;
|
||||
_registryWatcher = null;
|
||||
binaryWatcher = _binaryWatcher;
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
|
||||
if (registryWatcher != null)
|
||||
{
|
||||
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { registryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
if (binaryWatcher != null)
|
||||
{
|
||||
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { binaryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// authorized agent.</item>
|
||||
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</item>
|
||||
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
|
||||
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
|
||||
/// enrollment is only now continuing) the session is rotated like above and registered
|
||||
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
|
||||
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
|
||||
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
|
||||
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
|
||||
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
|
||||
/// </list>
|
||||
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
/// <param name="rotateOnSessionGone">
|
||||
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
|
||||
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null,
|
||||
Func<string> rotateSession = null)
|
||||
Func<string> rotateSession = null,
|
||||
bool rotateOnSessionGone = false)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
string rotatedFromSessionId = null;
|
||||
string rotationReason = null;
|
||||
|
||||
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
|
||||
{
|
||||
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
if (response != null && response.Success)
|
||||
{
|
||||
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
|
||||
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
|
||||
}
|
||||
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
rotationReason = SessionRotationReasons.OwnerMismatch;
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendSessionGoneException ex)
|
||||
{
|
||||
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
|
||||
{
|
||||
var goneSessionId = registration.SessionId;
|
||||
string newSessionId = null;
|
||||
try
|
||||
{
|
||||
newSessionId = rotateSession();
|
||||
}
|
||||
catch (Exception rotateEx)
|
||||
{
|
||||
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
|
||||
}
|
||||
|
||||
if (newSessionId != null)
|
||||
{
|
||||
rotatedFromSessionId = goneSessionId;
|
||||
rotationReason = SessionRotationReasons.SessionGone;
|
||||
logger.Warning(
|
||||
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
|
||||
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
|
||||
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
|
||||
/// caller ends monitoring and cleans up.
|
||||
/// </summary>
|
||||
SessionGone = 3,
|
||||
}
|
||||
|
||||
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
|
||||
public static class SessionRotationReasons
|
||||
{
|
||||
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
|
||||
public const string OwnerMismatch = "owner_mismatch";
|
||||
|
||||
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
|
||||
public const string SessionGone = "session_gone";
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
|
||||
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
|
||||
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
|
||||
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
|
||||
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
|
||||
/// registration. Null on the normal path.
|
||||
/// </summary>
|
||||
public string RotatedFromSessionId { get; }
|
||||
|
||||
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
|
||||
public string RotationReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the run ended on a 401/403 that something on the network path answered (TLS
|
||||
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
|
||||
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
int httpStatusCode,
|
||||
string errorMessage,
|
||||
string rotatedFromSessionId = null,
|
||||
bool isIntercepted = false)
|
||||
bool isIntercepted = false,
|
||||
string rotationReason = null)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Response = response;
|
||||
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
ErrorMessage = errorMessage;
|
||||
RotatedFromSessionId = rotatedFromSessionId;
|
||||
IsIntercepted = isIntercepted;
|
||||
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
|
||||
}
|
||||
|
||||
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
|
||||
public static SessionRegistrationResult Succeeded(
|
||||
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
|
||||
=> new SessionRegistrationResult(
|
||||
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
|
||||
|
||||
public static SessionRegistrationResult SessionGone(int statusCode, string message)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
|
||||
|
||||
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
|
||||
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
|
||||
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
|
||||
// after `diagnostics_collecting` and the snapshot captured in the package
|
||||
// matches the timeline. Best-effort — never blocks termination.
|
||||
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
|
||||
// a collector that hangs is left behind, so the upload and the cleanup below
|
||||
// still run.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
|
||||
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
|
||||
/// previous session already completed enrollment — written by
|
||||
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
|
||||
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
|
||||
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
|
||||
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
|
||||
/// <para>
|
||||
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
|
||||
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
|
||||
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
|
||||
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
|
||||
/// break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
|
||||
/// so all three leave the device in the same state. Never throws.
|
||||
/// and the session-gone end force it), and deletes the persisted session files. Shared by
|
||||
/// the session-age emergency break, the intercepted-registration give-up
|
||||
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
|
||||
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
|
||||
/// so all four leave the device in the same state. Never throws.
|
||||
/// </summary>
|
||||
internal static void EndMonitoringAtStart(
|
||||
string stateDirectory,
|
||||
|
||||
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
|
||||
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
|
||||
// starts than the buffer allows cleans up like the emergency break (state folder +
|
||||
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
|
||||
// cleanup factory) and still exits 6. A registration the backend answers with 410
|
||||
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
|
||||
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
|
||||
var registration = Runtime.BackendSessionRegistration.Register(
|
||||
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
|
||||
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
|
||||
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
sessionPersistence: bootstrap.SessionPersistence,
|
||||
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
|
||||
stateDirectory: stateSubdir,
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
|
||||
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
|
||||
if (registration.ShouldExit)
|
||||
{
|
||||
return registration.ExitCode;
|
||||
|
||||
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
|
||||
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
|
||||
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
|
||||
priorRegistrationFailures: registration.PriorFailures);
|
||||
priorRegistrationFailures: registration.PriorFailures,
|
||||
sessionRotationReason: registrationResult?.RotationReason);
|
||||
// Wire-visible signal that the agent is running on defaults/cache
|
||||
// rather than the live tenant config — closes the cold-start
|
||||
// blind spot observed in session 8f2bef72 (2026-05-19).
|
||||
|
||||
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// the next boot. The failed run is recorded for the emergency break report. A registration
|
||||
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
|
||||
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
|
||||
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
|
||||
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
|
||||
/// session instead, because its enrollment is only now continuing.
|
||||
/// A successful registration after failed starts reports them on <c>agent_started</c>
|
||||
/// (<see cref="ClearAfterSuccess"/>).
|
||||
/// </summary>
|
||||
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
|
||||
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
|
||||
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
|
||||
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
|
||||
public static SessionRegistrationOutcomeResult Register(
|
||||
AgentConfiguration agentConfig,
|
||||
BackendAuthBundle auth,
|
||||
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
SessionIdPersistence sessionPersistence = null,
|
||||
string configFetchOutcome = null,
|
||||
string stateDirectory = null,
|
||||
Func<CleanupService> cleanupServiceFactory = null)
|
||||
Func<CleanupService> cleanupServiceFactory = null,
|
||||
bool isWhiteGloveResume = false)
|
||||
{
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (auth == null) throw new ArgumentNullException(nameof(auth));
|
||||
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Reuse the single hardened hardware read from the auth bundle so the session row
|
||||
// matches the security headers the backend validated against (no second WMI query).
|
||||
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
|
||||
rotateSession: rotateSession)
|
||||
rotateSession: rotateSession,
|
||||
rotateOnSessionGone: isWhiteGloveResume)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
|
||||
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
|
||||
}
|
||||
|
||||
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
|
||||
{
|
||||
// The report goes out through the auth bundle's client: dispose only afterwards.
|
||||
var exitCode = ConcludeSessionGone(
|
||||
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
|
||||
cleanupServiceFactory, consoleMode, logger);
|
||||
try { mtlsHttpClient?.Dispose(); } catch { }
|
||||
try { auth.BackendApiClient?.Dispose(); } catch { }
|
||||
return SessionRegistrationOutcomeResult.Exit(exitCode);
|
||||
}
|
||||
|
||||
var record = RecordFailedRun(
|
||||
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
|
||||
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
|
||||
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end of a start whose registration the backend answered with 410: the session was
|
||||
/// deleted. One report over the emergency channel (a deleted session has no timeline to
|
||||
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
|
||||
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
|
||||
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
|
||||
/// </summary>
|
||||
internal static int ConcludeSessionGone(
|
||||
AgentConfiguration agentConfig,
|
||||
EmergencyReporter emergencyReporter,
|
||||
SessionIdPersistence sessionPersistence,
|
||||
string stateDirectory,
|
||||
Func<CleanupService> cleanupServiceFactory,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
logger.Error(
|
||||
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
|
||||
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
|
||||
if (consoleMode)
|
||||
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
|
||||
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.RegistrationRejectedSessionGone,
|
||||
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
|
||||
logger);
|
||||
|
||||
Program.EndMonitoringAtStart(
|
||||
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
|
||||
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
|
||||
cleanupReason: "session_gone");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
|
||||
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
|
||||
|
||||
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentLogger logger,
|
||||
string rotatedFromSessionId = null,
|
||||
PriorRegistrationFailures priorRegistrationFailures = null)
|
||||
PriorRegistrationFailures priorRegistrationFailures = null,
|
||||
string sessionRotationReason = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
|
||||
// id (session_owner_mismatch) and this run rotated to a new session before
|
||||
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
|
||||
// True when the backend refused the persisted session id and this run rotated
|
||||
// to a new session before registering: session_owner_mismatch (re-enroll
|
||||
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
|
||||
// sessionRotationReason below names which.
|
||||
{ "sessionRotated", rotatedFromSessionId != null },
|
||||
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
|
||||
data["sessionRotationReason"] = sessionRotationReason;
|
||||
|
||||
// Which bootstrap installed this agent (installer and loader versions, MSI or
|
||||
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
|
||||
// fleet's share of old or directly assigned installers countable.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
// Bounded blocking send for the one session-gone report: the terminate that follows ends
|
||||
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
|
||||
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
|
||||
private const int SessionGoneReportAttempts = 2;
|
||||
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
|
||||
|
||||
/// <summary>
|
||||
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
|
||||
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
|
||||
|
||||
if (emergencyReporter != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: SessionGoneReportAttempts,
|
||||
perAttemptTimeout: SessionGoneReportTimeout,
|
||||
retryDelay: SessionGoneReportRetryDelay)
|
||||
.Wait(SessionGoneReportBudget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
SessionGoneReport.SendBlocking(
|
||||
emergencyReporter,
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
logger);
|
||||
|
||||
var terminateAction = new ServerAction
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
|
||||
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
|
||||
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
|
||||
/// the caller ends the process right after and the cleanup deletes the agent, so a
|
||||
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
|
||||
/// reservation (ONE report). Never throws.
|
||||
/// </summary>
|
||||
internal static class SessionGoneReport
|
||||
{
|
||||
private const int Attempts = 2;
|
||||
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
|
||||
|
||||
public static void SendBlocking(
|
||||
EmergencyReporter emergencyReporter,
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (emergencyReporter == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
errorType,
|
||||
message,
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: Attempts,
|
||||
perAttemptTimeout: PerAttemptTimeout,
|
||||
retryDelay: RetryDelay)
|
||||
.Wait(Budget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,39 @@ public class AuthFunctionTests
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_MemberWithARoleGetsTheCompanyName()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = " Contoso Ltd. ";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_RolelessMemberGetsNoCompanyName()
|
||||
{
|
||||
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = "Contoso Ltd.";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null((string?)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -202,6 +202,28 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_SetupGuideMarkers_NoSnapshot()
|
||||
{
|
||||
// The setup guide's markers would otherwise push real settings out of the two backup slots.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
|
||||
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
|
||||
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
|
||||
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
|
||||
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
|
||||
/// tenant, manufacturer "Contoso", model "TestBook".
|
||||
/// </summary>
|
||||
public sealed class ConfigConfirmBeforeRefusalTests
|
||||
{
|
||||
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
|
||||
|
||||
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
|
||||
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
AllowInsecureAgentRequests = true,
|
||||
ManufacturerWhitelist = "*",
|
||||
ModelWhitelist = "*",
|
||||
};
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
|
||||
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
|
||||
/// service, a confirming read makes the stored row the cached one.
|
||||
/// </summary>
|
||||
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var current = cached;
|
||||
|
||||
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
|
||||
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
|
||||
{
|
||||
if (stored != null) current = stored;
|
||||
return stored;
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
var validator = new SecurityValidator(
|
||||
configService: config.Object,
|
||||
adminConfigService: adminConfig.Object,
|
||||
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
logger: Mock.Of<ILogger>(),
|
||||
bootstrapSessionService: null,
|
||||
deviceAssociationValidator: null);
|
||||
return new Gate(validator, config, new Dictionary<object, object>());
|
||||
}
|
||||
|
||||
private static Task<SecurityValidationResult> Validate(Gate gate)
|
||||
=> gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
|
||||
|
||||
private static string? Stamp(Gate gate)
|
||||
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
|
||||
|
||||
// ---------------------------------------------------------------- a refusal is confirmed first
|
||||
|
||||
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
|
||||
{
|
||||
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
|
||||
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
|
||||
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
|
||||
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
|
||||
{
|
||||
var gate = Build(cached: outdated, stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(OutdatedCopies))]
|
||||
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
|
||||
{
|
||||
// Inside the confirm interval, or storage failed: nothing newer to decide on.
|
||||
var gate = Build(cached: refusing, stored: null);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(refusal, result.ErrorMessage);
|
||||
Assert.Null(Stamp(gate));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the hot path stays untouched
|
||||
|
||||
[Fact]
|
||||
public async Task A_request_the_cached_copy_admits_reads_nothing()
|
||||
{
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
|
||||
{
|
||||
// A certificate of another tenant: the configuration admits, the certificate does not.
|
||||
var gate = Build(cached: Admitting(), stored: Admitting());
|
||||
|
||||
var result = await gate.Validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
|
||||
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Null(Stamp(gate));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the device validation chain
|
||||
|
||||
[Fact]
|
||||
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
|
||||
{
|
||||
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
|
||||
// nothing admits the device, on either pass. What counts here is the control flow.
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
|
||||
var gate = Build(cached, stored);
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
|
||||
{
|
||||
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
|
||||
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
|
||||
|
||||
var result = await Validate(gate);
|
||||
|
||||
Assert.Equal("Device not registered", result.ErrorMessage);
|
||||
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
|
||||
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
|
||||
{
|
||||
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
|
||||
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
|
||||
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
|
||||
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the patch marker
|
||||
|
||||
[Fact]
|
||||
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
|
||||
{
|
||||
static PatchOutcome Applied(params string[] changed)
|
||||
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
|
||||
|
||||
// Wire names are camelCase, applied names are the model's property names.
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
|
||||
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
|
||||
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
|
||||
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
|
||||
}
|
||||
}
|
||||
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
// ---- RecordSetupGuideStep ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetupGuideStateResponse_matches_the_marker_shape()
|
||||
{
|
||||
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
|
||||
DateTime? dismissedAt = null;
|
||||
string? dismissedBy = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
settingsReviewedAt,
|
||||
hardwareReviewedAt,
|
||||
dismissedAt,
|
||||
dismissedBy
|
||||
},
|
||||
new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = settingsReviewedAt,
|
||||
HardwareReviewedAt = hardwareReviewedAt,
|
||||
DismissedAt = null,
|
||||
DismissedBy = null
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.DependencyCollector;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
|
||||
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
|
||||
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
|
||||
/// and both credential clients (webhooks, push) carry the mark.
|
||||
/// </summary>
|
||||
public class CredentialUrlRedactionProcessorTests
|
||||
{
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Received { get; } = new();
|
||||
public void Process(ITelemetry item) => Received.Add(item);
|
||||
}
|
||||
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string? EndpointAddress { get; set; }
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
|
||||
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
processor.Process(row);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
}
|
||||
}
|
||||
|
||||
private static DependencyTelemetry Row(string url, string method = "POST")
|
||||
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
|
||||
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
|
||||
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
|
||||
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
|
||||
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
|
||||
{
|
||||
var dependency = Row(url);
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("discord.com", dependency.Data);
|
||||
Assert.Equal("POST discord.com", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_a_marked_request_are_untouched()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
|
||||
|
||||
processor.Process(dependency);
|
||||
|
||||
Assert.Same(dependency, Assert.Single(next.Received));
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mark_covers_the_request_and_ends_with_it()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
|
||||
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
|
||||
{
|
||||
InnerHandler = new TrackingInnerHandler(processor, inside),
|
||||
});
|
||||
|
||||
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
|
||||
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
|
||||
processor.Process(after);
|
||||
|
||||
Assert.Equal("https://discord.com", inside.Data);
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
using var configuration = new TelemetryConfiguration
|
||||
{
|
||||
TelemetryChannel = channel,
|
||||
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
|
||||
using var collector = new DependencyTrackingTelemetryModule();
|
||||
collector.Initialize(configuration);
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = AnswerNoContentAsync(listener, connections: 2);
|
||||
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
|
||||
|
||||
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
|
||||
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
|
||||
using (var plain = new HttpClient(new SocketsHttpHandler()))
|
||||
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
|
||||
await server;
|
||||
|
||||
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
|
||||
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WebhookNotificationService))]
|
||||
[InlineData(PushHttpClientRegistration.ClientName)]
|
||||
public void Both_credential_clients_carry_the_mark(string clientName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
services.AddPushSender();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var handlers = new List<HttpMessageHandler>();
|
||||
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
|
||||
handler != null;
|
||||
handler = (handler as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
handlers.Add(handler);
|
||||
}
|
||||
|
||||
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
|
||||
}
|
||||
|
||||
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
|
||||
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
using var socket = await listener.AcceptTcpClientAsync();
|
||||
var stream = socket.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var received = new StringBuilder();
|
||||
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
// The request body ("{}") follows the headers; the 204 does not wait for it.
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,4 +640,116 @@ public class DistressValidationTests
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
|
||||
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
|
||||
// the whitelist is stored as HardwareNotAllowed.
|
||||
// =========================================================================
|
||||
|
||||
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(ResolveTenant);
|
||||
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static DistressErrorType Resolve(
|
||||
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
|
||||
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
|
||||
DistressCertSourceState? certState = null)
|
||||
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Latitude 5440")]
|
||||
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
|
||||
[InlineData("HP", "EliteBook 840 G10")]
|
||||
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
|
||||
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "VMware20,1")]
|
||||
[InlineData("VMware, Inc.", null)]
|
||||
[InlineData("", "")]
|
||||
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
|
||||
{
|
||||
// The validator refuses a suspended tenant before it looks at the hardware.
|
||||
var config = ValidatingTenant(c => c.Disabled = true);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c =>
|
||||
{
|
||||
c.ValidateAutopilotDevice = false;
|
||||
c.AllowInsecureAgentRequests = true;
|
||||
});
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
|
||||
{
|
||||
// Without a certificate the agent authenticates with a bootstrap token, which skips the
|
||||
// hardware check.
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered,
|
||||
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
|
||||
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
|
||||
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
|
||||
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
|
||||
{
|
||||
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
|
||||
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Graph;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
|
||||
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
|
||||
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
|
||||
/// normal case and carries neither the flag nor a marker.
|
||||
/// </summary>
|
||||
public class GetScriptDisplayNamesFunctionTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-4000-8000-000000000002";
|
||||
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, null)]
|
||||
[InlineData(true, false, "timedOut")]
|
||||
[InlineData(false, true, "retry")]
|
||||
[InlineData(true, true, "retryTimedOut")]
|
||||
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
|
||||
bool timedOut, bool isFollowUp, string? expectedMarker)
|
||||
{
|
||||
var resolver = new Mock<IScriptDisplayNameResolver>();
|
||||
resolver
|
||||
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
|
||||
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
|
||||
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
|
||||
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
|
||||
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
|
||||
// The key is left out, not false, when no time limit fired.
|
||||
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
|
||||
if (timedOut) Assert.True(flag.GetBoolean());
|
||||
|
||||
if (expectedMarker == null)
|
||||
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
|
||||
else
|
||||
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,11 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
|
||||
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
|
||||
"setupGuideDismissed",
|
||||
"setupGuideHardwareDone",
|
||||
"setupGuideSettingsDone",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
@@ -520,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
|
||||
Assert.DoesNotContain("Secret Holdings", json);
|
||||
}
|
||||
|
||||
// ── setup guide ──
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
|
||||
|
||||
Assert.False(payload.SetupGuideSettingsDone);
|
||||
Assert.False(payload.SetupGuideHardwareDone);
|
||||
Assert.False(payload.SetupGuideDismissed);
|
||||
Assert.False(payload.HasEnrollmentSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
|
||||
Assert.True(payload.SetupGuideSettingsDone);
|
||||
Assert.True(payload.SetupGuideHardwareDone);
|
||||
Assert.True(payload.SetupGuideDismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
|
||||
{
|
||||
var element = Serialize(TenantConfiguration.CreateDefault("t"));
|
||||
|
||||
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("hidden.admin", json);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
|
||||
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
|
||||
}
|
||||
|
||||
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
|
||||
public HashSet<string> HangingTenants { get; } = new();
|
||||
|
||||
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
|
||||
public Exception? TokenContextThrows { get; set; }
|
||||
|
||||
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (TokenContextThrows != null) throw TokenContextThrows;
|
||||
if (HangingTenants.Contains(tenantId))
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||
}
|
||||
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
||||
return Task.FromResult(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
|
||||
|
||||
+165
-17
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Null(result[refs[0]]);
|
||||
Assert.Single(result.Names);
|
||||
Assert.Null(result.Names[refs[0]]);
|
||||
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
|
||||
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
|
||||
fix.Detector.HangingTenants.Add(TenantId);
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
var line = Assert.Single(fix.IncompleteLines);
|
||||
Assert.Contains($"tenant={TenantId} ", line);
|
||||
Assert.Contains("reasons=TokenTimeout ", line);
|
||||
Assert.Contains("requested=1 ", line);
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("MyScript.ps1", result[r]);
|
||||
Assert.Equal("MyScript.ps1", result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Bootstrap", result[r]);
|
||||
Assert.Equal("Bootstrap", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
// Both list entries cached for future calls.
|
||||
Assert.Equal(2, fix.Repo.Entries.Count);
|
||||
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
|
||||
{
|
||||
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
|
||||
// as "not found" would drop their names.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
|
||||
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
|
||||
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
|
||||
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
|
||||
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
|
||||
|
||||
Assert.Equal("Listed", result.Names[listed]);
|
||||
Assert.Equal("Unlisted", result.Names[unlisted]);
|
||||
Assert.Equal(2, fix.Http.Requests.Count);
|
||||
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines);
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Http.WhenTimedOut("deviceManagementScripts?");
|
||||
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
|
||||
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "abc");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
|
||||
{
|
||||
// A list cut after its first page is incomplete. Stored together with the meta row it
|
||||
// would count as the tenant's whole list for a week.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
using var caller = new CancellationTokenSource();
|
||||
fix.Http.OnRequest = _ => caller.Cancel();
|
||||
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
|
||||
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
|
||||
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
|
||||
var r = new ScriptRef(ScriptKind.Platform, "id-1");
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
|
||||
|
||||
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries);
|
||||
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Equal("Net-New", result[r]);
|
||||
Assert.Equal("Net-New", result.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
|
||||
// Per-ID fetch also writes to cache.
|
||||
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
|
||||
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
||||
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
|
||||
{
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
||||
};
|
||||
fix.Http.WhenTimedOut("deviceHealthScripts/first");
|
||||
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
|
||||
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
|
||||
var refs = new[]
|
||||
{
|
||||
new ScriptRef(ScriptKind.Remediation, "first"),
|
||||
new ScriptRef(ScriptKind.Remediation, "second"),
|
||||
};
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests);
|
||||
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
|
||||
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.True(result.TimedOut);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
|
||||
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(first[r]);
|
||||
Assert.Null(second[r]);
|
||||
Assert.Null(first.Names[r]);
|
||||
Assert.Null(second.Names[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
|
||||
Assert.False(entry.IsNotFound);
|
||||
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
|
||||
|
||||
Assert.Empty(result);
|
||||
Assert.Empty(result.Names);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]); // per-ID never ran
|
||||
Assert.Null(result.Names[r]); // per-ID never ran
|
||||
Assert.Single(fix.Http.Requests); // only the failed full-pull
|
||||
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, refs);
|
||||
|
||||
Assert.All(refs, r => Assert.Null(result[r]));
|
||||
Assert.All(refs, r => Assert.Null(result.Names[r]));
|
||||
Assert.Single(fix.Http.Requests); // second call never issued
|
||||
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
|
||||
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
|
||||
|
||||
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(result[r]);
|
||||
Assert.Null(result.Names[r]);
|
||||
Assert.Empty(fix.Http.Requests);
|
||||
}
|
||||
|
||||
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
|
||||
public FakeScriptNameCacheRepository Repo { get; } = new();
|
||||
public StubHttpMessageHandler Http { get; } = new();
|
||||
public StubHttpClientFactory HttpFactory { get; }
|
||||
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
|
||||
public ScriptDisplayNameResolver Sut { get; }
|
||||
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public Fixture()
|
||||
public Fixture(TimeSpan? tokenAcquireBudget = null)
|
||||
{
|
||||
HttpFactory = new StubHttpClientFactory(Http);
|
||||
Sut = new ScriptDisplayNameResolver(
|
||||
Detector, Repo, HttpFactory,
|
||||
NullLogger<ScriptDisplayNameResolver>.Instance,
|
||||
Logger.Object,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
new FakeTimeProvider(FixedNow));
|
||||
new FakeTimeProvider(FixedNow),
|
||||
tokenAcquireBudget);
|
||||
}
|
||||
|
||||
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
|
||||
public List<string> IncompleteLines => Logger.Invocations
|
||||
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
|
||||
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
|
||||
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
public void GivenPermission()
|
||||
{
|
||||
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
|
||||
|
||||
+8
-7
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
/// <list type="bullet">
|
||||
/// <item>Positive rows older than 7 days are removed.</item>
|
||||
/// <item>Positive rows younger than 7 days are kept.</item>
|
||||
/// <item>NotFound rows older than 1 hour are removed.</item>
|
||||
/// <item>NotFound rows younger than 1 hour are kept.</item>
|
||||
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
|
||||
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deletes_notfound_rows_older_than_1_hour()
|
||||
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
|
||||
{
|
||||
// A not-found row that goes after hours instead of days sends one Graph lookup per
|
||||
// unknown ID on the first view after every cleanup run.
|
||||
var fix = new Fixture();
|
||||
var deletedId = "deleted-1h-ago";
|
||||
var recentId = "recent-30min";
|
||||
var deletedId = "not-found-8-days-ago";
|
||||
var recentId = "not-found-2-hours-ago";
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = deletedId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
|
||||
IsNotFound = true,
|
||||
};
|
||||
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
|
||||
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
|
||||
Kind = ScriptKind.Remediation,
|
||||
ScriptId = recentId,
|
||||
DisplayName = null,
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
|
||||
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
|
||||
IsNotFound = true,
|
||||
};
|
||||
|
||||
|
||||
+16
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
|
||||
private readonly List<string> _timedOut = new();
|
||||
public List<string> Requests { get; } = new();
|
||||
|
||||
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
|
||||
public Action<string>? OnRequest { get; set; }
|
||||
|
||||
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
|
||||
{
|
||||
_scripted.Add((urlSubstring, status, body));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
|
||||
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
|
||||
{
|
||||
_timedOut.Add(urlSubstring);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri!.ToString();
|
||||
Requests.Add(url);
|
||||
OnRequest?.Invoke(url);
|
||||
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
|
||||
}
|
||||
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
|
||||
if (match.Equals(default((string, HttpStatusCode, string))))
|
||||
{
|
||||
|
||||
@@ -76,6 +76,34 @@ public class GraphWireParityTests
|
||||
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
|
||||
{
|
||||
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
|
||||
// answer within the limits keeps the shapes pinned above.
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { refs = payload, timedOut = true },
|
||||
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
|
||||
{
|
||||
var options = ApiJsonOptions.Create();
|
||||
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
|
||||
|
||||
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
|
||||
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
|
||||
|
||||
Assert.True(followUp!.Retry);
|
||||
Assert.Null(first!.Retry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
|
||||
{
|
||||
|
||||
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
dpaAcceptancePending = true,
|
||||
companyName = "Contoso Ltd."
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
DpaAcceptancePending = true,
|
||||
CompanyName = "Contoso Ltd."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> is the one definition of the allowed-list entry
|
||||
/// grammar; the agent tests run its parse cases against the netstandard2.0 build, the portal runs the file
|
||||
/// against <c>allowedAccounts.ts</c>. Plus the list-level write rules of
|
||||
/// <see cref="TenantConfigValidation.ValidateLocalAdminAllowedAccounts"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAllowListParityTests
|
||||
{
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "local-admin-allow-list", "cases.json"))));
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => Fixture.Value["parse"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
public static IEnumerable<object[]> WriteEntries() => Fixture.Value["write"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Parse_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["parse"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteEntries))]
|
||||
public void New_entry_validation_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["write"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var error = LocalAdminAllowList.ValidateNewEntry(entry);
|
||||
|
||||
if (c.Value<bool>("valid"))
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present()
|
||||
{
|
||||
Assert.True(Fixture.Value["parse"]!.Count() >= 15, "cases.json lost parse cases.");
|
||||
Assert.True(Fixture.Value["write"]!.Count() >= 25, "cases.json lost write cases.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builtin_catalog_keeps_the_four_rid_accounts_and_the_standard_user_system_accounts()
|
||||
{
|
||||
var rid = LocalAdminAllowList.BuiltIns.Where(b => b.Match == LocalAdminBuiltInMatch.Rid).ToDictionary(b => b.Rid!.Value, b => b.Role);
|
||||
Assert.Equal(LocalAccountRole.Any, rid[500]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[501]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[503]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[504]);
|
||||
|
||||
string RoleOf(string name) => LocalAdminAllowList.BuiltIns
|
||||
.Single(b => b.Match == LocalAdminBuiltInMatch.AccountName && b.Name == name).Role.ToString();
|
||||
Assert.Equal("StandardUser", RoleOf("WsiAccount"));
|
||||
Assert.Equal("StandardUser", RoleOf("kioskUser0"));
|
||||
Assert.Equal("StandardUser", RoleOf("Administrator"));
|
||||
Assert.Equal("Any", RoleOf("defaultuser0"));
|
||||
}
|
||||
|
||||
// ── list-level write rules ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[]")]
|
||||
[InlineData("[\"adm-*\"]", "")]
|
||||
[InlineData("[\"*\"]", "[\"*\"]")]
|
||||
public void Valid_or_unchanged_lists_pass(string? stored, string candidate)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_entry_is_never_rechecked_when_another_entry_is_added()
|
||||
{
|
||||
// Lists written before the grammar existed (a bare "*", an Entra SID) stay saveable.
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\",\"kiosk|user\"]",
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\"]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"a\":1}", "JSON array")]
|
||||
[InlineData("[1,2]", "JSON array")]
|
||||
[InlineData("not json", "JSON array")]
|
||||
[InlineData("[\"*\"]", "too broad")]
|
||||
[InlineData("[\"kiosk|admin\"]", "is not valid")]
|
||||
[InlineData("[\"kiosk\",\"kiosk|user\"]", "listed twice")]
|
||||
[InlineData("[\"Kiosk\",\"kiosk\"]", "listed twice")]
|
||||
public void Invalid_new_entries_are_rejected(string candidate, string expected)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expected, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_entry_repeating_a_stored_pattern_is_rejected()
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[\"WsiAccount\",\"WsiAccount|user\"]", "[\"WsiAccount\"]");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("listed twice", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_entry_count_is_capped()
|
||||
{
|
||||
var entries = Enumerable.Range(0, LocalAdminAllowList.MaxEntries + 1).Select(i => $"\"svc{i:D3}\"");
|
||||
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[" + string.Join(",", entries) + "]", null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains($"at most {LocalAdminAllowList.MaxEntries}", error);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return dir ?? throw new DirectoryNotFoundException("Could not locate repo root (AutopilotMonitor.sln).");
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ public class PerPartitionFanOutMergeTests
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
|
||||
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
|
||||
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
|
||||
/// </summary>
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
|
||||
|
||||
private static void StoreRows(Harness h)
|
||||
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
|
||||
|
||||
private static int Reads(Harness h, string tenantId)
|
||||
=> h.ConfigRepo.Invocations.Count(i =>
|
||||
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
|
||||
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[Fact]
|
||||
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
|
||||
|
||||
Assert.Equal(0, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
|
||||
{
|
||||
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
|
||||
h.ConfigRepo.Invocations.Clear();
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
|
||||
|
||||
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
|
||||
Assert.Equal(1, Reads(h, TenantA));
|
||||
Assert.Equal(1, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
|
||||
var r = await h.Middleware.DecideAsync(
|
||||
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(0, Reads(h, TenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Empty(h.ConfigRepo.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
StoreRows(h);
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
|
||||
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
|
||||
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
|
||||
/// answer with the tenant's row or with an error — never with the synthesized default the plain
|
||||
/// cached read hands out when storage fails.
|
||||
/// </summary>
|
||||
public sealed class PortalConfigReadTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
return (service, store);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Row(string manufacturers)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_with_the_tenants_row()
|
||||
{
|
||||
var (service, _) = Service(Row("Dell*"));
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("Dell*", Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
|
||||
{
|
||||
var (service, store) = Service(null);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(store.Row);
|
||||
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
|
||||
{
|
||||
var (service, store) = Service(Row("Dell*"));
|
||||
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
// The read fails before the homing service or the enrollment counter is asked.
|
||||
var function = new GetTenantFeatureFlagsFunction(
|
||||
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
|
||||
|
||||
var response = await function.Run(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,7 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
case "P256dh": d.P256dh = (string)value!; break;
|
||||
case "Auth": d.Auth = (string)value!; break;
|
||||
case "VapidKid": d.VapidKid = (string)value!; break;
|
||||
case "Label": d.Label = (string)value!; break;
|
||||
default: throw new InvalidOperationException($"unexpected patch key {key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
@@ -18,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// and the raw-table deny-list. The dependency redaction lives in CredentialUrlRedactionProcessorTests.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
@@ -182,30 +180,4 @@ public class PushChannelRoutingTests
|
||||
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
|
||||
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependency_rows_for_other_hosts_are_untouched()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,43 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal((int)status, row.LastStatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Start_alert_goes_out_at_high_urgency(bool isResume)
|
||||
{
|
||||
// D-424: an Info start below "high" waits at FCM while the phone dozes, and the verdict sharing
|
||||
// its session Topic then replaces it — the start never reaches the phone.
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
"DESKTOP-4711", "SN-0001", "Dell Inc.", "Latitude 7450", isResume, Constants.PortalSessionUrl("session-1"));
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("high", Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Error, "high")]
|
||||
[InlineData(NotificationSeverity.Warning, "high")]
|
||||
[InlineData(NotificationSeverity.Success, "normal")]
|
||||
[InlineData(NotificationSeverity.Info, "low")]
|
||||
public async Task Other_alerts_take_their_urgency_from_the_severity(NotificationSeverity severity, string expected)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = Alert();
|
||||
alert.EventType = "whats_new";
|
||||
alert.Severity = severity;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(expected, Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
|
||||
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
|
||||
/// Plus the label bodies of the portal routes: optional on confirm, required on rename.
|
||||
/// </summary>
|
||||
public class PushPairingFunctionsTests
|
||||
{
|
||||
@@ -39,6 +40,59 @@ public class PushPairingFunctionsTests
|
||||
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "iPhone")] // a portal without the name field sends no body
|
||||
[InlineData("{\"label\":\"\"}", "iPhone")]
|
||||
[InlineData("{\"label\":\"iPhone Otto\"}", "iPhone Otto")]
|
||||
public async Task Confirm_reads_an_optional_name(string? body, string expectedLabel)
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var sut = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, sut);
|
||||
|
||||
var response = await sut.Confirm(EndpointHarness.Request(TenantId, jsonBody: body).Req, pairingId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
Assert.Equal(expectedLabel, device.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_answers_204_and_maps_a_blank_name_to_400_and_an_unknown_device_to_404()
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var pairing = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, pairing);
|
||||
await pairing.Confirm(EndpointHarness.Request(TenantId).Req, pairingId);
|
||||
var deviceId = h.Repo.Devices.Values.Single().DeviceId;
|
||||
var sut = new PushDevicesFunctions(h.Sut, NullLogger<PushDevicesFunctions>.Instance);
|
||||
|
||||
var renamed = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"iPhone IT\"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var blank = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\" \"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, blank.StatusCode);
|
||||
var noBody = await sut.Rename(EndpointHarness.Request(TenantId).Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, noBody.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var unknown = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"x\"}").Req, "unknownDevice");
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A pairing created through the tenant route by the harness caller and redeemed by a device named "iPhone".</summary>
|
||||
private static async Task<string> RedeemedPairingAsync(PushPairingServiceTests.Harness h, PushPairingFunctions sut)
|
||||
{
|
||||
var created = await sut.Create(EndpointHarness.Request(TenantId).Req);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
created.Body.Position = 0;
|
||||
var pairing = (await JsonSerializer.DeserializeAsync<CreatePairingResponse>(created.Body, ApiJsonOptions.Read))!;
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(pairing.Code, label: "iPhone"))).Ok);
|
||||
return pairing.PairingId;
|
||||
}
|
||||
|
||||
private static PushPairingFunctions RefusingFunctions()
|
||||
{
|
||||
var pairing = new PushPairingServiceTests.Harness();
|
||||
|
||||
@@ -400,6 +400,54 @@ public class PushPairingServiceTests
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_takes_the_name_chosen_on_the_computer_and_a_blank_one_keeps_the_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
var renamed = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(renamed.Value!.Code, label: "iPhone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, renamed.Value.PairingId, Admin, " iPhone \u0007IT ")).Ok);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal("iPhone IT", device.Label);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "CREATE", "PushDevice", device.DeviceId, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == "iPhone IT" && !d.ContainsKey("previousLabel"))), Times.Once);
|
||||
|
||||
var kept = await h.Sut.CreateAsync(Tenant, Operator);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(kept.Value!.Code, label: "ops phone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, kept.Value.PairingId, Operator, " \u0007 ")).Ok);
|
||||
Assert.Equal("ops phone", h.Repo.Devices.Values.Single(d => d.OwnerUpn == Operator.Upn).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_follows_the_remove_rule_cleans_the_name_and_audits_only_a_change()
|
||||
{
|
||||
var h = new Harness();
|
||||
var adminDevice = await PairAsync(h, Admin);
|
||||
var opsDevice = await PairAsync(h, Operator);
|
||||
|
||||
// An Operator renames only their own device; someone else's answers like a missing one.
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, adminDevice, "taken over", Operator));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, "Ops phone", Operator));
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, "missing", "x", Admin));
|
||||
|
||||
// A scope admin renames every device of the scope.
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, " iPhone\u0007 Otto " + new string('z', 60), Admin));
|
||||
var renamed = h.Repo.Devices[(TenantId, opsDevice)];
|
||||
Assert.StartsWith("iPhone Otto zz", renamed.Label);
|
||||
Assert.Equal(Constants.Push.MaxLabelLength, renamed.Label.Length);
|
||||
Assert.Equal(renamed.Label, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Single(d => d.DeviceId == opsDevice).Label);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "PushDevice", opsDevice, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == renamed.Label && d["previousLabel"] == "Ops phone")), Times.Once);
|
||||
|
||||
// Blank after cleaning is refused; the same name again writes nothing and audits nothing.
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, " \u0007 ", Admin));
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, null, Admin));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, renamed.Label, Admin));
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(It.IsAny<string>(), "UPDATE", It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
|
||||
private static async Task<string> PairAsync(Harness h, PushCaller owner)
|
||||
{
|
||||
@@ -467,6 +515,8 @@ public class PushPairingServiceTests
|
||||
public VapidKeyRing Keys { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
/// <summary>The tenant audit log the pairing service writes to.</summary>
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public RecordingHandler Http { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
public PushDeliveryService Delivery { get; }
|
||||
@@ -499,7 +549,7 @@ public class PushPairingServiceTests
|
||||
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
|
||||
|
||||
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
|
||||
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
Maintenance.Object, tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
|
||||
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
|
||||
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
|
||||
/// route, that the surface is exactly these 24 routes (a 25th cannot appear unnoticed), that only the
|
||||
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
|
||||
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ public class PushRoutePolicyTests
|
||||
{
|
||||
private const string SampleId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
/// <summary>The 24 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
private static readonly string[] PinnedRoutes =
|
||||
{
|
||||
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
|
||||
@@ -29,6 +29,7 @@ public class PushRoutePolicyTests
|
||||
"POST push/pairings/{pairingId}/confirm",
|
||||
"POST push/pairings/{pairingId}/reject",
|
||||
"GET push/devices",
|
||||
"PATCH push/devices/{deviceId}",
|
||||
"DELETE push/devices/{deviceId}",
|
||||
"POST push/devices/{deviceId}/test",
|
||||
// Session watches: ?tenantId= like the annotations route.
|
||||
@@ -41,6 +42,7 @@ public class PushRoutePolicyTests
|
||||
"POST global/push/pairings/{pairingId}/confirm",
|
||||
"POST global/push/pairings/{pairingId}/reject",
|
||||
"GET global/push/devices",
|
||||
"PATCH global/push/devices/{deviceId}",
|
||||
"DELETE global/push/devices/{deviceId}",
|
||||
"POST global/push/devices/{deviceId}/test",
|
||||
};
|
||||
@@ -60,6 +62,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
|
||||
@@ -72,6 +75,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
@@ -85,7 +89,7 @@ public class PushRoutePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_push_surface_is_exactly_the_22_pinned_routes()
|
||||
public void The_push_surface_is_exactly_the_24_pinned_routes()
|
||||
{
|
||||
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The setup guide's markers (POST config/{tenantId}/setup-guide) and the done rules the feature
|
||||
/// flags derive from the row.
|
||||
/// </summary>
|
||||
public class SetupGuideFunctionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Admin = "admin@contoso.com";
|
||||
|
||||
// ── step parsing ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("settings", "settings")]
|
||||
[InlineData(" Hardware ", "hardware")]
|
||||
[InlineData("DISMISS", "dismiss")]
|
||||
[InlineData("restore", "restore")]
|
||||
[InlineData("validation", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void NormalizeStep_accepts_only_the_four_steps(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SetupGuideFunction.NormalizeStep(input));
|
||||
}
|
||||
|
||||
// ── ApplyStep ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "settings", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(Admin, row.UpdatedBy);
|
||||
|
||||
// A second completion keeps the first one on record and writes nothing.
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "settings", "other@contoso.com", Now.AddHours(1)));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideHardwareReviewedAt);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now.AddMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dismiss_records_who_and_restore_clears_it()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideDismissedAt);
|
||||
Assert.Equal(Admin, row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now.AddMinutes(1)));
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(2)));
|
||||
Assert.Null(row.SetupGuideDismissedAt);
|
||||
Assert.Null(row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_step_writes_nothing()
|
||||
{
|
||||
var row = new TenantConfiguration { UpdatedBy = "earlier" };
|
||||
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "validation", Admin, Now));
|
||||
Assert.Equal("earlier", row.UpdatedBy);
|
||||
}
|
||||
|
||||
// ── SetupGuideState.SettingsDone ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_open_on_a_new_tenant()
|
||||
{
|
||||
Assert.False(SetupGuideState.SettingsDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_once_reviewed_even_if_nothing_was_applied()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_when_all_three_are_already_on()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "OnFailure";
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_stay_open_while_one_of_the_three_is_off()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "Off";
|
||||
|
||||
Assert.False(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
// ── SetupGuideState.HardwareDone ──
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_open_with_the_default_whitelist()
|
||||
{
|
||||
Assert.False(SetupGuideState.HardwareDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_done_once_reviewed()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation,VMware*", "*")]
|
||||
[InlineData("*", "*")]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation", "Latitude*")]
|
||||
public void Hardware_is_done_when_the_whitelist_differs_from_the_default(string manufacturers, string models)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = manufacturers;
|
||||
config.ModelWhitelist = models;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_ignores_order_case_and_spacing_of_the_default_entries()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = "microsoft corporation, lenovo*,HP*, dell*";
|
||||
|
||||
Assert.False(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,30 @@ public sealed class SharedManifestParityTests
|
||||
// Limits of feedback and reports (text lengths, the report body cap, the daily feedback cap):
|
||||
// the portal's counters and upload budget read the same numbers the backend enforces.
|
||||
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
|
||||
// Local Admin Analyzer allowed list: the built-in entries and the entry grammar. The portal's
|
||||
// Allowed Local Accounts card renders and checks against these; agent and backend use the class.
|
||||
["localAdminAllowList"] = new Dictionary<string, object?>
|
||||
{
|
||||
["roleSeparator"] = LocalAdminAllowList.RoleSeparator.ToString(),
|
||||
["standardUserQualifier"] = LocalAdminAllowList.StandardUserQualifier,
|
||||
["maxEntries"] = LocalAdminAllowList.MaxEntries,
|
||||
["maxEntryLength"] = LocalAdminAllowList.MaxEntryLength,
|
||||
["minPatternLiterals"] = LocalAdminAllowList.MinPatternLiterals,
|
||||
["forbiddenNameCharacters"] = LocalAdminAllowList.ForbiddenNameCharacters,
|
||||
["builtIns"] = LocalAdminAllowList.BuiltIns.Select(b => new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = b.Name,
|
||||
["match"] = b.Match switch
|
||||
{
|
||||
LocalAdminBuiltInMatch.Rid => "rid",
|
||||
LocalAdminBuiltInMatch.AccountName => "account",
|
||||
LocalAdminBuiltInMatch.ProfileFolder => "folder",
|
||||
_ => throw new InvalidOperationException($"Unmapped built-in match {b.Match}"),
|
||||
},
|
||||
["role"] = b.Role == LocalAccountRole.StandardUser ? "user" : "any",
|
||||
["rid"] = b.Rid,
|
||||
}).ToArray(),
|
||||
},
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Tables refuses <c>$top</c> above 1000 with 400 InvalidInput. A computed page size
|
||||
/// handed to <c>maxPerPage</c> unguarded reaches that limit only at the edge (a fan-out reader
|
||||
/// that asked for <c>pageSize + 1</c> answered every <c>pageSize=1000</c> request with an empty
|
||||
/// page). Ratchet: every <c>maxPerPage</c> argument is a literal of at most 1000 or goes through
|
||||
/// <see cref="AzureTablesPaginator.ServerPageSize"/> / <c>Math.Min(…, 1000)</c>.
|
||||
/// </summary>
|
||||
public class TableServerPageSizeGuardTests
|
||||
{
|
||||
private static readonly Regex AllowedForm = new(
|
||||
@"^(\d+|(AzureTablesPaginator\.)?ServerPageSize\(.+\)|Math\.Min\(.+,\s*1000\s*\))$",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(16, 16)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(1001, 1000)]
|
||||
[InlineData(int.MaxValue, 1000)]
|
||||
public void ServerPageSize_never_exceeds_the_Azure_limit(int rows, int expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureTablesPaginator.ServerPageSize(rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_maxPerPage_argument_stays_within_the_Azure_limit()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var found = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
if (rel.StartsWith("bin/", StringComparison.Ordinal) || rel.StartsWith("obj/", StringComparison.Ordinal)) continue;
|
||||
|
||||
var source = File.ReadAllText(path);
|
||||
foreach (var arg in MaxPerPageArguments(source))
|
||||
{
|
||||
found++;
|
||||
if (!IsAllowed(arg)) offenders.Add($"{rel}: maxPerPage: {arg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the scan must see the known call sites, or it proves nothing.
|
||||
Assert.True(found > 20, $"Only {found} maxPerPage arguments found — the scan lost its call sites.");
|
||||
Assert.True(offenders.Count == 0,
|
||||
"maxPerPage beyond Azure's $top limit is possible — pass AzureTablesPaginator.ServerPageSize(n) " +
|
||||
"or a literal of at most 1000:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1000", true)]
|
||||
[InlineData("25", true)]
|
||||
[InlineData("1001", false)]
|
||||
[InlineData("limit", false)]
|
||||
[InlineData("pageSize + 1", false)]
|
||||
[InlineData("AzureTablesPaginator.ServerPageSize(limit)", true)]
|
||||
[InlineData("Math.Min(maxResults + 1, 1000)", true)]
|
||||
[InlineData("Math.Min(maxResults, 5000)", false)]
|
||||
public void IsAllowed_accepts_only_bounded_forms(string argument, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IsAllowed(argument));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string argument)
|
||||
{
|
||||
if (!AllowedForm.IsMatch(argument)) return false;
|
||||
return !int.TryParse(argument, out var literal) || literal <= AzureTablesPaginator.MaxServerPageSize;
|
||||
}
|
||||
|
||||
/// <summary>The argument text after each <c>maxPerPage:</c>, up to the comma or parenthesis that ends it.</summary>
|
||||
private static IEnumerable<string> MaxPerPageArguments(string source)
|
||||
{
|
||||
const string marker = "maxPerPage:";
|
||||
var at = 0;
|
||||
while ((at = source.IndexOf(marker, at, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var i = at + marker.Length;
|
||||
at = i;
|
||||
// A mention inside a comment ("(maxPerPage: 1)") is not a call site.
|
||||
var lineStart = source.LastIndexOf('\n', i) + 1;
|
||||
if (source.AsSpan(lineStart, i - lineStart).TrimStart().StartsWith("//")) continue;
|
||||
|
||||
var depth = 0;
|
||||
var start = i;
|
||||
for (; i < source.Length; i++)
|
||||
{
|
||||
var c = source[i];
|
||||
if (c == '(') depth++;
|
||||
else if (c == ')') { if (depth == 0) break; depth--; }
|
||||
else if (c == ',' && depth == 0) break;
|
||||
}
|
||||
yield return source[start..i].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,10 @@ public class TenantConfigPatchServiceTests
|
||||
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
|
||||
[InlineData("cloudPcPermissionMissingSince")]
|
||||
[InlineData("cloudPcPermissionMissingConfirmedAt")]
|
||||
[InlineData("setupGuideSettingsReviewedAt")]
|
||||
[InlineData("setupGuideHardwareReviewedAt")]
|
||||
[InlineData("setupGuideDismissedAt")]
|
||||
[InlineData("setupGuideDismissedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
@@ -485,6 +489,36 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[\"*\"]")]
|
||||
[InlineData("[\"kiosk|admin\"]")]
|
||||
[InlineData("[\"S-1-12-1-1-2-3-4\"]")]
|
||||
[InlineData("\"kiosk\"")]
|
||||
public async Task Patch_InvalidAllowedLocalAccount_ValidationFails_NoWrite(string json)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", json)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("allowed local accounts", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_StandardUserAllowedLocalAccount_Applies()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", "[\"adm-*\",\"kiosk|user\"]")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success, outcome.Error);
|
||||
Assert.Equal("[\"adm-*\",\"kiosk|user\"]", harness.Current!.LocalAdminAllowedAccountsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
|
||||
/// instance that served it. These tests model two instances (two services, each with its own cache)
|
||||
/// over one stored row and pin the two ways the stored row reaches the other instance without
|
||||
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
|
||||
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
|
||||
/// before a refusal.
|
||||
/// </summary>
|
||||
public sealed class TenantConfigReadAfterWriteTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed record Instances(
|
||||
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
|
||||
|
||||
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
seed?.Invoke(row);
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(row);
|
||||
var clock = new TestTimeProvider(Start);
|
||||
TenantConfigurationService Instance() => new(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
|
||||
ManagedTenantProIndex.None, clock);
|
||||
return new Instances(Instance(), Instance(), store, repo, clock);
|
||||
}
|
||||
|
||||
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
|
||||
=> instance.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.ManufacturerWhitelist = manufacturers;
|
||||
return true;
|
||||
}, "test", "hardware");
|
||||
|
||||
// ---------------------------------------------------------------- the hazard itself
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
|
||||
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
|
||||
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RefreshFromStoreAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
|
||||
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
|
||||
Assert.True(exists);
|
||||
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
|
||||
await AllowOnly(s.A, "Dell*");
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
|
||||
await s.B.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var store = repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await service.RefreshFromStoreAsync(TenantId);
|
||||
|
||||
Assert.Null(store.Row);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
await AllowOnly(s.A, "Dell*,Contoso");
|
||||
|
||||
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
|
||||
|
||||
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
|
||||
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
|
||||
|
||||
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
|
||||
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
|
||||
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
|
||||
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the bootstrap feature switch
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
|
||||
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
|
||||
|
||||
await s.A.UpdateAsync(TenantId, row =>
|
||||
{
|
||||
row.BootstrapTokenEnabled = true;
|
||||
return true;
|
||||
}, "test", "bootstrap");
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
|
||||
{
|
||||
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
|
||||
await s.B.GetConfigurationAsync(TenantId);
|
||||
s.Repo.Invocations.Clear();
|
||||
|
||||
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
|
||||
{
|
||||
var s = TwoInstances();
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
|
||||
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.BackWithRow(null);
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
|
||||
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,34 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Null(mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_SetupGuideFields_SurviveStoreAndMap()
|
||||
{
|
||||
var at = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
SetupGuideSettingsReviewedAt = at,
|
||||
SetupGuideHardwareReviewedAt = at.AddMinutes(2),
|
||||
SetupGuideDismissedAt = at.AddDays(1),
|
||||
SetupGuideDismissedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(at, mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(at.AddMinutes(2), mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Equal(at.AddDays(1), mapped.SetupGuideDismissedAt);
|
||||
Assert.Equal("admin@contoso.com", mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
|
||||
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
|
||||
/// configuration for a few minutes, because a write clears only the cache of the instance that
|
||||
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
|
||||
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
|
||||
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
|
||||
/// <c>config/</c>, or is listed here with the reason it needs no hint.
|
||||
/// </summary>
|
||||
public class TenantConfigWriteRouteGuardTests
|
||||
{
|
||||
private static readonly Regex TenantConfigWrite = new(
|
||||
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
|
||||
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WriteRoute = new(
|
||||
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
|
||||
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
|
||||
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Infrastructure/AuthFunction.cs"] =
|
||||
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
|
||||
["Functions/Admin/TenantOffboardFunction.cs"] =
|
||||
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
|
||||
};
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
|
||||
{
|
||||
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var portalList = File.ReadAllText(Path.Combine(
|
||||
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
|
||||
Assert.Contains("\"/api/config/\"", portalList);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var offenders = new List<string>();
|
||||
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var source = File.ReadAllText(path);
|
||||
if (!TenantConfigWrite.IsMatch(source))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
|
||||
seen.Add(rel);
|
||||
|
||||
if (Baseline.TryGetValue(rel, out var entry))
|
||||
{
|
||||
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
|
||||
{
|
||||
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
|
||||
continue;
|
||||
}
|
||||
foreach (Match route in WriteRoute.Matches(source))
|
||||
{
|
||||
var value = route.Groups["route"].Value;
|
||||
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
|
||||
offenders.Add($"{rel}: write route '{value}' is not under config/");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
|
||||
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
|
||||
+ string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,10 @@ public class TypedContractCompletionWireParityTests
|
||||
trialAvailable = true,
|
||||
contactEmailSet = false,
|
||||
companyNameSet = false,
|
||||
setupGuideSettingsDone = true,
|
||||
setupGuideHardwareDone = false,
|
||||
setupGuideDismissed = false,
|
||||
hasEnrollmentSessions = false,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = 90,
|
||||
@@ -424,6 +428,10 @@ public class TypedContractCompletionWireParityTests
|
||||
TrialAvailable = true,
|
||||
ContactEmailSet = false,
|
||||
CompanyNameSet = false,
|
||||
SetupGuideSettingsDone = true,
|
||||
SetupGuideHardwareDone = false,
|
||||
SetupGuideDismissed = false,
|
||||
HasEnrollmentSessions = false,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = 90,
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That limit is a row count, not an Azure page size: Azure Tables refuses <c>$top</c> above
|
||||
/// 1000, so a reader passes <c>AzureTablesPaginator.ServerPageSize(limit)</c> as
|
||||
/// <c>maxPerPage</c> and keeps reading across server pages until it has its limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
|
||||
+9
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -743,6 +744,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DpaAcceptancePending", config.DpaAcceptancePending },
|
||||
{ "DpaAcceptedBy", config.DpaAcceptedBy },
|
||||
{ "DpaAcceptedAt", config.DpaAcceptedAt },
|
||||
{ "SetupGuideSettingsReviewedAt", config.SetupGuideSettingsReviewedAt },
|
||||
{ "SetupGuideHardwareReviewedAt", config.SetupGuideHardwareReviewedAt },
|
||||
{ "SetupGuideDismissedAt", config.SetupGuideDismissedAt },
|
||||
{ "SetupGuideDismissedBy", config.SetupGuideDismissedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "CompanyName", config.CompanyName },
|
||||
{ "Disabled", config.Disabled },
|
||||
@@ -865,6 +870,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false,
|
||||
DpaAcceptedBy = entity.GetString("DpaAcceptedBy"),
|
||||
DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"),
|
||||
SetupGuideSettingsReviewedAt = entity.GetDateTime("SetupGuideSettingsReviewedAt"),
|
||||
SetupGuideHardwareReviewedAt = entity.GetDateTime("SetupGuideHardwareReviewedAt"),
|
||||
SetupGuideDismissedAt = entity.GetDateTime("SetupGuideDismissedAt"),
|
||||
SetupGuideDismissedBy = entity.GetString("SetupGuideDismissedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
CompanyName = entity.GetString("CompanyName"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
|
||||
+5
-2
@@ -3,9 +3,11 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
@@ -63,12 +65,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100)
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100)
|
||||
{
|
||||
var pk = tenantId.ToLowerInvariant();
|
||||
var result = new List<DistressReportEntry>();
|
||||
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {pk} and ErrorType eq {errorType.ToString()}");
|
||||
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == pk, maxPerPage: maxResults))
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)))
|
||||
{
|
||||
result.Add(MapToEntry(entity));
|
||||
if (result.Count >= maxResults)
|
||||
|
||||
+3
@@ -66,6 +66,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
=> _storage.GetTenantStatsAsync(tenantId);
|
||||
|
||||
public Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
=> _storage.TenantHasEnrollmentsAsync(tenantId);
|
||||
|
||||
public Task<List<TenantStats>> GetAllTenantStatsAsync()
|
||||
=> _storage.GetAllTenantStatsAsync();
|
||||
|
||||
|
||||
+20
-28
@@ -86,36 +86,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get ops events page");
|
||||
return RawPage<OpsEventEntry>.Empty;
|
||||
}
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
|
||||
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
|
||||
@@ -135,7 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
|
||||
+1
-2
@@ -71,8 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+1
-2
@@ -83,8 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var registration = request.Registration;
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, registration.TenantId))
|
||||
{
|
||||
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
|
||||
}
|
||||
|
||||
+1
-2
@@ -72,8 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
|
||||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||||
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return req.CreateResponse(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
+1
-2
@@ -53,8 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var request = read.Value!;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -94,8 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
|
||||
// Feature gate — same generic message as not-found so a disabled tenant is
|
||||
// indistinguishable from an unknown code (no enumeration oracle).
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await ScriptResponseAsync(
|
||||
|
||||
+1
-2
@@ -42,8 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var tenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
// Return empty list instead of error — feature simply not visible
|
||||
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+1
-2
@@ -47,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
|
||||
{
|
||||
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
|
||||
}
|
||||
|
||||
+1
-2
@@ -75,8 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
|
||||
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
|
||||
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
|
||||
{
|
||||
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
|
||||
return await req.NotFoundAsync("Code not found, expired, or revoked");
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
_logger.LogInformation($"GetTenantConfiguration: {requestCtx.TargetTenantId} by user {userIdentifier}");
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
// The tenant's row or an error, never a synthesized default: this answer is the baseline
|
||||
// of the settings editor and of every read-modify-write in the portal, and the plain
|
||||
// cached read answers a storage failure with defaults. A tenant without a row still
|
||||
// gets its default row.
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
|
||||
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
// Only a caller with WRITE authority over THIS target tenant sees the full config including
|
||||
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
|
||||
|
||||
+49
-6
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -16,15 +18,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
private readonly IMetricsRepository _metricsRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants whose enrollment counter was seen above zero. The counter only grows, so a seen
|
||||
/// "true" costs no further read on this instance. An offboarded and re-onboarded tenant starts
|
||||
/// at zero again; until this instance recycles, its setup guide then stays hidden here.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, bool> KnownEnrolledTenants = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public GetTenantFeatureFlagsFunction(
|
||||
ILogger<GetTenantFeatureFlagsFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AppHomingService appHomingService)
|
||||
AppHomingService appHomingService,
|
||||
IMetricsRepository metricsRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_appHomingService = appHomingService;
|
||||
_metricsRepository = metricsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
///
|
||||
/// Adding a field here is a deliberate decision that the field is non-sensitive: it must not
|
||||
/// expose webhook URLs, SAS tokens, admin allowlists, or any other admin-only data. The full
|
||||
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, gated
|
||||
/// to TenantAdminOrGA.
|
||||
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, which
|
||||
/// redacts its secrets for every caller without write authority over the tenant.
|
||||
/// </summary>
|
||||
[Function("GetTenantFeatureFlags")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
@@ -48,11 +60,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
// The tenant's row or an error, never a synthesized default (see GetTenantConfiguration):
|
||||
// the flags decide banners and gates, and a failed answer is not cached by the portal.
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
|
||||
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
|
||||
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
|
||||
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
|
||||
? await HasEnrollmentSessionsAsync(requestCtx.TargetTenantId)
|
||||
: null;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive, hasEnrollmentSessions));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,13 +80,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the counter could not be read: the guide then stays hidden rather than guessing.</summary>
|
||||
private async Task<bool?> HasEnrollmentSessionsAsync(string tenantId)
|
||||
{
|
||||
if (KnownEnrolledTenants.ContainsKey(tenantId)) return true;
|
||||
try
|
||||
{
|
||||
var has = await _metricsRepository.TenantHasEnrollmentsAsync(tenantId);
|
||||
if (has) KnownEnrolledTenants.TryAdd(tenantId, true);
|
||||
return has;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Feature flags: enrollment history of tenant {TenantId} unknown", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
|
||||
/// Pulled out as a static method so unit tests can verify field-level mapping
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(
|
||||
TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false, bool? hasEnrollmentSessions = null)
|
||||
{
|
||||
// Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
|
||||
// managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
|
||||
@@ -126,6 +163,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
// Same contract for the second half of the contact profile: boolean only.
|
||||
CompanyNameSet = !string.IsNullOrWhiteSpace(config.CompanyName),
|
||||
// Setup guide checklist (dashboard card, sidebar entry): done states and the
|
||||
// dismissal. Booleans only, derived from the row and the enrollment counter.
|
||||
SetupGuideSettingsDone = SetupGuideState.SettingsDone(config),
|
||||
SetupGuideHardwareDone = SetupGuideState.HardwareDone(config),
|
||||
SetupGuideDismissed = config.SetupGuideDismissedAt != null,
|
||||
HasEnrollmentSessions = hasEnrollmentSessions,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = entitlements.RetentionCapDays,
|
||||
|
||||
+24
@@ -1,9 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -88,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
request.Reason,
|
||||
callerTier);
|
||||
|
||||
StampUnchangedFields(req, request.Fields.Keys, outcome);
|
||||
return await WriteOutcome(req, outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -96,6 +100,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the sent fields the stored row already held onto the request row. The portal sends
|
||||
/// only what its user changed, so such a field means the page showed an outdated configuration.
|
||||
/// </summary>
|
||||
private static void StampUnchangedFields(HttpRequestData req, IEnumerable<string> sentFields, PatchOutcome outcome)
|
||||
{
|
||||
var unchanged = UnchangedFieldCount(sentFields, outcome);
|
||||
if (unchanged > 0)
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigPatchUnchangedKey, unchanged.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Sent fields (wire names) a successful patch did not change; zero for a failed patch.</summary>
|
||||
internal static int UnchangedFieldCount(IEnumerable<string> sentFields, PatchOutcome outcome)
|
||||
{
|
||||
if (!outcome.Success)
|
||||
return 0;
|
||||
var applied = new HashSet<string>(outcome.AppliedFields, StringComparer.OrdinalIgnoreCase);
|
||||
return sentFields.Count(field => !applied.Contains(field));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
|
||||
/// X-Client-Source: mcp) or the plain API.
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
@@ -406,28 +406,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -511,7 +489,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// The setup guide's markers on the tenant row. The guide applies settings through the field
|
||||
/// PATCH like any settings card; this endpoint only stamps what the PATCH cannot: that an admin
|
||||
/// finished a step, or hid the guide for the whole tenant. Server time and the caller's UPN.
|
||||
/// </summary>
|
||||
public class SetupGuideFunction
|
||||
{
|
||||
internal const string StepSettings = "settings";
|
||||
internal const string StepHardware = "hardware";
|
||||
internal const string StepDismiss = "dismiss";
|
||||
internal const string StepRestore = "restore";
|
||||
|
||||
private readonly ILogger<SetupGuideFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public SetupGuideFunction(
|
||||
ILogger<SetupGuideFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/setup-guide — TenantAdminOrGA (catalog-enforced), refused in
|
||||
/// view-as. Body <c>{ "step": "settings" | "hardware" | "dismiss" | "restore" }</c>. A step that
|
||||
/// is already recorded writes nothing and answers with the current markers.
|
||||
/// </summary>
|
||||
[Function("RecordSetupGuideStep")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/setup-guide")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var read = await req.ReadAsync<SetupGuideStepRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
var step = NormalizeStep(read.Value!.Step);
|
||||
if (step == null)
|
||||
return await req.BadRequestAsync("step must be one of: settings, hardware, dismiss, restore.");
|
||||
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var update = await _configService.UpdateAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
row => ApplyStep(row, step, caller, nowUtc),
|
||||
"setup-guide",
|
||||
$"setup guide: {step}");
|
||||
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"SetupGuide",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
new Dictionary<string, string> { { "Step", step } });
|
||||
}
|
||||
|
||||
return await req.OkAsync(new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = config.SetupGuideSettingsReviewedAt,
|
||||
HardwareReviewedAt = config.SetupGuideHardwareReviewedAt,
|
||||
DismissedAt = config.SetupGuideDismissedAt,
|
||||
DismissedBy = config.SetupGuideDismissedBy,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RecordSetupGuideStep");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The known step in lower case, or null.</summary>
|
||||
internal static string? NormalizeStep(string? step)
|
||||
{
|
||||
var value = step?.Trim().ToLowerInvariant();
|
||||
return value is StepSettings or StepHardware or StepDismiss or StepRestore ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <paramref name="step"/> on the fresh row inside the conditional write. False (nothing to
|
||||
/// write) when the step is already recorded — the first completion stays the one on record.
|
||||
/// </summary>
|
||||
internal static bool ApplyStep(TenantConfiguration row, string step, string caller, DateTime nowUtc)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case StepSettings:
|
||||
if (row.SetupGuideSettingsReviewedAt != null) return false;
|
||||
row.SetupGuideSettingsReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepHardware:
|
||||
if (row.SetupGuideHardwareReviewedAt != null) return false;
|
||||
row.SetupGuideHardwareReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepDismiss:
|
||||
if (row.SetupGuideDismissedAt != null) return false;
|
||||
row.SetupGuideDismissedAt = nowUtc;
|
||||
row.SetupGuideDismissedBy = caller;
|
||||
break;
|
||||
case StepRestore:
|
||||
if (row.SetupGuideDismissedAt == null) return false;
|
||||
row.SetupGuideDismissedAt = null;
|
||||
row.SetupGuideDismissedBy = null;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
@@ -77,18 +78,22 @@ public class GetScriptDisplayNamesFunction
|
||||
|
||||
var distinctRefs = parsedRefs.Distinct().ToList();
|
||||
|
||||
var resolved = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
|
||||
var lookup = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
|
||||
|
||||
var payload = new Dictionary<string, string?>(distinctRefs.Count);
|
||||
foreach (var r in distinctRefs)
|
||||
{
|
||||
payload[r.ToString()] = resolved.TryGetValue(r, out var name) ? name : null;
|
||||
payload[r.ToString()] = lookup.Names.TryGetValue(r, out var name) ? name : null;
|
||||
}
|
||||
|
||||
var marker = RequestRowMarkers.ScriptNameLookup.For(lookup.TimedOut, isRetry: body.Retry == true);
|
||||
if (marker != null) RequestRowMarkers.Stamp(req, RequestRowMarkers.ScriptNameLookupKey, marker);
|
||||
|
||||
return await req.OkAsync(new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed.Count > 0 ? malformed : null,
|
||||
TimedOut = lookup.TimedOut ? true : null,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -890,6 +890,12 @@ public class AuthFunction
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
|
||||
// Navbar label, for the callers the config GET serves too (a tenant role, or a Global
|
||||
// Admin); a roleless member only gets the Progress Portal. In view-as tenantConfig is
|
||||
// the viewed tenant's row and the role the emulated one.
|
||||
CompanyName = (role != null || isGlobalAdmin) && !string.IsNullOrWhiteSpace(tenantConfig.CompanyName)
|
||||
? tenantConfig.CompanyName.Trim()
|
||||
: null,
|
||||
ViewAs = viewAs
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
@@ -148,8 +148,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup). The config also decides
|
||||
// the effective error type below.
|
||||
var (tenantConfig, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -223,11 +224,14 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
|
||||
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
|
||||
|
||||
var errorType = ResolveErrorType(
|
||||
report.ErrorType, report.HttpStatusCode, manufacturer, model, report.CertSourceState, tenantConfig);
|
||||
|
||||
// Persist to Table Storage
|
||||
var entry = new DistressReportEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = report.ErrorType.ToString(),
|
||||
ErrorType = errorType.ToString(),
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serialNumber,
|
||||
@@ -256,28 +260,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Notify tenant channels for hardware whitelist rejections (opt-in). Durable via
|
||||
// the notification-dispatch queue — a fire-and-forget send after the response was
|
||||
// not counted as open work by the host and could be dropped on scale-in.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& _hardwareThrottle.ShouldNotify(tenantId, manufacturer, model))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (config, configExists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (configExists)
|
||||
var hwChannels = tenantConfig.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
{
|
||||
var hwChannels = config!.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
@@ -289,7 +289,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// In-app bell notification on first-ever hardware rejection per (tenant, manufacturer, model).
|
||||
// Lifetime dedup via tracker table — independent of the webhook 24h in-memory throttle so
|
||||
// tenant admins see a one-shot persistent notification even if the webhook is not configured.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(model))
|
||||
{
|
||||
try
|
||||
@@ -318,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// No webhook — volume is tiny and the remediation is not time-critical. Reports
|
||||
// without a serial number cannot be deduped and fire no bell; they still appear
|
||||
// in the panel.
|
||||
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
|
||||
if (errorType == DistressErrorType.TpmPssUnsupported
|
||||
&& !string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
try
|
||||
@@ -343,8 +343,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
|
||||
// Structured log (Warning, not Critical — data is unverified)
|
||||
_logger.LogWarning(
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
"AgentDistress [{ErrorType}] reported={ReportedErrorType} tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
errorType, report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
|
||||
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
|
||||
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
|
||||
@@ -354,7 +354,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_telemetryClient.TrackEvent("AgentDistressReport", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["ErrorType"] = report.ErrorType.ToString(),
|
||||
["ErrorType"] = errorType.ToString(),
|
||||
["ReportedErrorType"] = report.ErrorType.ToString(),
|
||||
["Manufacturer"] = manufacturer ?? string.Empty,
|
||||
["Model"] = model ?? string.Empty,
|
||||
["SerialNumber"] = serialNumber ?? string.Empty,
|
||||
@@ -387,6 +388,31 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error type to store and act on. The agent reports every backend 403 as
|
||||
/// <see cref="DistressErrorType.DeviceNotRegistered"/>, but <c>SecurityValidator</c> checks the
|
||||
/// hardware whitelist before it looks up the device's registration: a 403 for hardware
|
||||
/// outside the whitelist is a hardware rejection, and only as one does it reach the bell,
|
||||
/// the webhook and the rejected-hardware list. The conditions follow the validator's order:
|
||||
/// a suspended tenant, a tenant without any validation and bootstrap-token auth (no
|
||||
/// certificate) are refused or let through before the hardware check, so their 403 stays as
|
||||
/// reported. If that order changes, this has to change with it.
|
||||
/// </summary>
|
||||
internal static DistressErrorType ResolveErrorType(
|
||||
DistressErrorType reported, int? httpStatusCode, string? manufacturer, string? model,
|
||||
DistressCertSourceState? certSourceState, TenantConfiguration config)
|
||||
{
|
||||
if (reported != DistressErrorType.DeviceNotRegistered || httpStatusCode != 403) return reported;
|
||||
if (string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(model)) return reported;
|
||||
if (config.IsCurrentlyDisabled()) return reported;
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests) return reported;
|
||||
if (certSourceState == DistressCertSourceState.NoCertInStore) return reported;
|
||||
|
||||
var hardware = HardwareWhitelistValidator.ValidateHardware(
|
||||
manufacturer, model, config.GetManufacturerWhitelist(), config.GetModelWhitelist());
|
||||
return hardware.IsValid ? reported : DistressErrorType.HardwareNotAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a distress report timestamp is within acceptable range.
|
||||
/// Rejects timestamps older than 24 hours or more than 5 minutes in the future.
|
||||
|
||||
+8
-5
@@ -13,9 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
/// <list type="bullet">
|
||||
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
|
||||
/// renamed Intune script eventually surfaces under its new name.</item>
|
||||
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
|
||||
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
|
||||
/// once the cache is gone the resolver will lazily re-check.</item>
|
||||
/// <item>Negative (NotFound) rows live just as long (<see cref="NotFoundTtl"/>): they
|
||||
/// suppress repeated 404 fetches for an ID Graph does not know. A list pull that
|
||||
/// contains the ID after all overwrites the row.</item>
|
||||
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
|
||||
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
|
||||
/// </list>
|
||||
@@ -27,8 +27,11 @@ public class ScriptNameCacheCleanupFunction
|
||||
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
|
||||
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
|
||||
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
|
||||
/// <summary>
|
||||
/// Same lifetime as a name. A shorter one asks Graph again for every unknown ID on the
|
||||
/// first view after each cleanup run.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan NotFoundTtl = PositiveTtl;
|
||||
|
||||
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
|
||||
private const string Cron = "0 0 3 * * *";
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +11,7 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Device management from the portal: list (own devices for everyone on the tier, every device
|
||||
/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// of the scope for its admins), rename, remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// under <c>push/devices…</c> (TenantAdminOrOperator), platform routes under
|
||||
/// <c>global/push/devices…</c> (GlobalAdminOnly).
|
||||
/// </summary>
|
||||
@@ -36,6 +37,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("RenamePushDevice")]
|
||||
public Task<HttpResponseData> Rename(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("TestPushDevice")]
|
||||
public Task<HttpResponseData> Test(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -53,6 +60,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("RenamePlatformPushDevice")]
|
||||
public Task<HttpResponseData> RenamePlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("TestPlatformPushDevice")]
|
||||
public Task<HttpResponseData> TestPlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -88,6 +101,25 @@ public class PushDevicesFunctions
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> RenameCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(deviceId))
|
||||
return await req.NotFoundAsync("Device not found.");
|
||||
var read = await req.ReadAsync<RenamePushDeviceRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var error = await _pairing.RenameAsync(ctx.Scope, deviceId, read.Value!.Label, ctx.Caller);
|
||||
if (error != PushOpError.None)
|
||||
return await PushFunctionSupport.ErrorAsync(req, error, error == PushOpError.NotFound ? "Device not found." : null);
|
||||
return req.CreateResponse(HttpStatusCode.NoContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RenamePushDevice");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
/// </summary>
|
||||
internal static class PushFunctionSupport
|
||||
{
|
||||
/// <summary>Body cap of the portal routes that carry only a device label.</summary>
|
||||
public const long LabelBodyMaxBytes = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
|
||||
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
|
||||
@@ -57,6 +60,7 @@ internal static class PushFunctionSupport
|
||||
PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"The push channel is not configured on the platform."),
|
||||
PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."),
|
||||
PushOpError.InvalidLabel => req.BadRequestAsync(message ?? "Enter a device name."),
|
||||
_ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
|
||||
new InvalidOperationException("unmapped push error"), "Push"),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -114,7 +115,10 @@ public class PushPairingFunctions
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(pairingId))
|
||||
return await req.NotFoundAsync("Pairing not found.");
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller);
|
||||
// The body is optional: without one the device keeps the name it sent.
|
||||
var read = await req.ReadOptionalAsync<ConfirmPairingRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller, read.Value?.Label);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found.");
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.DeviceNotRegistered, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new DeviceNotRegisteredResponse
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.HardwareNotAllowed, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new HardwareRejectedResponse
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.TpmPssUnsupported, maxResults: 200);
|
||||
var tpmReports = reports.Where(IsTpmPssReport).ToList();
|
||||
|
||||
IReadOnlyDictionary<string, DateTime>? lastCertificateIngest = null;
|
||||
|
||||
+5
-4
@@ -434,10 +434,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
RegisteredAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
|
||||
/// the agent treats register and telemetry uniformly when a session is being deleted.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
|
||||
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
|
||||
@@ -492,6 +488,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 410 Gone for the cascade-delete guard: a cascade owns the session, or its tombstone is
|
||||
/// still fresh. Same status as the telemetry ingest. The agent ends monitoring on it and
|
||||
/// removes itself; a WhiteGlove Part 2 start registers under a new session id instead.
|
||||
/// </summary>
|
||||
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||||
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user