mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 249e39bc68 | |||
| 6bd9139869 | |||
| 73373a2c9e | |||
| 6d607c9045 | |||
| 3af94d9dcd | |||
| 0d5e2572aa | |||
| 793e907e60 | |||
| 8e8bb66371 | |||
| 7e1389cf32 | |||
| 3a89319472 | |||
| b37e6f975c | |||
| cacbd24667 | |||
| a157d98a40 | |||
| 7fa50d09c1 | |||
| 94afc02e62 | |||
| 5e4c5e80be | |||
| b535973531 | |||
| 7e0c578a59 | |||
| 1257e0d195 | |||
| 98f8114d90 | |||
| 7b3230d507 | |||
| 87b7a20223 | |||
| 4c19be1de4 | |||
| 93de130cd1 | |||
| b8b95c7e76 | |||
| e57e081cec | |||
| 3a60186824 | |||
| c35595503f | |||
| 838da4cc8a | |||
| a9937e0f5d | |||
| 1dc2cc9a54 | |||
| dd7ae299a8 | |||
| d96d10ccec | |||
| 457fff65f5 | |||
| dc45694611 | |||
| 7569061983 | |||
| 0f5e36db94 | |||
| e1de5a8971 | |||
| a2d93e884c | |||
| 425229deee | |||
| 8675e7ee06 | |||
| e857e99436 | |||
| 6435d5f290 | |||
| 445ce4e75b | |||
| 4e54ffe4b4 | |||
| 65d45a616e | |||
| 4aa1f07fbb | |||
| ede616444a | |||
| 61a359ee54 | |||
| babc89c776 | |||
| a2f448cad0 | |||
| f15e115dea | |||
| 977c5fb447 | |||
| 27fdd19568 | |||
| 72f7adcb3d | |||
| 06cf1c4c12 | |||
| e612c69178 | |||
| 829a841d9b | |||
| 10eedded13 | |||
| a9f6ccd86d | |||
| 7ba67df1a1 | |||
| e214e8dc1a | |||
| f8d0ccd2f5 | |||
| c9701301ea | |||
| ba2a390a83 |
+1
-1
Submodule .claude/commands updated: 2866e07882...ba49ac60ca
@@ -0,0 +1,147 @@
|
||||
---
|
||||
type: concept
|
||||
title: Diagnostics Package
|
||||
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
|
||||
resource: agent
|
||||
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
|
||||
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
|
||||
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
|
||||
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
|
||||
|
||||
## Archive layout
|
||||
|
||||
```
|
||||
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
|
||||
<built-in sections> catalog order, see below
|
||||
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
|
||||
package-manifest.txt every packaging decision (always written)
|
||||
_TRUNCATED.txt only when a cap skipped something
|
||||
```
|
||||
|
||||
## The built-in catalog is the single source of truth
|
||||
|
||||
Everything the package collects **before** any configured path is data, not code paths:
|
||||
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
|
||||
The agent iterates it in order; the backend serves the same list to the portal
|
||||
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
|
||||
agent does. There is no second copy to drift.
|
||||
|
||||
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
|
||||
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
|
||||
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
|
||||
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
|
||||
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
|
||||
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
|
||||
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
|
||||
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
|
||||
|
||||
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
|
||||
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
|
||||
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
|
||||
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
|
||||
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
|
||||
name. Two sections may share a ZIP folder only when they read different source folders
|
||||
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
|
||||
|
||||
### Conditions
|
||||
|
||||
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
|
||||
effective configuration and the enrollment scenario; unknown values fail closed.
|
||||
|
||||
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
|
||||
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
|
||||
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
|
||||
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
|
||||
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
|
||||
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
|
||||
No ConfigVersion bump — no new wire field.
|
||||
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
|
||||
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
|
||||
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
|
||||
fallback rules count as Classic. The probe is a registry read, evaluated once per build
|
||||
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
|
||||
treated as Classic and logged — the evtx export is skipped, never the package. The channel
|
||||
is exclusively locked while active, so the existing `wevtutil epl` export path
|
||||
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
|
||||
|
||||
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
|
||||
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
|
||||
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
|
||||
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
|
||||
interactive user exists — identical to configured paths). Active ones log
|
||||
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
|
||||
`ADDED / NO MATCH / FOLDER MISSING` lines.
|
||||
|
||||
## Built-ins bypass the configured-path guards — on purpose
|
||||
|
||||
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
|
||||
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
|
||||
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
|
||||
exactly like the five sections that never went through the guard before — and
|
||||
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
|
||||
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
|
||||
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
|
||||
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
|
||||
|
||||
## Configured paths: global ∪ tenant
|
||||
|
||||
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
|
||||
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
|
||||
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
|
||||
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
|
||||
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
|
||||
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
|
||||
same-named folder share one ZIP folder.
|
||||
|
||||
## Portal surfaces
|
||||
|
||||
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
|
||||
neutral condition pills) above the editable global list; add-row first.
|
||||
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
|
||||
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
|
||||
read-only, then the tenant's own entries; every role with a tenant membership can read the
|
||||
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
|
||||
full values in tooltips.
|
||||
|
||||
## Test seams
|
||||
|
||||
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
|
||||
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
|
||||
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
|
||||
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
|
||||
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
|
||||
because Shared has no test project.
|
||||
|
||||
# Examples
|
||||
|
||||
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
|
||||
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
|
||||
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
|
||||
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
|
||||
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
|
||||
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
|
||||
ImeBootstrapperEventLog`. A tenant that had added
|
||||
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
|
||||
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
|
||||
entry is removed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
|
||||
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
|
||||
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
type: Concept
|
||||
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
|
||||
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
|
||||
tags:
|
||||
- agent
|
||||
- delivery-optimization
|
||||
- network
|
||||
- tenant-configuration
|
||||
timestamp: 2026-08-21T00:00:00+02:00
|
||||
---
|
||||
|
||||
# DO GroupId Auto-Set
|
||||
|
||||
## Schema
|
||||
|
||||
### The problem
|
||||
|
||||
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
|
||||
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
|
||||
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
|
||||
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
|
||||
match them by public IP. A GroupId derived from the local network makes the group exactly
|
||||
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
|
||||
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
|
||||
ESP, when the big app downloads actually happen), so it can seed the same value for
|
||||
Autopilot enrollments.
|
||||
|
||||
### The setting
|
||||
|
||||
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
|
||||
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
|
||||
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
|
||||
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
|
||||
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
|
||||
config cache like every plain feature flag.
|
||||
|
||||
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
|
||||
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
|
||||
|
||||
### The fingerprint
|
||||
|
||||
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
|
||||
|
||||
```
|
||||
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
|
||||
```
|
||||
|
||||
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
|
||||
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
|
||||
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
|
||||
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
|
||||
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
|
||||
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
|
||||
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
|
||||
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
|
||||
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
|
||||
|
||||
### Ownership + conflict rules (never fight a foreign policy)
|
||||
|
||||
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
|
||||
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
|
||||
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
|
||||
`GroupId` value name is intentionally not.
|
||||
|
||||
Skip conditions (Info event with `skipReason`, nothing written):
|
||||
|
||||
| skipReason | Meaning |
|
||||
|---|---|
|
||||
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
|
||||
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
|
||||
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
|
||||
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
|
||||
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
|
||||
|
||||
Ownership is tracked through the `StartupEventGate` fingerprint for the
|
||||
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
|
||||
gateway change the agent recognizes its own stale value and updates it — while a value
|
||||
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
|
||||
ours→update, else→skip; unparseable existing values count as foreign). Every write is
|
||||
verified by readback.
|
||||
|
||||
### The event
|
||||
|
||||
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
|
||||
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
|
||||
latch, so each failing start keeps its retry trail). Severity: Info for
|
||||
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
|
||||
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
|
||||
|
||||
## Examples
|
||||
|
||||
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
|
||||
|
||||
```
|
||||
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
|
||||
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
|
||||
```
|
||||
|
||||
Tenant already deploys a DO GroupId via Intune:
|
||||
|
||||
```
|
||||
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
|
||||
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
|
||||
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
|
||||
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
|
||||
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
|
||||
@@ -5,9 +5,12 @@ How the on-device agent works:
|
||||
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
|
||||
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
|
||||
* [DO GroupId Auto-Set](do-group-id-auto-set.md) - the optional Delivery Optimization peering-group seed: deterministic GUID from default gateway IPv4 + MAC (RealmJoin-compatible), written to the DOGroupId policy value with readback, ownership-tracked so foreign Intune/GPO values are never overwritten; skip reasons and the do_group_id_auto_set event.
|
||||
* [System Timeline Watcher](system-timeline-watcher.md) - clock steps (system_clock_changed: oldTime/newTime/timeDeltaMs/process, micro-slew floor) and completed sleep episodes (system_sleep_episode: sleep/hibernate/Modern Standby with authoritative enteredAt/exitedAt) from one System-channel EventLogWatcher with pre-agent backfill; feeds the web clock-set jump cause, the standby duration note, time-attribution SleepSpans and ANALYZE-DEV-012.
|
||||
|
||||
Reading order for newcomers: overview → decision engine → persistence.
|
||||
|
||||
@@ -22,10 +22,21 @@ invisible. The watcher closes that gap **event-driven, with zero polling**.
|
||||
|
||||
# Mechanism
|
||||
|
||||
- `PowerStateWatcherHost` (`Orchestration/Hosts`) subscribes to the WMI push event
|
||||
`SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE:
|
||||
fires on AC↔DC switches and battery-percentage steps, granularity set by the battery
|
||||
driver, typically 1–3%). The event carries no payload — each arrival re-probes via the
|
||||
- `PowerStateWatcherHost` (`Orchestration/Hosts`) arms a WMI event subscription with a
|
||||
two-step chain:
|
||||
1. `SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10` (PBT_APMPOWERSTATUSCHANGE:
|
||||
true kernel push, fires on AC↔DC switches and battery-percentage steps).
|
||||
2. **Fallback** `SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance
|
||||
ISA 'Win32_Battery'` — required because on Windows 11 24H2/25H2 (observed on builds
|
||||
26200 and 26220, session 161b838c) the `MS_Power_Management_Event_Provider` is still
|
||||
registered in the repository but its activation fails with `WBEM_E_NOT_FOUND`: the
|
||||
legacy push provider no longer loads on exactly the OS builds new devices enroll with.
|
||||
The WITHIN sampling runs inside WinMgmt (one Win32_Battery snapshot every 30 s), not in
|
||||
the agent; worst-case detection latency ≈ 35 s incl. debounce. `collector_degraded`
|
||||
(`watcher_arm_failed`) is emitted only when BOTH attempts fail; the agent log's arm
|
||||
line names the active mode.
|
||||
|
||||
Either way the arriving event's payload is ignored — each arrival re-probes via the
|
||||
existing `PowerStateProbe.Probe()` (cheap P/Invoke).
|
||||
- Snapshots are diffed by the pure `PowerStateTransitionTracker`
|
||||
(`Monitoring/Enrollment/SystemSignals`), which decides what to emit. The host only owns
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: Concept
|
||||
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
|
||||
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
|
||||
tags:
|
||||
- agent
|
||||
- eventlog
|
||||
- time
|
||||
- clock-skew
|
||||
- standby
|
||||
- collector
|
||||
- time-attribution
|
||||
- analyze-rules
|
||||
timestamp: 2026-08-21T00:00:00+02:00
|
||||
---
|
||||
|
||||
# System Timeline Watcher
|
||||
|
||||
Two things silently distort every session timeline and its wall-clock duration:
|
||||
|
||||
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
|
||||
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
|
||||
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
|
||||
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
|
||||
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
|
||||
|
||||
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
|
||||
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
|
||||
channel with one provider-qualified XPath and forwards both facts as events.
|
||||
|
||||
# Sources and events
|
||||
|
||||
| Source (System channel) | Emitted event | Key payload |
|
||||
|---|---|---|
|
||||
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
|
||||
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
|
||||
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
|
||||
|
||||
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
|
||||
need an orphan-tolerant state machine across agent restarts for zero information gain — an
|
||||
episode without a 507 never completed and would not be emitted under any design.
|
||||
|
||||
# Thresholds (constants, not knobs)
|
||||
|
||||
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
|
||||
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
|
||||
still claimed by the dedup watermark.
|
||||
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
|
||||
token/cert validation windows; matches the web TimeJumpBadge threshold).
|
||||
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
|
||||
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
|
||||
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
|
||||
floor — they only exist for genuinely completed S3/S4 transitions.
|
||||
|
||||
# Payload times are authoritative
|
||||
|
||||
The decisive clock correction happens exactly when the device clock is untrustworthy, and
|
||||
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
|
||||
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
|
||||
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
|
||||
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
|
||||
convention) so rows land where the wake/step happened.
|
||||
|
||||
# Backfill and dedup
|
||||
|
||||
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
|
||||
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
|
||||
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
|
||||
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
|
||||
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
|
||||
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
|
||||
pre-agent records (the live watcher arms before the backfill scan).
|
||||
|
||||
# Non-activity classification (both sides)
|
||||
|
||||
Both event types are environment observation, not enrollment progress:
|
||||
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
|
||||
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
|
||||
stall must stay Stalled).
|
||||
|
||||
# Consumers
|
||||
|
||||
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
|
||||
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
|
||||
`system_clock_changed` deltas and matches a backward display step against a backward clock
|
||||
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
|
||||
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
|
||||
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
|
||||
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
|
||||
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
|
||||
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
|
||||
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
|
||||
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
|
||||
standby chip in `TimeAttributionLane`.
|
||||
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
|
||||
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
|
||||
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
|
||||
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
|
||||
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
|
||||
- `rules/analyze/ANALYZE-DEV-012.json`
|
||||
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
|
||||
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
|
||||
with `SleepTime`/`WakeTime` across a hibernate.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Client-Certificate Tenant Binding — Closing the Cross-Tenant Gap
|
||||
description: Why a chain-valid Intune MDM certificate does not prove the caller belongs to the tenant it claims, which certificate extension carries the Entra tenant id (and which two look like it but do not), and the shadow-then-enforce rollout that measures field coverage before it can lock devices out.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- authentication
|
||||
- certificates
|
||||
- intune
|
||||
- multi-tenant
|
||||
timestamp: 2026-08-24T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Agent requests authenticate with mTLS. `CertificateValidator` pins the chain to the embedded
|
||||
Intune roots (`X509ChainTrustMode.CustomRootTrust`), checks the validity window and requires the
|
||||
Client-Authentication EKU. All of that together proves exactly one thing: **the certificate was
|
||||
issued by the Microsoft Intune MDM Device CA to some tenant**.
|
||||
|
||||
Those roots are shared by every Intune tenant on the planet. Nothing in the certificate stage ties
|
||||
the caller to the tenant in the request. The tenant scoping came entirely from the downstream
|
||||
device validators — the serial number must appear in that tenant's Autopilot / imported-device /
|
||||
Cloud PC inventory. Device serial numbers are not secrets; they are printed on the chassis.
|
||||
|
||||
So the pre-existing attack was: enroll a device in your own Intune tenant, obtain a legitimate
|
||||
device certificate, learn one serial number of the victim tenant, and submit telemetry as that
|
||||
tenant. Certificate validation passes, device validation passes.
|
||||
|
||||
# Mechanism
|
||||
|
||||
The Intune MDM Device CA stamps the customer's identifiers into the leaf certificate. Since the
|
||||
agent presents this certificate in the TLS handshake, its contents are proof of possession and the
|
||||
backend can compare them against the requested tenant — no Graph call, no additional consent, and
|
||||
nothing the agent has to send.
|
||||
|
||||
## Which extension carries what
|
||||
|
||||
Verified by decoding a real field certificate (issued 2026-05-08, `Functions.Tests/device-cert-sample.pem`):
|
||||
|
||||
| OID | Encoding | Content |
|
||||
| --- | --- | --- |
|
||||
| `1.2.840.113556.5.14` | nested OCTET STRING (`04 10` + 16 bytes) | **Entra tenant id** — the one to compare |
|
||||
| `1.2.840.113556.5.6` | nested OCTET STRING (`04 10` + 16 bytes) | Intune **Account** id — a *different* GUID, never equal to the tenant id |
|
||||
| `1.2.840.113556.5.4` | **16 raw bytes, no wrapper** | Intune device id, identical to the certificate's Subject CN |
|
||||
|
||||
Two traps are worth stating explicitly, because both cost real debugging time:
|
||||
|
||||
1. `…5.6` is widely described as "the tenant id (AccountID)". It is not the Entra tenant id.
|
||||
Comparing it against the requested tenant would reject every request in production.
|
||||
2. The encodings are not uniform. `…5.4` holds the GUID bare while the other two nest it in a
|
||||
second OCTET STRING, so a decoder written against one shape silently fails on the other.
|
||||
`MsDeviceCertificateOids.TryParseGuid` accepts both, and all GUIDs are Microsoft
|
||||
little-endian (what `new Guid(byte[])` expects).
|
||||
|
||||
A 16-byte input is inherently ambiguous — it is indistinguishable from a truncated wrapper and is
|
||||
always read as a bare GUID. That is harmless here: the value is only ever compared for equality
|
||||
against a known tenant id, never trusted as a standalone claim.
|
||||
|
||||
## Where it is NOT
|
||||
|
||||
`EntraDeviceCertHelper` reads tenant and device ids from the **MS-Organization-Access** certificate
|
||||
(`1.2.840.113556.1.5.284.*`). That certificate is not part of the authentication path at all — the
|
||||
agent selects the Intune MDM certificate by exact issuer match (`CN=Microsoft Intune MDM Device CA`,
|
||||
see `CertificateHelper.SelectMdmCertificate`) and the backend pins only the MDM chain. The Entra
|
||||
certificate is used purely as a local fallback for `TenantIdResolver`; it never leaves the device.
|
||||
|
||||
Sending it in a header instead would prove nothing: only the certificate used in the TLS handshake
|
||||
demonstrates possession of a private key. A certificate copied into a custom header can be replayed
|
||||
by anyone who has seen it once.
|
||||
|
||||
# Rollout
|
||||
|
||||
Enforcing immediately would be reckless: any device population whose certificates predate the
|
||||
extension, or whose CA behaves differently (sovereign clouds), would be locked out at once. The
|
||||
rollout therefore has staged consequences.
|
||||
|
||||
**Stage 1 — shadow (current).** `SecurityValidator.ObserveCertTenantBinding` evaluates the binding
|
||||
after certificate validation and logs the outcome. It returns `void`, changes no state, and swallows
|
||||
its own exceptions, so it cannot fail a request that would otherwise be accepted. The pure comparison
|
||||
lives in `CertTenantBinding.Evaluate`; the outcome codes (`Match`, `Mismatch`, `ExtensionMissing`,
|
||||
`Unparseable`, `RequestTenantNotAGuid`) are stable and queried by exact match in KQL.
|
||||
`WouldRejectUnderEnforcement` records what stage 2 *would* have done, so the telemetry can be read
|
||||
without re-deriving the rule at query time.
|
||||
|
||||
Volume control: the outcomes that inform the decision (mismatch, missing, undecodable) are always
|
||||
logged, because they are expected to be rare and each is individually interesting. The bulk `Match`
|
||||
case is logged only when the certificate was freshly validated rather than served from the 5-minute
|
||||
thumbprint cache (`CertificateValidationResult.FromCache`). Both numerator and denominator are
|
||||
therefore counted per certificate-window rather than per request, which keeps the ratio meaningful
|
||||
while cutting the volume by roughly the request-per-device factor.
|
||||
|
||||
**Stage 2 — enforce.** Once telemetry shows the `ExtensionMissing` rate is negligible, `Mismatch`
|
||||
becomes a rejection. The decision that still has to be made is the policy for certificates without
|
||||
the extension: skip with a warning, or reject. Grep marker for every stage-2 site:
|
||||
`CERT-TENANT-BINDING-SHADOW`.
|
||||
|
||||
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
|
||||
unaffected by either stage.
|
||||
|
||||
# Examples
|
||||
|
||||
Read the shadow telemetry:
|
||||
|
||||
```kusto
|
||||
traces
|
||||
| where message has "AgentCertTenantBinding"
|
||||
| extend Outcome = tostring(customDimensions.Outcome),
|
||||
Tenant = tostring(customDimensions.TenantId)
|
||||
| summarize count() by Outcome, Tenant
|
||||
```
|
||||
|
||||
Anything in `Mismatch` is a cross-tenant certificate; anything in `ExtensionMissing` is a device
|
||||
that stage-2 enforcement would have to make a policy decision about.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
|
||||
shared ASN.1 GUID decoder (one implementation for agent and backend).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs` — extension extraction
|
||||
alongside chain validation, cached per thumbprint.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/CertTenantBinding.cs` — the pure comparison.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — shadow observation.
|
||||
* [W365 Cloud PC Device Validation](cloudpc-device-validation.md) — the other consumer of this
|
||||
certificate's identity fields (Subject CN = Intune device id).
|
||||
@@ -78,7 +78,8 @@ Durations shown for attempts are the sessions' authoritative `DurationSeconds` v
|
||||
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
|
||||
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
|
||||
success session (the platform's StartedAt-date convention; late terminals converge via
|
||||
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
|
||||
the sweep, which slices the tick's shared projected window scan — see
|
||||
`time-attribution.md`). All counts are **additive**, so a window rate is the ratio of summed daily
|
||||
rows — there is no rolling-window row (unlike the median-based time-attribution
|
||||
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
|
||||
on purpose (the UI needs the n for "insufficient data (n=…)").
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
type: concept
|
||||
title: Plan Downgrade Policy & the Retention Grace Period
|
||||
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
|
||||
resource: backend
|
||||
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
|
||||
timestamp: 2026-08-22
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## The downgrade model: gate, don't destroy
|
||||
|
||||
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
|
||||
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
|
||||
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
|
||||
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
|
||||
(≤ ~5 min config cache):
|
||||
|
||||
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
|
||||
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
|
||||
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
|
||||
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
|
||||
assignments are never touched. Resurrects on re-upgrade.
|
||||
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
|
||||
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
|
||||
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
|
||||
|
||||
**Retention is the one exception**: the retention sweep (12-hourly
|
||||
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
|
||||
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
|
||||
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
|
||||
irreversibly cascade-deleting the 90–365-day band on the next tick.
|
||||
|
||||
## The retention grace period (30 days)
|
||||
|
||||
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
|
||||
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
|
||||
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
|
||||
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
|
||||
swept, grace or not.
|
||||
|
||||
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
|
||||
returns null while effectively Pro; grace end = latest anchor + 30d):
|
||||
|
||||
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
|
||||
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
|
||||
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
|
||||
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
|
||||
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
|
||||
the trial timestamp disappears and could not anchor). Cleared on any transition back to
|
||||
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
|
||||
resets the grace clock.
|
||||
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
|
||||
(read-time, like edition resolution). A planTier downgrade under a still-active trial
|
||||
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
|
||||
anchors the grace then.
|
||||
|
||||
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
|
||||
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
|
||||
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
|
||||
which is correct: no tenant had been downgraded before the feature existed.
|
||||
|
||||
## Ops visibility (dual-registered event types)
|
||||
|
||||
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
|
||||
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
|
||||
response body also returns `retentionGraceEndsUtc`.
|
||||
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
|
||||
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
|
||||
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
|
||||
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
|
||||
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
|
||||
|
||||
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
|
||||
so Telegram alert rules can route them.
|
||||
|
||||
## Deliberate non-goals
|
||||
|
||||
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
|
||||
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
|
||||
grace a tenant admin cannot SET a value above the Community cap. The grace protects
|
||||
existing data; new writes follow the new plan.
|
||||
* The feature-flags endpoint keeps publishing the plain edition cap
|
||||
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
|
||||
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Silent-Session Classification (Sweep, Max-Lifetime, Retro-Reconcile)
|
||||
description: How a session whose agent went silent gets its honest status — the two-stage maintenance sweep (2h Stalled marker, 5h timeout classification), the EnrollmentTimeoutClassifier rule order incl. the WhiteGlove Part-2 and self-deploying gates, which ingest paths share the classifier, and the admin retro-reconcile modes.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
|
||||
tags:
|
||||
- backend
|
||||
- maintenance
|
||||
- session-status
|
||||
- classification
|
||||
- whiteglove
|
||||
- self-deploying
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Silent-Session Classification
|
||||
|
||||
A session only ever becomes terminal through evidence. When the agent stops reporting
|
||||
(power-off, reboot without relaunch, Wi-Fi never re-associating, max-lifetime watchdog),
|
||||
the backend must decide the status from what it last saw — it never hard-fails a session
|
||||
for being silent. One pure function holds that decision,
|
||||
`EnrollmentTimeoutClassifier.ClassifyTimedOutSession`, and every silent-session path calls it
|
||||
so a session gets the same verdict regardless of which path noticed the silence first.
|
||||
|
||||
# Schema
|
||||
|
||||
## Inputs (`EspProvisioningRollup` + session flags)
|
||||
|
||||
`ExtractRollup(events)` distills the event stream into facts: Device/Account Setup
|
||||
subcategory counts (incl. the 30s fallback-confirmed messages), `HasExplicitFailure`,
|
||||
`HasEnrollmentComplete` vs. `HasTerminalComplete` (the Part-1 `whiteglove_complete`),
|
||||
`HasAgentEmergencyBreak`, `DesktopArrived`, `HelloResolved`, `HelloPolicyDisabled`,
|
||||
`SkipUserEsp`, RealmJoin detection/resolution. Session-row flags are passed alongside:
|
||||
`IsPreProvisioned` + `ResumedAt` (WhiteGlove Part 2) and `IsSelfDeployingProfile`
|
||||
(registry-confirmed `CloudAssignedOobeConfig 0x20|0x40`, set at registration).
|
||||
|
||||
## Rule order (first match wins)
|
||||
|
||||
| # | Condition | Verdict |
|
||||
| --- | --- | --- |
|
||||
| 1 | `HasExplicitFailure` | Failed |
|
||||
| 1b | `IsWhiteGloveAwaitingUser`: pre-provisioned + resumed + Device Setup done + **no** user evidence since resume + no explicit failure/enrollment_complete | AwaitingUser within grace; Succeeded (honest WG reason) past grace |
|
||||
| 1c | `IsSelfDeployingProvisioned`: self-deploying profile + Device Setup done + no explicit failure | **Succeeded** (reconcile) — no grace, no AwaitingUser: the profile has no user phase |
|
||||
| 2 | Account Setup all-succeeded, or `enrollment_complete`, or `whiteglove_complete` on a **non-resumed** session | Succeeded |
|
||||
| 3 | `HasAgentEmergencyBreak` | Incomplete immediately (agent self-destructed, nothing can arrive) |
|
||||
| 4 | `DesktopArrived` + (Hello terminal, or Hello disabled + SkipUserEsp) | Succeeded (user provably finished) |
|
||||
| 5 | Device Setup done | AwaitingUser within grace, Incomplete past grace |
|
||||
| 6 | otherwise | Incomplete |
|
||||
|
||||
Grace = `ResolveGraceHours(SessionGraceHours, AbsoluteMaxSessionHours)` (~51h default),
|
||||
measured from `ResumedAt ?? StartedAt`. Every Succeeded-reconcile reason carries the
|
||||
silence-transparency clause (`AppendReconcileTiming`: last agent contact, silence, verdict time).
|
||||
|
||||
## Why rule 1c exists (kiosk tenant audit 2026-08-23)
|
||||
|
||||
On a self-deploying profile Device ESP all-succeeded IS the enrollment's end; the agent's
|
||||
own SelfDeploying terminal only adds a 5-min confirmation window
|
||||
(`DecisionEngine.SelfDeploying.s_deviceOnlyEspDetectionWindow`). Tenant `aebdce78` (school,
|
||||
Wi-Fi ThinkBooks, a language-change app forcing a reboot right after Device ESP) lost that
|
||||
window on 836 of ~970 sessions in one week: the agent died 0–4 min after
|
||||
`DeviceSetup fallback_confirmed`, the reboot relaunch never registered (30s registration
|
||||
budget on a Wi-Fi link still associating), and rule 5 labeled finished kiosks
|
||||
"awaiting user / Account Setup 0/5" → Incomplete 51h later. Nobody signs in on these devices
|
||||
(prepared during the holidays, boxed, in service) — AwaitingUser was factually wrong and
|
||||
Incomplete turned a working rollout into a red wall. Other self-deploying tenants completed
|
||||
normally in the same window, so this is a classification gap, not a profile-wide agent bug.
|
||||
Agent-side companion fix: `SessionRegistrationHelper` now waits up to 15s for a network link
|
||||
and retries 6× (~62s backoff) instead of 5× (~30s).
|
||||
|
||||
## Callers (all pass both gate inputs)
|
||||
|
||||
| Path | File | Notes |
|
||||
| --- | --- | --- |
|
||||
| Sweep stage 1 (2h agent-silent marker) | `MaintenanceService.MarkStalledSessionsAsTimedOutAsync` | Default: InProgress → Stalled. Gates run BEFORE the Stalled write: `TryMarkWhiteGloveAwaitingUserAsync` (AwaitingUser) and `TryReconcileSelfDeployingAsync` (Succeeded). Only flagged sessions pay the event read. |
|
||||
| Sweep stage 2 (SessionTimeoutHours, 5h default) | same | InProgress/Stalled/AwaitingUser past the window and past the 2h silence cutoff → full classification; AwaitingUser within grace is skipped without an event read. |
|
||||
| Agent max-lifetime (`enrollment_failed`/max_lifetime, `agent_shutting_down`/max_lifetime) | `EventIngestProcessor.ApplyMaxLifetimeVerdictAsync` | Watchdog = "agent stopped waiting", not a verdict; classified from evidence. Pending sessions exempt. |
|
||||
| Late-telemetry reconcile | `EventIngestProcessor.TryLateTelemetryReconcileAsync` | Heal-only: applies a Succeeded verdict to Incomplete/AwaitingUser rows when straggler events arrive. |
|
||||
| Agent stall probe (`session_stalled`) | `EventIngestProcessor.TryMapStallToWhiteGloveAwaitingUserAsync` | Only the WG gate (1b); a stall probe on a self-deploying device with Device Setup done means the agent is alive and something else blocks — stays Stalled. |
|
||||
| Admin retro-reconcile | `LegacyReclassificationService` | Modes `legacy_timeouts`, `pending_orphans`, `self_deploying_silent` (Incomplete/AwaitingUser/Stalled rows with the profile flag, heal-only to Succeeded, admin-marked rows skipped). `POST /api/maintenance/reclassify-legacy?mode=…&dryRun=false&tenantId=…&maxSessions=…`, dry-run by default. |
|
||||
|
||||
## Known gap (decided, not built)
|
||||
|
||||
After max-lifetime the agent writes `enrollment-complete.marker` and self-destructs, so an
|
||||
AwaitingUser verdict from that path can never heal — it is a 51h countdown to Incomplete.
|
||||
The emergency-break path (rule 3) already terminalizes immediately; max-lifetime still goes
|
||||
through the grace window by design ("user simply absent"). Revisit if customers read
|
||||
AwaitingUser as "still possible".
|
||||
|
||||
# Examples
|
||||
|
||||
* Session `195593e2` (tenant aebdce78): `DeviceSetup 4/4` + `fallback_confirmed`, `AccountSetup 0/5`
|
||||
(IME false positive), Hello disabled, SkipUser=True, silent after 13:01 → rule 1c →
|
||||
Succeeded "self-deploying profile — Device Setup fully provisioned … agent went silent
|
||||
before confirming completion. Agent last reported 2026-08-21 13:01 UTC …".
|
||||
* Same event shape on a user-driven profile → rule 5 → AwaitingUser (within grace) /
|
||||
Incomplete (past grace). Guarded by
|
||||
`Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts`.
|
||||
* fairstone WG Part 2 reseal-reboot, technician powers off at logon screen → rule 1b →
|
||||
AwaitingUser; user unboxes days later → ingest completion heals to Succeeded.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs` — rollup + rule order, `IsWhiteGloveAwaitingUser`, `IsSelfDeployingProvisioned`, reason helpers.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — `MarkStalledSessionsAsTimedOutAsync`, `TryMarkWhiteGloveAwaitingUserAsync`, `TryReconcileSelfDeployingAsync`.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs` — max-lifetime verdict, late-telemetry reconcile, stall mapping.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs`, `Functions/Admin/ReclassifyLegacySessionsFunction.cs` — retro modes.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — `GetAgentSilentSessionsAsync`, `GetStalledSessionsAsync`, `GetSelfDeployingSilentSessionsAsync`.
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — link wait + 6-attempt registration budget.
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs` — the agent-side 5-min SelfDeploying terminal window.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs` — rule coverage incl. the 2026-08-23 self-deploying block.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Table Schema Sentinel (startup table initialization)
|
||||
description: Why a cold start no longer issues one CreateTableIfNotExists per registered table - a hash over TableNames.All stored in AdminConfiguration gates the full pass, the SessionsIndex backfill only runs after a full pass and behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs
|
||||
tags:
|
||||
- backend
|
||||
- table-storage
|
||||
- startup
|
||||
- maintenance
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Problem
|
||||
|
||||
`TableInitializerService` (hosted service) called `InitializeTablesAsync` on every process start, which issued `CreateTableIfNotExistsAsync` for every entry of `Constants.TableNames.All` (66 at the time of writing, DoP 8). Each call is a Create POST whose 409 is swallowed - on Flex Consumption with frequent cold starts that was ~9 serial round-trip waves before the first request. It was followed by a SessionsIndex emptiness query whose *error* path returned "empty", so a transient storage error triggered a full Sessions scan with serial upserts - on every scaled-out instance at once.
|
||||
|
||||
## Sentinel
|
||||
|
||||
* Row `AdminConfiguration` / PK `SchemaSentinel` / RK `tables`, property `TableSchemaHash` (+ `TableCount`, `UpdatedUtc`).
|
||||
* Value = `ComputeTableSchemaHash(TableNames.All)`: SHA-256 over the ordinal-sorted, newline-joined table names. Derived, never hand-maintained: adding a table to `TableNames.All` changes the hash by construction (`TableLifecycleBucketTests` already forces every constant into `All`).
|
||||
* `InitializeTablesAsync` (returns `bool fullPassRan`):
|
||||
1. Point-read the sentinel. Match -> mark initialized, **zero** table calls, return `false`.
|
||||
2. Missing / stale / unreadable (fresh storage has no AdminConfiguration table yet) -> `EnsureAllTablesAsync` (the old full pass), then upsert the sentinel **only if no table failed**, return `true`. A failed pass leaves `_tablesInitialized = false`, so the next call retries.
|
||||
* Scale-out: two instances that both see a stale sentinel both run the idempotent pass - harmless.
|
||||
|
||||
## SessionsIndex backfill
|
||||
|
||||
* Only evaluated when the full pass ran (`fullPassRan == true`): an empty index can only exist on fresh storage, which is exactly the case without a sentinel.
|
||||
* `IsSessionIndexEmptyAsync` now returns `false` on error (skip), not `true`.
|
||||
* `TryClaimSessionIndexBackfillAsync`: conditional `AddEntityAsync` on `SchemaSentinel` / `sessionIndexBackfill` - only the winner scans; 409 or any error -> skip. The manual maintenance backfill (`RunManualAsync`) remains the safety net.
|
||||
|
||||
## Known gap and repair
|
||||
|
||||
A table deleted out-of-band (cleanup skill, portal, tests) is not noticed at startup anymore. `IStorageInitializer.EnsureAllAsync` runs as the first step of the daily timer maintenance (`MaintenanceService.RunAllAsync`), recreating it within 24h. Storage helpers are fail-soft in the meantime.
|
||||
|
||||
## Measuring it
|
||||
|
||||
`StartupTelemetryService` (hosted service) emits two App Insights metrics on `IHostApplicationLifetime.ApplicationStarted` - i.e. after every hosted service finished `StartAsync`:
|
||||
|
||||
* `BackendStartupMs` - process start (`Process.StartTime`) to host ready; includes runtime, DI and all hosted services.
|
||||
* `BackendTableInitMs` - the `InitializeTablesAsync` slice alone (`TableStorageService.LastInitialization`).
|
||||
|
||||
Dimensions: `version`, `commit`, `tableInitFullPass`, `instance`. Metrics bypass adaptive sampling and the worker log level, so they arrive even though Information traces do not. The MCP server logs the same idea as one `{"type":"boot","readyMs":…}` stderr line per start (ContainerAppConsoleLogs_CL, see `.claude/commands/mcp-telemetry.md`).
|
||||
|
||||
```kusto
|
||||
customMetrics
|
||||
| where name in ("BackendStartupMs", "BackendTableInitMs")
|
||||
| summarize p50 = percentile(value, 50), p95 = percentile(value, 95), n = count()
|
||||
by name, version = tostring(customDimensions.version), fullPass = tostring(customDimensions.tableInitFullPass)
|
||||
```
|
||||
|
||||
# Examples
|
||||
|
||||
* Deploy that adds a table: first cold start after the deploy runs the full pass once (log: "Table schema sentinel missing or stale"), every later start logs "Table schema sentinel matches (...)".
|
||||
* Fresh storage account: sentinel read throws TableNotFound -> full pass -> index empty -> one instance claims and backfills.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs` - sentinel, `InitializeTablesAsync`, `EnsureAllTablesAsync`, `TryClaimSessionIndexBackfillAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableInitializerService.cs` - startup gating
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` - daily `EnsureAllTablesAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/StartupTelemetryService.cs` - `BackendStartupMs` / `BackendTableInitMs`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/index.ts` - `{"type":"boot"}` line
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TableSchemaSentinelTests.cs`, `StartupTelemetryServiceTests.cs`
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Telemetry Ingest Shaping (App Insights / Log Analytics)
|
||||
description: What the backend deliberately keeps out of Application Insights and why - one canonical request record (worker copy, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation), expected storage outcomes (404/412/409) and successful SignalR REST calls filtered in the worker, and the query rules that follow.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs
|
||||
tags:
|
||||
- backend
|
||||
- telemetry
|
||||
- application-insights
|
||||
- cost
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Telemetry Ingest Shaping
|
||||
|
||||
The backend is storage-I/O heavy and runs on the Functions isolated worker, which emits telemetry from TWO processes (host and worker). Left alone, Log Analytics ingestion is dominated by rows nobody reads. This document records what is removed, where, and what that means for queries.
|
||||
|
||||
# Schema
|
||||
|
||||
## Request telemetry — one canonical record
|
||||
|
||||
| Source | Emitted by | Content | Fate |
|
||||
|---|---|---|---|
|
||||
| Host HTTP request (`Properties.Source` empty, `Url` set) | Functions host process | Name, duration, status — no tenant/user/correlation | **Dropped at ingestion** by the workspace transformation on `AppRequests` (`infra/appinsights-workspace-transforms.bicep`) |
|
||||
| Host non-HTTP invocation (`Url` empty) | Functions host process | Timer / queue executions | Kept (the worker middleware never sees them) |
|
||||
| Worker request (`Properties.Source == 'WorkerMiddleware'`) | `RequestTelemetryMiddleware` | TenantId, UserId, UserRole, CorrelationId, ClientSource, McpToolName, HttpPath | **Kept, unsampled** — the canonical record |
|
||||
|
||||
Why two mechanisms:
|
||||
|
||||
* The host request item cannot be suppressed from worker code or from `host.json` (`Host.Results` controls log traces, not the request item; verified 2026-06-09). The only lever is a Log Analytics workspace transformation: `source | where tostring(Properties.Source) == 'WorkerMiddleware' or isempty(Url)`.
|
||||
* `host.json` `samplingSettings.excludedTypes: "Request;Event"` only applies to the host process. The worker's `AddApplicationInsightsTelemetryWorkerService` runs its own adaptive sampling; before 2026-08-23 worker request rows carried `ItemCount` 2–5 on weekdays (≈30 % of individual requests missing, `count()` undercounting by the same share — `sum(ItemCount)` matched the host copy to 1.00 per day). Re-configuring the worker sampling chain did not take effect in the isolated worker (see the L4 note in `Program.cs`). The per-item opt-out does: the middleware sets `SamplingPercentage = 100` on its own `RequestTelemetry`, and the SDK sampling processor passes through every item whose sampling percentage is already set.
|
||||
|
||||
## Dependency telemetry — keep only signal
|
||||
|
||||
`StorageDependencyFilterProcessor` (worker pipeline) drops:
|
||||
|
||||
* successful Azure Storage calls (Table/Queue/Blob) in both shapes — the HTTP shape (`Azure table`, target `*.table.core.windows.net`) and the Azure SDK ActivitySource shape (`InProc | Microsoft.Tables`, `InProc | Microsoft.Storage`);
|
||||
* storage calls with an **expected outcome**: `404` (point-read miss — EventTypeIndex, BlockedDevices lookups), `412` (ETag precondition), `409` (idempotent insert conflict). HTTP-shape rows carry the code in `ResultCode`; InProc rows only in `Properties["Error"]` (`Status: 404 (`). Before this filter these were 100 % of the remaining "failed" storage rows (~540 MB/week, two rows per call);
|
||||
* successful Azure SignalR Service REST calls the worker makes itself (`*.service.signalr.net`).
|
||||
|
||||
The bulk of SignalR REST traffic (group add/remove per connection, ~470 MB/week) is emitted by the **host** process for the SignalR output binding (`SDKVersion rdddsc:*`) and never passes the worker pipeline — live-verified after the 2026-08-23 deploy (worker rows: 0, host rows: ~560/h). Those are removed by the second data flow of the same workspace transformation: `AppDependencies | where not(Success == true and Target endswith '.service.signalr.net')`.
|
||||
|
||||
Everything else passes: storage 429/5xx/auth/timeouts, failed SignalR calls, all HTTP/Graph/NVD/MSRC dependencies, the worker's own `InProc Invoke` span, and every non-dependency item.
|
||||
|
||||
## Measured baseline (7 days before the change, `_BilledSize`, `autopilotmonitor-api-eu`)
|
||||
|
||||
| Table | Rows | MB | After |
|
||||
|---|---|---|---|
|
||||
| AppDependencies | 976k | 1248 | ≈ 240 (SignalR + expected-outcome rows gone) |
|
||||
| AppRequests host copy | 274k | 323 | ≈ 0 (only timer/queue rows) |
|
||||
| AppRequests worker copy | 189k | 208 | ≈ 300 (now 100 % of requests) |
|
||||
| AppTraces | 142k | 137 | unchanged |
|
||||
| AppEvents | 6k | 4 | unchanged — not worth touching |
|
||||
|
||||
Net ≈ −1.3 GB/week with MORE usable data (every request now has tenant context).
|
||||
|
||||
# Examples
|
||||
|
||||
Counting requests (exact since 2026-08-23; for older data replace `count()` with `sum(itemCount)`):
|
||||
|
||||
```kql
|
||||
requests
|
||||
| where customDimensions.Source == 'WorkerMiddleware'
|
||||
| summarize total=count(), failed=countif(success == false) by name
|
||||
```
|
||||
|
||||
Real storage trouble only:
|
||||
|
||||
```kql
|
||||
dependencies
|
||||
| where type startswith 'InProc | Microsoft.Tables' or target endswith '.table.core.windows.net'
|
||||
| summarize count() by resultCode, tostring(customDimensions.Error)
|
||||
```
|
||||
|
||||
Post-deploy verification of the per-item sampling bypass (must be `ItemCount == 1` only):
|
||||
|
||||
```kql
|
||||
AppRequests
|
||||
| where TimeGenerated > ago(1h) and tostring(Properties.Source) == 'WorkerMiddleware'
|
||||
| summarize rows=count() by ItemCount
|
||||
```
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `SamplingPercentage = 100` before `TrackRequest`.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Telemetry/StorageDependencyFilterProcessor.cs` + `StorageDependencyFilterProcessorTests.cs` — the filter contract.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Program.cs` — the failed L4 worker-sampling attempt and why it must not be retried.
|
||||
* `infra/appinsights-workspace-transforms.bicep` — the AppRequests transformation DCR, apply/verify/rollback commands.
|
||||
* `.claude/commands/backend-logs.md`, `session-debug.md` — query conventions.
|
||||
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
timestamp: 2026-08-23
|
||||
---
|
||||
|
||||
# Schema
|
||||
@@ -58,6 +58,46 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
## Mail transport
|
||||
|
||||
Both transactional mails (welcome, post-offboarding farewell) go through the
|
||||
provider-neutral `EmailService` (`IEmailService` for the welcome path,
|
||||
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
|
||||
detail confined to one private transport method plus the `Email:*` configuration
|
||||
section — a provider swap must not rename classes, DI registrations or callers.
|
||||
|
||||
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
|
||||
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
|
||||
`ResiliencePolicies.Notification` retry policy (15 s timeout).
|
||||
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
|
||||
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
|
||||
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
|
||||
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
|
||||
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
|
||||
Never throws — both callers rely on that fail-soft contract.
|
||||
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
|
||||
receives only the recipient address and the tenant domain, which is exactly the claim on
|
||||
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
|
||||
|
||||
## Template overrides (operator-editable)
|
||||
|
||||
Global Admins can replace either HTML body without a deployment (portal: Settings →
|
||||
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
|
||||
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
|
||||
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
|
||||
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
|
||||
|
||||
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
|
||||
matching the built-ins. Subjects are not editable (pinned constants).
|
||||
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
|
||||
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
|
||||
read path is not — a broken store surfaces as an error instead of silently showing the
|
||||
built-in.
|
||||
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
|
||||
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
|
||||
contact address (activation notification email → `ContactEmail` → caller UPN) with the
|
||||
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
@@ -73,3 +113,8 @@ paths never duplicate afterwards.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
|
||||
|
||||
@@ -105,6 +105,11 @@ the partition).
|
||||
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
|
||||
late-terminating sessions into their StartedAt-date aggregates, and enforces version
|
||||
purity — a stale-version row whose events aged out counts as missing, never mixed.
|
||||
Reads the tick's **shared window scan** (`MaintenanceService.LoadSweepWindowSessionsAsync`,
|
||||
one projected 35-day cross-tenant drain via `MaintenanceSweepSessionProjection` that
|
||||
also feeds the device-journey and verdict-calibration sweeps) — the StartedAt-only
|
||||
filter is a full-table read in Table Storage, so it is done once per tick, not once per
|
||||
sweep; `MaintenanceSweepProjectionEquivalenceTests` pins the column set.
|
||||
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
|
||||
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
|
||||
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Verdict Calibration (classifier thermometer)
|
||||
description: How every session status write declares its VerdictPath, how overriding a verdict preserves it as PriorStatus/PriorVerdictPath, how the daily calibration aggregate counts paths with the re-enrollment proxy and the correction stream, and how the drift radar (share / silence / evidence-gap) turns that into operator-only ops events.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs
|
||||
tags:
|
||||
- backend
|
||||
- sessions
|
||||
- classifier
|
||||
- calibration
|
||||
- regression-radar
|
||||
timestamp: 2026-08-23T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Verdict Calibration (classifier thermometer)
|
||||
|
||||
A session's terminal status is written by ~20 code paths: the agent's own terminal events,
|
||||
the silence classifier (`EnrollmentTimeoutClassifier`, rules 1–6) reached via the
|
||||
maintenance sweep / the agent's max-lifetime shutdown / late-telemetry heal / retro
|
||||
reclassification, the analyze-rule engine, admin marks and session superseding. Verdict
|
||||
calibration makes every one of those paths **countable**, tracks how often a path's verdict
|
||||
was later **overridden**, attaches the delayed **re-enrollment proxy**, and raises an
|
||||
operator-only alarm when a path's share **drifts**. It is deterministic, fact-based tuning
|
||||
input for the rules — explicitly not a learned model, and never a customer-facing feature.
|
||||
|
||||
# Schema
|
||||
|
||||
## VerdictPath on the session row
|
||||
|
||||
`SessionSummary.VerdictPath` (`VerdictPaths` vocabulary, `origin:detail`, append-only) is
|
||||
stamped by **every** status write — `UpdateSessionStatusAsync` takes it as a required
|
||||
parameter, `StoreSessionAsync` stamps `register:new` / `register:whiteglove_resume` and
|
||||
preserves an existing path through the re-registration Replace, `SetSessionPreProvisionedAsync`
|
||||
requires it whenever it writes a status. Unlike `FailureSource` (persisted only on `Failed`),
|
||||
the path survives for Incomplete, AwaitingUser, Stalled and Pending.
|
||||
|
||||
| Origin | Paths |
|
||||
| --- | --- |
|
||||
| `agent` | `complete`, `complete_soft` (Continue-Anyway), `failed`, `esp_failure_fallback`, `gather_complete`, `whiteglove_pending`, `whiteglove_resumed`, `stall_probe`, `stall_heal` |
|
||||
| `ingest` | `wg_awaiting` (stall probe mapped to WhiteGlove AwaitingUser) |
|
||||
| `sweep` | `stalled`, `wg_awaiting`, `sd_reconcile`, `r{rule}` (stage-2 classifier) |
|
||||
| `maxlife` / `late` / `retro` | `r{rule}` (classifier via max-lifetime shutdown / late-telemetry heal / retro reclassification); `retro:sd_reconcile`, `retro:superseded` |
|
||||
| `register` | `new`, `whiteglove_resume`, `superseded` |
|
||||
| `rule` | `rule:{ruleId}` (analyze rule with MarkSessionAsFailed) |
|
||||
| `manual` | `failed`, `succeeded` |
|
||||
| `legacy` | read-side derivation only (`legacy:{rule}` when the rule is recoverable from the reason literal, `legacy:unknown` otherwise) — never written |
|
||||
|
||||
Classifier rule ids (`ClassifierRules`): `r1`, `r1b_awaiting`, `r1b_succeeded`, `r1c`, `r2`,
|
||||
`r3`, `r4`, `r5_awaiting`, `r5_incomplete`, `r6`. `ClassifyTimedOutSession` returns
|
||||
`(Status, Reason, Rule)` so each `return` names its rule.
|
||||
|
||||
## PriorStatus / PriorVerdictPath — the correction stream
|
||||
|
||||
When a write replaces a prior **verdict** (existing status ∈ Succeeded/Failed/Incomplete/
|
||||
AwaitingUser with a stamped path, incoming status different), the storage seam preserves the
|
||||
pair (`TableStorageService.ComputePriorVerdict`, both the ETag path and the force path). A
|
||||
stall marker healing to InProgress, a same-status refresh, or an unstamped legacy row never
|
||||
sets it. Admin marks, late agent completions and retro reclassifications all flow through it.
|
||||
|
||||
## Read-side derivation for pre-instrumentation rows
|
||||
|
||||
`VerdictPathDerivation.Derive(session)` returns the stamped path verbatim, otherwise derives
|
||||
from `AdminMarkedAction`, `FailureSource`, `ReconcileReason` and `FailureReason` using the
|
||||
writers' fixed literals (classifier rule prefixes, the `max-lifetime watchdog` / `Retro-
|
||||
reclassified` suffixes, `Rule: `, `Superseded by session`, `Late completion report received`).
|
||||
Unambiguous rows get the real path (`agent:complete`, `manual:failed`, `rule:…`,
|
||||
`maxlife:r6`), ambiguous-but-rule-known rows `legacy:{rule}`, the rest `legacy:unknown`. No
|
||||
mass backfill: the aggregate counts derived rows with `DerivedCount` so the matrix shows
|
||||
history while flagging its weaker evidence. Tests pin the literals against the classifier
|
||||
output so a wording change fails loudly.
|
||||
|
||||
## Daily aggregate — `VerdictCalibrationAggregates`
|
||||
|
||||
PK = `TenantId` | `"global"`, RK = `yyyy-MM-dd` (session **StartedAt** date, like every daily
|
||||
aggregate). `VerdictCalibrationDailyAggregate { Version, SessionCount, TerminalSessionCount,
|
||||
Buckets[], ComputedAt }` with one bucket per (VerdictPath, Status):
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `Count` / `DerivedCount` | sessions currently on the path; of those, derived read-side |
|
||||
| `Eligible7d` | terminal sessions whose end (`CompletedAt ?? LastEventAt ?? StartedAt`) lies ≥ 7 d before compute time |
|
||||
| `ReEnrolled7d` | of the eligible: the device's `DeviceHistories` chain shows the next terminal session starting < 7 d after this one's end |
|
||||
| `OverriddenByAdmin` / `OverriddenByLateCompletion` / `OverriddenOther` | sessions whose **PriorVerdictPath** is this bucket — attributed to the path that was overridden, so a bucket can carry overrides with `Count = 0` |
|
||||
|
||||
`MaintenanceService.SweepVerdictCalibrationAsync` (2-hourly, after the device-journey sweep
|
||||
so the chains are fresh; manual maintenance parity; kill switch
|
||||
`VerdictCalibrationSweepDisabled`) slices the tick's shared projected window scan
|
||||
(`LoadSweepWindowSessionsAsync` — one 35-day cross-tenant drain feeds the time-attribution,
|
||||
device-journey and calibration sweeps plus the radar's tenant discovery; the StartedAt-only
|
||||
filter is a full-table read, so never per sweep), recomputes the rolling 30-day window whole (Replace),
|
||||
deletes stale date rows it did not regenerate, and the unbounded-tables cleanup applies the
|
||||
180-day retention. Sessions inside a deletion cascade are skipped; the table is regenerable
|
||||
and is wiped with the tenant on offboarding.
|
||||
|
||||
## Read surfaces
|
||||
|
||||
* `GET global/metrics/verdict-calibration?days=&tenantId=` (GlobalReadOrAdmin,
|
||||
TenantScoping.QueryParam; `VerdictCalibrationResponse.Build`): per path — count, share of
|
||||
all window sessions, derived count, eligible/re-enrolled with `reEnrollRatePct` **null below
|
||||
20 eligible**, the three override counters, a today-anchored 7d-vs-28d share trend
|
||||
(`window7`, `baseline28`, `lift` null without baseline) — plus totals, the trend basis and
|
||||
the active `alerts[]`. Empty but well-formed before the first sweep.
|
||||
* MCP `get_verdict_calibration` (platform scope: GA + Global Reader). Same endpoint, LLM-shaped in
|
||||
`verdict-calibration-shape.ts`: per-row `window7.sessions` / `baseline28.sessions` are dropped (the
|
||||
denominators live once in `trend`), `reEnrollRatePct` / `lift` are always present (`null` = withheld),
|
||||
and `minSharePct` / `top` trim the one-session tail — rows carrying overrides are never trimmed and
|
||||
everything dropped is reported in `omitted`. The wire shape the admin page consumes is untouched.
|
||||
* Portal: Global Admin → Metrics → Verdict Calibration (`/admin/metrics/verdict-calibration`):
|
||||
matrix grouped by origin, status pill, `derived n` gray pill, trend glyph (arrow only from
|
||||
5 window hits and lift ≥ 2 / ≤ 0.5), re-enrollment rate or `— (n=…)`, overrides
|
||||
`admin / late / other`, `↑ Drift` pill on rows with an active episode, and the active
|
||||
episodes list.
|
||||
|
||||
## Drift radar — `VerdictCalibrationRadar`
|
||||
|
||||
Same statistics as the [rule regression radar](rule-regression-radar.md) (7-day window vs the
|
||||
prior 28 days, ≥ 5 window hits, ≥ 20 window sessions, lift ≥ 2, `MetricsMath.
|
||||
RateIncreaseSeparated`), anchored on yesterday, over every partition with rows (each tenant
|
||||
and `global`):
|
||||
|
||||
| Kind | Signal | Re-arm |
|
||||
| --- | --- | --- |
|
||||
| `share_regression` | one **backend-decided** path's share of all sessions doubled (`sweep`/`maxlife`/`late`/`retro`/`rule`/`manual`/`ingest`; `agent:*` and `register:*` mirror customer workflow mix — first prod pass fired on `agent:whiteglove_pending` rollout weeks — and `legacy:*` is derived, all excluded) | share < 1.5× baseline, the path stops occurring, or the path is no longer eligible |
|
||||
| `silence_share_regression` | the `sweep:*` + `maxlife:*` share doubled — the backend had to decide more often because the agent went silent (a liveness signal, not a classifier one) | same |
|
||||
| `evidence_gap` | absolute: `r6` (pure fallthrough) decides ≥ 20 % of the window's classifier verdicts, ≥ 20 verdicts | < 15 % or < 20 verdicts |
|
||||
|
||||
Episodes are tracker rows `verdictcalibration|{kind}|{path}|{status}` (register = `AddEntity`,
|
||||
409 = already burning, fail-closed; refresh carries `FirstNotifiedAt`; 30-day tracker
|
||||
retention re-arms long burners). A new episode fires **one** `VerdictCalibrationDrift` ops
|
||||
event (category Maintenance, Warning; dual-registered in the web `OPS_EVENT_TYPES`) — there
|
||||
is deliberately **no tenant bell**. Per-path findings on a tenant partition get the on-fire
|
||||
dimension correlation (`RuleRegressionRadar.ComputeDimensionConcentration` over the window's
|
||||
sessions whose derived path matches), reported with the shared wording contract "correlated,
|
||||
not necessarily causal"; group kinds and the global partition carry no dimension claim.
|
||||
Kill switch `VerdictCalibrationRadarDisabled`.
|
||||
|
||||
# Examples
|
||||
|
||||
* **Reading the thermometer (prod, 2026-08-23 hand analysis that motivated this):**
|
||||
re-enrollment within 7 days after Succeeded 8.9 % (lab background), after Failed 24.3 %,
|
||||
after Incomplete 10.6 % — Incomplete behaves like Succeeded, so rules 5/6 are probably too
|
||||
cautious; the matrix now shows this per rule instead of per status.
|
||||
* **Correction stream:** a session swept to `sweep:r5_incomplete`, later completed by the
|
||||
agent → row `agent:complete` with `PriorVerdictPath = sweep:r5_incomplete`; the aggregate
|
||||
increments `OverriddenByLateCompletion` on the `sweep:r5_incomplete / Incomplete` bucket.
|
||||
Four admin marks since launch were invisible before — now each one counts against the path
|
||||
it corrected.
|
||||
* **Evidence gap:** a tenant whose agent build stopped emitting the Device Setup rollup line
|
||||
drives its sweep verdicts into `r6`; the absolute gate fires at 20 % and names the
|
||||
tenant — the fix is agent-side, the classifier is only the messenger.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/VerdictPaths.cs` — vocabulary + `ClassifierRules`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `ComputePriorVerdict`, `ApplyVerdictPath`, re-registration preserve
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictPathDerivation.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.VerdictCalibration.cs` — sweep + `BuildVerdictCalibrationAggregates`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/VerdictCalibrationRadar.cs`, `Services/MaintenanceService.VerdictCalibrationRadar.cs`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `get_verdict_calibration`
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts` — LLM-facing response shaping (`minSharePct` / `top`, explicit nulls)
|
||||
* `src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx`
|
||||
* Tests: `VerdictPathTests`, `VerdictCalibrationTests`, `VerdictCalibrationRadarTests`
|
||||
* Related: [Silent-Session Classification](silent-session-classification.md), [Device Journeys](device-journeys.md), [Rule Regression Radar](rule-regression-radar.md)
|
||||
+9
-1
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Agent
|
||||
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
@@ -31,6 +31,8 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
|
||||
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
|
||||
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
@@ -43,14 +45,19 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# Backend
|
||||
|
||||
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
|
||||
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
@@ -69,6 +76,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
|
||||
|
||||
# Conventions for this bundle
|
||||
|
||||
|
||||
+28
@@ -1,7 +1,35 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-24
|
||||
|
||||
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
|
||||
|
||||
## 2026-08-23
|
||||
|
||||
* **New**: `backend/telemetry-ingest-shaping.md` — `RequestTelemetryMiddleware` sets `SamplingPercentage=100` on its own item (worker request rows were adaptively sampled, ItemCount 2–5 on weekdays, despite host.json `excludedTypes`), the redundant host HTTP request copy is dropped by a workspace transformation DCR (`infra/appinsights-workspace-transforms.bicep`, timer/queue rows kept), and `StorageDependencyFilterProcessor` now also drops expected storage outcomes (404/412/409, both InProc and HTTP shapes) and successful SignalR REST calls; the host-emitted SignalR binding calls (bulk of the volume, SDK `rdddsc`) are dropped by a second `AppDependencies` flow in the same DCR. Measured ≈ −1.3 GB/week.
|
||||
* **Update**: `backend/table-schema-sentinel.md` — "Measuring it": `StartupTelemetryService` emits `BackendStartupMs` (process start → ApplicationStarted) and `BackendTableInitMs` as App Insights custom metrics with version/commit/fullPass dimensions; the MCP server writes one `{"type":"boot","readyMs"}` stderr line per start (query in `.claude/commands/mcp-telemetry.md` §A2).
|
||||
* **New**: `backend/table-schema-sentinel.md` — startup table initialization gated by a schema sentinel (SHA-256 over `TableNames.All` in `AdminConfiguration`/`SchemaSentinel`/`tables`): matching sentinel ⇒ zero `CreateTableIfNotExists` calls on a cold start, missing/stale ⇒ one full pass + sentinel rewrite. SessionsIndex startup backfill only after a full pass, `IsSessionIndexEmptyAsync` no longer treats errors as "empty", conditional-insert claim so one scaled-out instance backfills. Daily maintenance runs `IStorageInitializer.EnsureAllAsync` to repair out-of-band table deletions.
|
||||
* **Update**: `backend/verdict-calibration.md` — MCP `get_verdict_calibration` response shaping: per-row trend denominators dropped, explicit nulls, `minSharePct` / `top` with an `omitted` report.
|
||||
* **Update**: `backend/verdict-calibration.md` — share-regression radar restricted to backend-decided paths; agent-declared/registration paths (customer workflow mix) and derived legacy paths no longer alert, stale episodes re-arm themselves.
|
||||
* **Update**: `backend/time-attribution.md`, `backend/device-journeys.md`, `backend/verdict-calibration.md` — the rolling maintenance sweeps (time attribution, device journeys, verdict calibration, calibration-radar discovery) now share ONE projected 35-day cross-tenant Sessions scan per tick (`LoadSweepWindowSessionsAsync` + `MaintenanceSweepSessionProjection`, pinned by `MaintenanceSweepProjectionEquivalenceTests`) instead of four full-table drains; each sweep slices its own window by StartedAt.
|
||||
* **New**: `backend/verdict-calibration.md` — the classifier thermometer: every status write now stamps `VerdictPath` (required parameter on `UpdateSessionStatusAsync`; `EnrollmentTimeoutClassifier` returns its rule id), overriding a verdict preserves `PriorStatus`/`PriorVerdictPath`, `VerdictPathDerivation` attributes pre-instrumentation rows read-side (`legacy:*` when ambiguous). New daily aggregate `VerdictCalibrationAggregates` (per-path count/derived/eligible7d/reEnrolled7d/overridden-by-admin|late|other, sweep after the device-journey pass, 180d retention), `GET global/metrics/verdict-calibration`, MCP `get_verdict_calibration`, admin metrics section, and the `VerdictCalibrationRadar` (share / silence-share / r6 evidence-gap episodes in the tracker keyspace `verdictcalibration|`, `VerdictCalibrationDrift` ops event, no tenant bell).
|
||||
* **New**: `backend/silent-session-classification.md` — one place for the silent-session verdict: `EnrollmentTimeoutClassifier` rule order incl. the new rule 1c `IsSelfDeployingProvisioned` (self-deploying profile + Device Setup all-succeeded + no explicit failure ⇒ Succeeded reconcile, no grace/AwaitingUser — kiosk tenant aebdce78 audit: 836 finished devices parked AwaitingUser/Incomplete), the sweep stage-1 gates (`TryMarkWhiteGloveAwaitingUserAsync`, new `TryReconcileSelfDeployingAsync`), the ingest callers (max-lifetime, late-telemetry heal, stall probe) and the new admin retro mode `self_deploying_silent` (`GetSelfDeployingSilentSessionsAsync`). Agent companion: `SessionRegistrationHelper` waits up to 15s for a network link and retries 6× (~62s) instead of 5× (~30s) so the reboot relaunch on Wi-Fi no longer exits 7 before the link is back.
|
||||
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
|
||||
|
||||
## 2026-08-22
|
||||
|
||||
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
|
||||
|
||||
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
|
||||
|
||||
## 2026-08-21
|
||||
|
||||
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
|
||||
|
||||
* **New**: `agent/system-timeline-watcher.md` — clock-step and sleep-episode ground truth from the System event log: one `EventLogWatcher` (Kernel-General 1, Power-Troubleshooter 1, Kernel-Power 507; 506 deliberately unconsumed) emits `system_clock_changed` (oldTime/newTime/signed timeDeltaMs/reason/process; 2 s micro-slew floor, ≥5 min = Warning+immediate) and `system_sleep_episode` (kind sleep/hibernate/modern_standby, authoritative `enteredAt`/`exitedAt` payload instants, 60 s Modern-Standby floor). WindowsUpdateTracker-style backfill (24 h default, `system-timeline-watermark.json`) recovers pre-agent records; ConfigVersion 39 (`Collectors.EnableSystemTimelineWatcher`). Consumers: TimeJumpBadge cause `clock-set` (ground truth beats inference), SessionInfoCard standby note next to the unchanged wall-clock duration, `SleepSpan`s in time attribution (AttributionVersion 3, RebootSpan clone), and ANALYZE-DEV-012 (info, single, ≥5 min episode; deliberately the only new rule — clock issues stay with DEV-008).
|
||||
|
||||
* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join).
|
||||
|
||||
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
|
||||
|
||||
@@ -31,6 +31,12 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
|
||||
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
|
||||
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
|
||||
sections are reviewed code and never pass through it — see
|
||||
[Diagnostics Package](../agent/diagnostics-package.md).
|
||||
|
||||
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
|
||||
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
|
||||
but the hard blocks must hold regardless of configuration:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Autopilot-Monitor — Log Analytics workspace transformation (ingestion-time filter)
|
||||
//
|
||||
// Two ingestion-time filters. (2) Successful Azure SignalR REST dependencies emitted by the
|
||||
// HOST process for the SignalR output binding (SDKVersion rdddsc:*) — the worker-side
|
||||
// StorageDependencyFilterProcessor never sees those. Failed SignalR calls are kept.
|
||||
// (1) Drops the redundant HOST-side HTTP request record from AppRequests so that exactly one
|
||||
// canonical request row remains: the worker-side copy emitted by RequestTelemetryMiddleware
|
||||
// (Properties.Source == 'WorkerMiddleware', carries TenantId/UserId/CorrelationId and is
|
||||
// sampling-exempt per item). Non-HTTP host invocations (timer/queue triggers) have an empty
|
||||
// Url and are kept — the worker middleware never sees them.
|
||||
//
|
||||
// Why ingestion-time: the host request item is emitted by the Functions host process and
|
||||
// cannot be suppressed from worker code or host.json (verified 2026-06-09, see Program.cs).
|
||||
// Live numbers 2026-08-23 (7d): host copy 274k rows / 323 MB, worker copy 189k rows / 208 MB.
|
||||
//
|
||||
// Cost note: transformation filtering is free up to 50% of the table's incoming volume; the
|
||||
// portion beyond that (here ~11% of AppRequests) is billed at the (cheap) data-processing rate.
|
||||
//
|
||||
// Apply (two steps; the link is a CLI update = GET+PUT, which keeps retention/SKU intact):
|
||||
// az deployment group create -g rg-autopilotmonitor-prd-gwc \
|
||||
// --template-file infra/appinsights-workspace-transforms.bicep \
|
||||
// --query properties.outputs.dcrId.value -o tsv
|
||||
// az monitor log-analytics workspace update -g rg-autopilotmonitor-prd-gwc -n AutopilotMonitor \
|
||||
// --data-collection-rule <dcrId-from-above>
|
||||
//
|
||||
// Verify after ~20 min (ingestion latency):
|
||||
// AppRequests | where TimeGenerated > ago(15m) | where AppRoleName == 'autopilotmonitor-api-eu'
|
||||
// | summarize rows=count() by source=tostring(Properties.Source), hasUrl=isnotempty(Url)
|
||||
// → only (WorkerMiddleware, true) and ('', false) buckets remain.
|
||||
//
|
||||
// Rollback: az monitor log-analytics workspace update -g <rg> -n AutopilotMonitor
|
||||
// --set properties.defaultDataCollectionRuleResourceId=null (then delete the DCR)
|
||||
|
||||
@description('Log Analytics workspace that backs the workspace-based Application Insights resources.')
|
||||
param workspaceName string = 'AutopilotMonitor'
|
||||
|
||||
param location string = resourceGroup().location
|
||||
|
||||
resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
|
||||
name: workspaceName
|
||||
}
|
||||
|
||||
resource transforms 'Microsoft.Insights/dataCollectionRules@2023-03-11' = {
|
||||
name: 'dcr-autopilotmonitor-ws-transforms'
|
||||
location: location
|
||||
kind: 'WorkspaceTransforms'
|
||||
properties: {
|
||||
description: 'AppRequests: keep the enriched worker request record (Properties.Source == WorkerMiddleware) and non-HTTP host invocations (empty Url); drop the host HTTP duplicate.'
|
||||
destinations: {
|
||||
logAnalytics: [
|
||||
{
|
||||
workspaceResourceId: workspace.id
|
||||
name: 'ws'
|
||||
}
|
||||
]
|
||||
}
|
||||
dataFlows: [
|
||||
{
|
||||
streams: [ 'Microsoft-Table-AppRequests' ]
|
||||
destinations: [ 'ws' ]
|
||||
transformKql: 'source | where tostring(Properties.Source) == \'WorkerMiddleware\' or isempty(Url)'
|
||||
}
|
||||
{
|
||||
// Successful Azure SignalR REST calls (group add/remove per connection) are emitted by
|
||||
// the HOST process for the SignalR output binding (SDKVersion rdddsc:*), so the worker's
|
||||
// StorageDependencyFilterProcessor never sees them. Failed SignalR calls are kept.
|
||||
streams: [ 'Microsoft-Table-AppDependencies' ]
|
||||
destinations: [ 'ws' ]
|
||||
transformKql: 'source | where not(Success == true and Target endswith \'.service.signalr.net\')'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace has exactly one transformation DCR, activated by linking it as the workspace's
|
||||
// defaultDataCollectionRuleResourceId — deliberately NOT modelled here (see Apply above).
|
||||
|
||||
output dcrId string = transforms.id
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-012",
|
||||
"title": "Device Entered Standby During Enrollment",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:system_sleep_episode"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "slept_during_enrollment",
|
||||
"source": "event_data",
|
||||
"eventType": "system_sleep_episode",
|
||||
"dataField": "durationSeconds",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Keep the device awake during enrollment",
|
||||
"steps": [
|
||||
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
|
||||
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
|
||||
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
|
||||
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
|
||||
]
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
"standby",
|
||||
"sleep",
|
||||
"environment"
|
||||
]
|
||||
}
|
||||
+48
@@ -1678,6 +1678,54 @@
|
||||
"environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-012",
|
||||
"title": "Device Entered Standby During Enrollment",
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:system_sleep_episode"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "slept_during_enrollment",
|
||||
"source": "event_data",
|
||||
"eventType": "system_sleep_episode",
|
||||
"dataField": "durationSeconds",
|
||||
"operator": "gte",
|
||||
"value": "300",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Keep the device awake during enrollment",
|
||||
"steps": [
|
||||
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
|
||||
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
|
||||
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
|
||||
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
|
||||
]
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
"standby",
|
||||
"sleep",
|
||||
"environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ENRL-001",
|
||||
"title": "Enrollment Failed",
|
||||
|
||||
@@ -28,10 +28,13 @@
|
||||
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
- All -> every optional permission above, in one run
|
||||
(also works with -Revoke to remove all optional grants at once)
|
||||
|
||||
.PARAMETER Permissions
|
||||
Microsoft Graph application permission names to grant directly. Use when the admin UI
|
||||
rendered a copy-paste command for you, or when you have a specific list in mind.
|
||||
Microsoft Graph application permission names to grant directly. Use when you copied the
|
||||
permission list from the admin UI ("Copy permissions"), or when you have a specific list
|
||||
in mind.
|
||||
|
||||
.PARAMETER TenantId
|
||||
Tenant ID (GUID) or verified domain. Optional -- defaults to the signed-in context.
|
||||
@@ -47,12 +50,16 @@
|
||||
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames
|
||||
|
||||
.EXAMPLE
|
||||
# Same effect, using the explicit-permission form (what the admin UI generates):
|
||||
# Same effect, using the explicit-permission form:
|
||||
.\Grant-AutopilotMonitorAddOn.ps1 `
|
||||
-ClientId "<your-client-id>" `
|
||||
-Permissions "DeviceManagementScripts.Read.All" `
|
||||
-TenantId "contoso.onmicrosoft.com"
|
||||
|
||||
.EXAMPLE
|
||||
# Grant every optional capability in one run:
|
||||
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features All
|
||||
|
||||
.EXAMPLE
|
||||
# Verify current state only, no changes:
|
||||
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -VerifyOnly
|
||||
@@ -65,6 +72,12 @@
|
||||
Requires:
|
||||
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
|
||||
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
|
||||
|
||||
Sign-in behaviour:
|
||||
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
|
||||
token for the already-signed-in user) - no device-code prompt, which Conditional Access
|
||||
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
|
||||
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
|
||||
- The Autopilot Monitor app must be admin-consented in your tenant first
|
||||
(its service principal must exist before grants can be added).
|
||||
|
||||
@@ -80,7 +93,7 @@ param(
|
||||
[string]$ClientId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
|
||||
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
|
||||
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation')]
|
||||
[string[]]$Features,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
|
||||
@@ -106,7 +119,11 @@ $FeatureCatalog = @{
|
||||
}
|
||||
|
||||
# Translate Features -> Permissions when caller used the high-level form.
|
||||
# 'All' is a script-side meta-value: it expands to every catalog feature.
|
||||
if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
|
||||
if ($Features -contains 'All') {
|
||||
$Features = @($FeatureCatalog.Keys)
|
||||
}
|
||||
$Permissions = @()
|
||||
foreach ($f in $Features) {
|
||||
$Permissions += $FeatureCatalog[$f]
|
||||
@@ -133,7 +150,25 @@ if ($TenantId) { $connectParams['TenantId'] = $TenantId }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[Auth] Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Connect-MgGraph @connectParams | Out-Null
|
||||
|
||||
# Preferred path: ambient identity. In Azure Cloud Shell the local identity endpoint issues a
|
||||
# delegated Graph token for the signed-in user, which avoids the device-code flow that
|
||||
# Conditional Access policies commonly block. Outside Azure this throws and we fall back to
|
||||
# an interactive sign-in with explicit scopes.
|
||||
$identityConnected = $false
|
||||
try {
|
||||
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop | Out-Null
|
||||
# An ambient identity can exist yet hold no Graph permissions (e.g. a plain VM managed
|
||||
# identity) - probe with a read before committing to this session.
|
||||
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=id&`$top=1" -ErrorAction Stop | Out-Null
|
||||
$identityConnected = $true
|
||||
Write-Host "[Auth] Connected via ambient identity (Azure Cloud Shell / managed identity)." -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
if (Get-MgContext) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
|
||||
Write-Host "[Auth] No usable ambient identity - falling back to interactive sign-in..." -ForegroundColor Yellow
|
||||
Connect-MgGraph @connectParams | Out-Null
|
||||
}
|
||||
|
||||
$ctx = Get-MgContext
|
||||
if (-not $ctx) {
|
||||
@@ -144,7 +179,10 @@ Write-Host "[Auth] Signed in as : $($ctx.Account)"
|
||||
Write-Host "[Auth] Tenant : $($ctx.TenantId)"
|
||||
Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
|
||||
|
||||
if (-not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
|
||||
# Scope check only applies to the interactive path: an ambient-identity (Cloud Shell) token
|
||||
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
|
||||
# and its effective rights follow the signed-in user's directory role.
|
||||
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
|
||||
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
|
||||
if (-not $VerifyOnly) { return }
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the compiled-in fallback rule catalogs (<see cref="BuiltInConfigCatalog"/>):
|
||||
/// the agent embeds the SAME CI-combined rules/dist artifacts the backend serves, so a
|
||||
/// no-config/no-cache session still runs full IME tracking. These tests guard the
|
||||
/// embedding + deserialization end-to-end — a broken LogicalName, a moved resource or a
|
||||
/// shape change in the combined JSON must fail HERE, not silently strip the fallback
|
||||
/// back down to zero patterns in the field.
|
||||
/// </summary>
|
||||
public class BuiltInConfigCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void ImeLogPatterns_LoadFromEmbeddedCatalog_EnabledOnly()
|
||||
{
|
||||
var patterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null);
|
||||
|
||||
Assert.NotEmpty(patterns);
|
||||
Assert.All(patterns, p =>
|
||||
{
|
||||
Assert.True(p.Enabled);
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.PatternId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Pattern));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Action));
|
||||
Assert.False(string.IsNullOrWhiteSpace(p.Category));
|
||||
});
|
||||
Assert.Equal(patterns.Count, patterns.Select(p => p.PatternId).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeLogPatterns_CarryCoreTrackingActions()
|
||||
{
|
||||
// The app-tracking backbone: without these two actions the ImeLogTracker cannot
|
||||
// attribute installs to apps or follow ESP phases. Their presence proves the
|
||||
// deserialization actually populated the action field (a shape drift in the
|
||||
// combined JSON would otherwise still pass the structural checks above).
|
||||
var actions = BuiltInConfigCatalog.GetEnabledImeLogPatterns(logger: null)
|
||||
.Select(p => p.Action).ToHashSet();
|
||||
|
||||
Assert.Contains("setCurrentApp", actions);
|
||||
Assert.Contains("espPhaseDetected", actions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatherRules_LoadFromEmbeddedCatalog_EnabledOnly()
|
||||
{
|
||||
// The built-in gather-rule catalog ships mostly disabled rules — only the
|
||||
// default-enabled ones may reach the fallback config.
|
||||
var rules = BuiltInConfigCatalog.GetEnabledGatherRules(logger: null);
|
||||
|
||||
Assert.All(rules, r =>
|
||||
{
|
||||
Assert.True(r.Enabled);
|
||||
Assert.False(string.IsNullOrWhiteSpace(r.RuleId));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
|
||||
nameof(AgentConfigResponse.NtpServer),
|
||||
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
|
||||
nameof(AgentConfigResponse.EnableDoGroupIdAutoSet),
|
||||
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
|
||||
nameof(AgentConfigResponse.DiagnosticsUploadMode),
|
||||
nameof(AgentConfigResponse.DiagnosticsLogPaths),
|
||||
@@ -52,6 +53,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
|
||||
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
|
||||
nameof(AgentConfigResponse.Collectors),
|
||||
// Merge reads one Analyzers knob (EnableRealmJoinWatcher → diagnostics RealmJoin sections);
|
||||
// the remaining flags flow untouched to DefaultComponentFactory + AgentAnalyzerManager.
|
||||
nameof(AgentConfigResponse.Analyzers),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -69,8 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
nameof(AgentConfigResponse.ImeLogPatterns),
|
||||
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
|
||||
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
|
||||
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
|
||||
nameof(AgentConfigResponse.Analyzers),
|
||||
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
|
||||
nameof(AgentConfigResponse.LatestAgentSha256),
|
||||
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
|
||||
|
||||
+29
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
EnrollmentSummaryLaunchRetrySeconds = 300,
|
||||
NtpServer = "pool.ntp.org",
|
||||
EnableTimezoneAutoSet = true,
|
||||
EnableDoGroupIdAutoSet = true,
|
||||
DiagnosticsUploadEnabled = true,
|
||||
DiagnosticsUploadMode = "OnFailure",
|
||||
DiagnosticsLogPaths = new List<DiagnosticsLogPath>
|
||||
@@ -83,6 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(300, agent.EnrollmentSummaryLaunchRetrySeconds);
|
||||
Assert.Equal("pool.ntp.org", agent.NtpServer);
|
||||
Assert.True(agent.EnableTimezoneAutoSet);
|
||||
Assert.True(agent.EnableDoGroupIdAutoSet);
|
||||
Assert.True(agent.DiagnosticsUploadEnabled);
|
||||
Assert.Equal("OnFailure", agent.DiagnosticsUploadMode);
|
||||
Assert.Single(agent.DiagnosticsLogPaths);
|
||||
@@ -366,6 +368,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_mirrors_analyzers_realmjoin_watcher_onto_agent_config()
|
||||
{
|
||||
// The diagnostics package gates its RealmJoin sections on this flag but never
|
||||
// sees the remote response — the merger carries the one Analyzers knob over.
|
||||
var agent = NewAgentConfig();
|
||||
var remote = FullRemote();
|
||||
remote.Analyzers = new AnalyzerConfiguration { EnableRealmJoinWatcher = true };
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.True(agent.EnableRealmJoinWatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_clears_realmjoin_watcher_when_remote_analyzers_missing()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.EnableRealmJoinWatcher = true; // stale cached true must not survive
|
||||
var remote = FullRemote();
|
||||
remote.Analyzers = null!;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.False(agent.EnableRealmJoinWatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
|
||||
{
|
||||
|
||||
+120
@@ -114,6 +114,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(1, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_DefaultsFallback_CarriesBuiltInCatalogs()
|
||||
{
|
||||
// A no-config/no-cache session must still get the compiled-in rule catalogs —
|
||||
// an empty ImeLogPatterns list kills the entire IME tracking pipeline (the
|
||||
// pre-catalog behaviour this pins against regressing to).
|
||||
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
|
||||
|
||||
var result = await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
Assert.Equal(0, result.ConfigVersion);
|
||||
Assert.NotEmpty(result.ImeLogPatterns);
|
||||
Assert.All(result.ImeLogPatterns, p => Assert.True(p.Enabled));
|
||||
Assert.All(result.GatherRules, r => Assert.True(r.Enabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_EmptyResponse_TreatedAsTransient()
|
||||
{
|
||||
@@ -128,6 +144,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(4, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
|
||||
{
|
||||
// The initial startup fetch must gate on the link-level signal so the retry
|
||||
// budget isn't burned into a NIC that is provably still down (agent relaunched
|
||||
// at boot, Wi-Fi still associating).
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
Assert.Equal(1, svc.NetworkLinkWaits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_RotatePath_SkipsNetworkLinkGate()
|
||||
{
|
||||
// rotate_config runs mid-session on a proven link and must stay latency-bounded.
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: false);
|
||||
|
||||
Assert.Equal(0, svc.NetworkLinkWaits);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_AfterFailedFetch_AppliesLiveConfig_KeepsFailureRecord()
|
||||
{
|
||||
// The core self-heal contract: registration proved the backend reachable, the
|
||||
// recovery swaps in the live config — but the LastFetch* observability keeps
|
||||
// describing the failed startup fetch so the wire event can report both.
|
||||
var svc = NewService(BackendBehaviour.FailAllThenSucceed);
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.NotNull(recovered);
|
||||
Assert.Equal(42, recovered.ConfigVersion);
|
||||
Assert.Equal(42, svc.CurrentConfig.ConfigVersion);
|
||||
Assert.True(svc.RecoveredAfterFetchFailure);
|
||||
// Startup-failure record intact:
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
Assert.Equal(1 + RemoteConfigService.InitialFetchRetryBackoff.Length, svc.LastFetchAttempts);
|
||||
Assert.Equal(nameof(HttpRequestException), svc.LastFetchFailureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_AfterSuccessfulFetch_IsNoOp()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_BeforeAnyFetch_IsNoOp()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.SuccessImmediately);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRecover_WhenBackendStillFailing_KeepsFallback_NeverThrows()
|
||||
{
|
||||
var svc = NewService(BackendBehaviour.AlwaysTransientFailure);
|
||||
var fallback = await svc.FetchConfigAsync(retryOnTransientErrors: true);
|
||||
Assert.Equal(0, fallback.ConfigVersion);
|
||||
|
||||
var recovered = await svc.TryRecoverConfigAsync();
|
||||
|
||||
Assert.Null(recovered);
|
||||
Assert.False(svc.RecoveredAfterFetchFailure);
|
||||
Assert.Equal(0, svc.CurrentConfig.ConfigVersion); // fallback config untouched
|
||||
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
|
||||
}
|
||||
|
||||
// ── Outcome state defaults ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -145,6 +249,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
SuccessImmediately,
|
||||
AlwaysTransientFailure,
|
||||
FailTwiceThenSucceed,
|
||||
/// <summary>All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds.</summary>
|
||||
FailAllThenSucceed,
|
||||
AlwaysAuthFailure401,
|
||||
AlwaysReturnNull,
|
||||
}
|
||||
@@ -177,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
_behaviour = behaviour;
|
||||
}
|
||||
|
||||
/// <summary>Number of times the network-link gate seam was awaited.</summary>
|
||||
public int NetworkLinkWaits { get; private set; }
|
||||
|
||||
protected override Task<AgentConfigResponse> CallBackendAsync()
|
||||
{
|
||||
_calls++;
|
||||
@@ -188,6 +297,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
if (_calls < 3)
|
||||
throw new HttpRequestException("simulated cold-start timeout");
|
||||
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
|
||||
case BackendBehaviour.FailAllThenSucceed:
|
||||
if (_calls <= 1 + RemoteConfigService.InitialFetchRetryBackoff.Length)
|
||||
throw new HttpRequestException("simulated dead network link");
|
||||
return Task.FromResult(new AgentConfigResponse { ConfigVersion = 42 });
|
||||
case BackendBehaviour.AlwaysTransientFailure:
|
||||
throw new HttpRequestException("simulated network failure");
|
||||
case BackendBehaviour.AlwaysAuthFailure401:
|
||||
@@ -202,6 +315,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
// Zero-delay so the suite finishes in ms instead of the live 10/30/60 s schedule.
|
||||
protected override Task DelayBetweenAttemptsAsync(TimeSpan delay) => Task.CompletedTask;
|
||||
|
||||
// Record + skip the real link poll so the suite is deterministic on offline CI.
|
||||
protected override Task WaitForNetworkLinkAsync()
|
||||
{
|
||||
NetworkLinkWaits++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// In-memory + per-instance cache so a prior test's successful fetch can't
|
||||
// bleed into a later test that expects UsedDefaults.
|
||||
private AgentConfigResponse? _cached;
|
||||
|
||||
+188
-2
@@ -1,9 +1,11 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
@@ -46,7 +48,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Directory.CreateDirectory(SpoolFolder);
|
||||
}
|
||||
|
||||
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
|
||||
public DiagnosticsPackageService Build(
|
||||
System.Action<AgentConfiguration>? mutateConfig = null,
|
||||
IReadOnlyDictionary<string, string>? sectionFolderOverrides = null,
|
||||
System.Func<bool>? devicePreparationProbe = null)
|
||||
{
|
||||
// BackendApiClient is required by the public ctor but BuildArchiveBytes
|
||||
// never touches it — construct with a throwaway HttpClient. Tests only
|
||||
@@ -71,7 +76,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
imeLogFolderOverride: ImeTmp.Path,
|
||||
agentStateFolderOverride: StateFolder,
|
||||
agentSpoolFolderOverride: SpoolFolder,
|
||||
agentDataFolderOverride: DataFolder);
|
||||
agentDataFolderOverride: DataFolder,
|
||||
sectionFolderOverrides: sectionFolderOverrides,
|
||||
devicePreparationProbe: devicePreparationProbe);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -287,6 +294,185 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/"));
|
||||
}
|
||||
|
||||
// ===================================================== built-in catalog gates ====
|
||||
// The RealmJoin sections ride behind the tenant's RealmJoin Watcher toggle, the
|
||||
// Device Preparation bootstrapper event log behind the deterministic WDP marker.
|
||||
// Every conditional section is skipped WITH a manifest line, never silently.
|
||||
|
||||
/// <summary>RealmJoin system-side folders redirected to temp (user-side goes via UserProfileResolver).</summary>
|
||||
private sealed class RealmJoinRig : System.IDisposable
|
||||
{
|
||||
public TempDirectory WindowsLogs { get; } = new TempDirectory();
|
||||
public TempDirectory Choco { get; } = new TempDirectory();
|
||||
public TempDirectory UserProfile { get; } = new TempDirectory();
|
||||
public string PackagesFolder => Path.Combine(WindowsLogs.Path, "RealmJoin");
|
||||
|
||||
public RealmJoinRig()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin.log"), "rj");
|
||||
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin2.log"), "rj2");
|
||||
var pkg = Path.Combine(PackagesFolder, "Packages", "generic-7zip");
|
||||
Directory.CreateDirectory(pkg);
|
||||
File.WriteAllText(Path.Combine(pkg, "install.log"), "pkg");
|
||||
var choco = Path.Combine(Choco.Path, "generic-7zip");
|
||||
Directory.CreateDirectory(choco);
|
||||
File.WriteAllText(Path.Combine(choco, "2026-06-30_install.log"), "choco");
|
||||
var userRj = Path.Combine(UserProfile.Path, "AppData", "Local", "RealmJoin");
|
||||
Directory.CreateDirectory(Path.Combine(userRj, "Logs", "generic-office"));
|
||||
File.WriteAllText(Path.Combine(userRj, "tray.log"), "tray");
|
||||
File.WriteAllText(Path.Combine(userRj, "config.pjson"), "not a log");
|
||||
File.WriteAllText(Path.Combine(userRj, "Logs", "RjImeHost.log"), "host");
|
||||
File.WriteAllText(Path.Combine(userRj, "Logs", "generic-office", "usersettings.log"), "pkg-user");
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> Overrides => new Dictionary<string, string>
|
||||
{
|
||||
["RealmJoinWindows"] = WindowsLogs.Path,
|
||||
["RealmJoinPackages"] = PackagesFolder,
|
||||
["RealmJoinChoco"] = Choco.Path,
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
WindowsLogs.Dispose();
|
||||
Choco.Dispose();
|
||||
UserProfile.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_skips_realmjoin_sections_when_watcher_disabled()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(sectionFolderOverrides: rj.Overrides).BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("SCENARIO: devicePreparation=False realmJoinWatcher=False", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinWindows", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinUserLogs", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_includes_realmjoin_sections_when_watcher_enabled()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: false);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
// ZIP layout mirrors the disk layout: flat client logs + package tree under
|
||||
// Windows/, tray logs + per-user Logs tree under User/.
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin2.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Windows/RealmJoin/Packages/generic-7zip/install.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/Choco/generic-7zip/2026-06-30_install.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/tray.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/Logs/RjImeHost.log", entries);
|
||||
Assert.Contains("RealmJoinLogs/User/Logs/generic-office/usersettings.log", entries);
|
||||
// Only tray*.log is collected from the user RealmJoin root — config stays out.
|
||||
Assert.DoesNotContain(entries, e => e.EndsWith("config.pjson"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("BUILT-IN: RealmJoinUserLogs -> folder=", manifest);
|
||||
Assert.DoesNotContain("BUILT-IN SKIPPED (RealmJoin Watcher disabled)", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_skips_realmjoin_user_sections_without_user_session()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var rj = new RealmJoinRig();
|
||||
// Token present, no interactive user detected → user-profile sections skip with a
|
||||
// manifest line; the system-side sections are unaffected.
|
||||
UserProfileResolver.SetForTesting(null!);
|
||||
try
|
||||
{
|
||||
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var entries = ZipEntryNames(bytes);
|
||||
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
|
||||
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/User"));
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserTray", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserLogs", manifest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UserProfileResolver.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_collects_bootstrapper_event_log_only_on_device_preparation()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var winevt = new TempDirectory();
|
||||
// Synthetic evtx: no registered channel claims the file, so the export falls
|
||||
// back to a raw copy — the point here is the scenario gate, not wevtutil.
|
||||
File.WriteAllBytes(Path.Combine(winevt.Path, "BootstrapperAgentServiceLogProvider.evtx"), new byte[] { 1, 2, 3 });
|
||||
var overrides = new Dictionary<string, string> { ["ImeBootstrapperEventLog"] = winevt.Path };
|
||||
|
||||
var classic = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => false)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
Assert.DoesNotContain("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(classic));
|
||||
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog",
|
||||
ReadZipEntryText(classic, "package-manifest.txt"));
|
||||
|
||||
var wdp = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => true)
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
Assert.Contains("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(wdp));
|
||||
Assert.Contains("SCENARIO: devicePreparation=True", ReadZipEntryText(wdp, "package-manifest.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_treats_failing_device_preparation_probe_as_classic()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var bytes = rig.Build(devicePreparationProbe: () => throw new System.InvalidOperationException("registry boom"))
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
|
||||
Assert.Contains("SCENARIO PROBE FAILED (treated as Classic): registry boom", manifest);
|
||||
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog", manifest);
|
||||
Assert.Contains("sessioninfo.txt", ZipEntryNames(bytes)); // archive still built
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DiagnosticsSectionCondition.Always, false, false, true)]
|
||||
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, false, true, false)]
|
||||
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, true, false, true)]
|
||||
[InlineData(DiagnosticsSectionCondition.DevicePreparation, true, false, false)]
|
||||
[InlineData(DiagnosticsSectionCondition.DevicePreparation, false, true, true)]
|
||||
[InlineData((DiagnosticsSectionCondition)999, true, true, false)]
|
||||
public void IsSectionActive_gates_on_condition(DiagnosticsSectionCondition condition, bool watcher, bool wdp, bool expected)
|
||||
{
|
||||
var section = new DiagnosticsBuiltInSection("X", "X", @"C:\X", new[] { "*.log" }, false, "x", condition);
|
||||
var cfg = Cfg();
|
||||
cfg.EnableRealmJoinWatcher = watcher;
|
||||
|
||||
Assert.Equal(expected, DiagnosticsPackageService.IsSectionActive(section, cfg, wdp));
|
||||
}
|
||||
|
||||
private static string[] ZipEntryNames(byte[] zipBytes)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// SystemTimelineTracker — clock steps (Kernel-General 1) and completed sleep episodes
|
||||
/// (Power-Troubleshooter 1 / Kernel-Power 507) from the System channel. Drives the primitive
|
||||
/// <see cref="SystemTimelineTracker.ProcessEvent"/> test seam (no real EventRecord, which is
|
||||
/// abstract + Windows-only), mirroring the WindowsUpdateTracker test pattern.
|
||||
/// </summary>
|
||||
public sealed class SystemTimelineTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly SystemTimelineTracker _tracker;
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public SystemTimelineTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_tracker = new SystemTimelineTracker(
|
||||
sessionId: "sess-stl",
|
||||
tenantId: "tenant-stl",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: null); // in-memory dedup for most tests
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Emission helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void EmitClockChange(
|
||||
DateTime? oldTime, DateTime? newTime, string? rawDeltaMs = null,
|
||||
int reason = 1, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
|
||||
{
|
||||
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (oldTime.HasValue) data["OldTime"] = oldTime.Value.ToString("o");
|
||||
if (newTime.HasValue) data["NewTime"] = newTime.Value.ToString("o");
|
||||
if (rawDeltaMs != null) data["TimeDeltaInMs"] = rawDeltaMs;
|
||||
data["Reason"] = reason.ToString();
|
||||
data["ProcessName"] = @"\Device\HarddiskVolume3\Windows\System32\svchost.exe";
|
||||
data["ProcessID"] = "6892";
|
||||
|
||||
_tracker.ProcessEvent(
|
||||
providerName: SystemTimelineTracker.ProviderKernelGeneral,
|
||||
eventId: SystemTimelineTracker.EventId_ClockChange,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: timeCreatedUtc ?? At,
|
||||
eventData: data,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private void EmitClassicResume(
|
||||
DateTime sleepTime, DateTime wakeTime, string? effectiveState = "1",
|
||||
string? wakeSourceText = null, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
|
||||
{
|
||||
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["SleepTime"] = sleepTime.ToString("o"),
|
||||
["WakeTime"] = wakeTime.ToString("o"),
|
||||
["WakeSourceType"] = "0",
|
||||
};
|
||||
if (effectiveState != null) data["EffectiveState"] = effectiveState;
|
||||
if (wakeSourceText != null) data["WakeSourceText"] = wakeSourceText;
|
||||
|
||||
_tracker.ProcessEvent(
|
||||
providerName: SystemTimelineTracker.ProviderPowerTroubleshooter,
|
||||
eventId: SystemTimelineTracker.EventId_ClassicResume,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: timeCreatedUtc ?? wakeTime,
|
||||
eventData: data,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private void EmitModernStandbyExit(
|
||||
long sleepDurationUs, long durationUs, string powerStateAc = "true",
|
||||
long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
|
||||
{
|
||||
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["SleepDurationInUs"] = sleepDurationUs.ToString(),
|
||||
["DurationInUs"] = durationUs.ToString(),
|
||||
["Reason"] = "32",
|
||||
["PowerStateAc"] = powerStateAc,
|
||||
["BatteryRemainingCapacityOnExit"] = "34180",
|
||||
};
|
||||
|
||||
_tracker.ProcessEvent(
|
||||
providerName: SystemTimelineTracker.ProviderKernelPower,
|
||||
eventId: SystemTimelineTracker.EventId_ModernStandbyExit,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: timeCreatedUtc ?? At,
|
||||
eventData: data,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// system_clock_changed
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ClockStep_AboveThreshold_EmitsInfo_WithSignedDeltaAndNormalizedTimes()
|
||||
{
|
||||
var oldTime = At;
|
||||
var newTime = At.AddSeconds(10);
|
||||
EmitClockChange(oldTime, newTime, reason: 1);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal(10_000L, data["timeDeltaMs"]);
|
||||
Assert.Equal("application_set", data["reasonText"]);
|
||||
Assert.Equal(1, data["reason"]);
|
||||
Assert.Equal(oldTime.ToString("o"), data["oldTime"]);
|
||||
Assert.Equal(newTime.ToString("o"), data["newTime"]);
|
||||
Assert.Equal(6892, data["processId"]);
|
||||
Assert.Contains("forward", (string)s.Payload![SignalPayloadKeys.Message]);
|
||||
Assert.Contains("svchost.exe", (string)s.Payload![SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(1999)]
|
||||
public void ClockMicroStep_BelowThreshold_Suppressed(int deltaMs)
|
||||
{
|
||||
EmitClockChange(At, At.AddMilliseconds(deltaMs));
|
||||
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockStep_Backward_EmitsNegativeDelta()
|
||||
{
|
||||
EmitClockChange(oldTime: At, newTime: At.AddSeconds(-3));
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
Assert.Equal(-3000L, Data(s)["timeDeltaMs"]);
|
||||
Assert.Contains("backward", (string)s.Payload![SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockStep_AtLeastFiveMinutes_WarningAndImmediateUpload()
|
||||
{
|
||||
EmitClockChange(At, At.AddMinutes(5), reason: 2);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("hardware_clock_sync", Data(s)["reasonText"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockStep_NoUsableTimesOrDelta_Skipped()
|
||||
{
|
||||
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "not-a-number");
|
||||
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClockStep_FallsBackToTimeDeltaInMs_WhenTimesUnparseable()
|
||||
{
|
||||
// Only the raw delta parses — the direction information of NewTime/OldTime is gone,
|
||||
// but the magnitude still gates and emits.
|
||||
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "7200000");
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
Assert.Equal(7_200_000L, Data(s)["timeDeltaMs"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// system_sleep_episode — classic (Power-Troubleshooter 1)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ClassicResume_EffectiveState5_EmitsHibernateEpisode()
|
||||
{
|
||||
var sleepAt = At.AddHours(-2);
|
||||
EmitClassicResume(sleepAt, At, effectiveState: "5", wakeSourceText: "Power Button");
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal("hibernate", data["kind"]);
|
||||
Assert.Equal(7200L, data["durationSeconds"]);
|
||||
Assert.Equal(sleepAt.ToString("o"), data["enteredAt"]);
|
||||
Assert.Equal(At.ToString("o"), data["exitedAt"]);
|
||||
Assert.Equal("Power Button", data["wakeSourceText"]);
|
||||
Assert.Contains("hibernate", (string)s.Payload![SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicResume_NonHibernateState_EmitsSleepKind()
|
||||
{
|
||||
EmitClassicResume(At.AddMinutes(-30), At, effectiveState: "4");
|
||||
Assert.Equal("sleep", Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)))["kind"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicResume_ShortEpisode_StillEmits()
|
||||
{
|
||||
// No duration floor for classic sleep: the event only exists for genuinely completed
|
||||
// S3/S4 transitions, so a 30 s real sleep is still signal.
|
||||
EmitClassicResume(At.AddSeconds(-30), At);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
Assert.Equal(30L, Data(s)["durationSeconds"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// system_sleep_episode — Modern Standby (Kernel-Power 507)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ModernStandbyExit_EmitsEpisode_WithDerivedEnterTime()
|
||||
{
|
||||
// Values from a live 507 on this dev machine: 38 min scenario, ~38 min actual sleep.
|
||||
EmitModernStandbyExit(sleepDurationUs: 2_324_095_807, durationUs: 2_330_201_652);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
var data = Data(s);
|
||||
Assert.Equal("modern_standby", data["kind"]);
|
||||
Assert.Equal(2330L, data["durationSeconds"]);
|
||||
Assert.Equal(2324L, data["sleepDurationSeconds"]);
|
||||
Assert.Equal(true, data["onAcPower"]);
|
||||
Assert.Equal(34180L, data["batteryRemainingCapacityOnExit"]);
|
||||
Assert.Equal(At.ToString("o"), data["exitedAt"]);
|
||||
Assert.Equal(At.AddMilliseconds(-2_330_201.652).ToString("o"), data["enteredAt"]);
|
||||
Assert.Contains("Modern Standby", (string)s.Payload![SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernStandbyExit_SleepBelowFloor_Suppressed()
|
||||
{
|
||||
EmitModernStandbyExit(sleepDurationUs: 30_000_000, durationUs: 90_000_000); // 30 s sleep
|
||||
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernStandbyExit_ScreenOffWithoutSleep_Suppressed()
|
||||
{
|
||||
// SleepEntered=false scenarios carry SleepDurationInUs 0 — the single duration gate
|
||||
// suppresses them without a separate branch.
|
||||
EmitModernStandbyExit(sleepDurationUs: 0, durationUs: 6_106_696);
|
||||
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernStandbyExit_ScenarioShorterThanSleep_ClampsToSleepDuration()
|
||||
{
|
||||
EmitModernStandbyExit(sleepDurationUs: 120_000_000, durationUs: 0);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)));
|
||||
Assert.Equal(120L, data["durationSeconds"]);
|
||||
Assert.Equal(120L, data["sleepDurationSeconds"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Backfill semantics
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BackfilledEvent_UsesEventTimeForTimelineTimestamp_AndFlagsData()
|
||||
{
|
||||
// A pre-agent standby episode (record created before the agent started) must land on
|
||||
// the timeline where the device actually woke, not at agent start.
|
||||
var wakeTime = At.AddHours(-3);
|
||||
EmitClassicResume(wakeTime.AddHours(-1), wakeTime, isBackfill: true, timeCreatedUtc: wakeTime);
|
||||
|
||||
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
|
||||
Assert.Equal(wakeTime, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Dedup / watermark
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_ProcessedTwice_EmitsOnce()
|
||||
{
|
||||
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
|
||||
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillEventWithLowerRecordId_AfterLiveEvent_IsStillEmitted()
|
||||
{
|
||||
// The live watcher is armed BEFORE backfill runs, so a live record with a higher
|
||||
// RecordId can be processed first. An older, never-emitted backfill record must NOT be
|
||||
// suppressed — pre-agent episodes are the whole point (high-water-mark trap).
|
||||
EmitClockChange(At, At.AddMinutes(1), recordId: 100); // live, high
|
||||
EmitClockChange(At, At.AddMinutes(2), recordId: 50, isBackfill: true); // backfill, older
|
||||
|
||||
Assert.Equal(2, ByType(Constants.EventTypes.SystemClockChanged).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var clockData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["OldTime"] = At.ToString("o"),
|
||||
["NewTime"] = At.AddMinutes(10).ToString("o"),
|
||||
["Reason"] = "1",
|
||||
};
|
||||
|
||||
var first = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
|
||||
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: false);
|
||||
|
||||
// A fresh tracker (agent restart) must load the watermark and skip the already-emitted
|
||||
// record when the backfill re-reads it.
|
||||
var second = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
|
||||
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: true);
|
||||
|
||||
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownProviderOrEventId_Ignored()
|
||||
{
|
||||
_tracker.ProcessEvent("Microsoft-Windows-Kernel-Power", eventId: 566, recordId: _nextRecordId++,
|
||||
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
|
||||
_tracker.ProcessEvent("Some-Other-Provider", eventId: 1, recordId: _nextRecordId++,
|
||||
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
|
||||
|
||||
Assert.Empty(_sink.Posted);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// XPath + parsing
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_ContainsAllThreeProviderIdPairs_AndNo506()
|
||||
{
|
||||
var xpath = SystemTimelineTracker.BuildXPath();
|
||||
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-General'] and EventID=1", xpath);
|
||||
Assert.Contains("Provider[@Name='Microsoft-Windows-Power-Troubleshooter'] and EventID=1", xpath);
|
||||
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-Power'] and EventID=507", xpath);
|
||||
Assert.DoesNotContain("506", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBackfillXPath_AppendsTimediffClause()
|
||||
{
|
||||
var xpath = SystemTimelineTracker.BuildBackfillXPath(86_400_000);
|
||||
Assert.Contains("TimeCreated[timediff(@SystemTime) <= 86400000]", xpath);
|
||||
Assert.Contains("EventID=507", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_RealKernelGeneralPayload_YieldsAuthoritativeFields()
|
||||
{
|
||||
// Verbatim (values shortened) from a live Kernel-General 1 record on a 26220 dev machine.
|
||||
const string xml =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
|
||||
"<System><Provider Name='Microsoft-Windows-Kernel-General'/><EventID>1</EventID></System>" +
|
||||
"<EventData>" +
|
||||
"<Data Name='NewTime'>2026-08-21T09:40:02.1987028Z</Data>" +
|
||||
"<Data Name='OldTime'>2026-08-21T09:40:02.1973028Z</Data>" +
|
||||
"<Data Name='TimeDeltaInMs'>1</Data>" +
|
||||
"<Data Name='Reason'>1</Data>" +
|
||||
"<Data Name='ProcessName'>\\Device\\HarddiskVolume3\\Windows\\System32\\svchost.exe</Data>" +
|
||||
"<Data Name='ProcessID'>6892</Data>" +
|
||||
"</EventData></Event>";
|
||||
|
||||
var data = SystemTimelineTracker.ParseEventData(xml);
|
||||
Assert.Equal("1", data["TimeDeltaInMs"]);
|
||||
Assert.Equal("2026-08-21T09:40:02.1987028Z", data["newtime"]); // case-insensitive keys
|
||||
Assert.NotNull(SystemTimelineTracker.TryParseUtc(data["OldTime"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDuration_CompactForms()
|
||||
{
|
||||
Assert.Equal("2h 05m", SystemTimelineTracker.FormatDuration(TimeSpan.FromMinutes(125)));
|
||||
Assert.Equal("56m 12s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(56 * 60 + 12)));
|
||||
Assert.Equal("45s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(45)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessLeafName_StripsDevicePath()
|
||||
{
|
||||
Assert.Equal("svchost.exe",
|
||||
SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\Windows\System32\svchost.exe"));
|
||||
Assert.Null(SystemTimelineTracker.ProcessLeafName(null));
|
||||
Assert.Null(SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
{
|
||||
public class DoGroupIdServiceTests
|
||||
{
|
||||
// -------------------------------------------------------------------
|
||||
// ComputeGroupId — RealmJoin byte-layout compatibility
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ComputeGroupId_MatchesHandComputedVector()
|
||||
{
|
||||
// Layout: {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac — through new Guid(byte[])
|
||||
// the first three groups print little-endian, the last eight bytes verbatim.
|
||||
var guid = DoGroupIdService.ComputeGroupId(
|
||||
new byte[] { 192, 168, 178, 1 },
|
||||
new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 });
|
||||
|
||||
Assert.Equal("00063065-0000-00a8-b201-001122334455", guid.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 10, 0, 0, 1 }, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF })]
|
||||
[InlineData(new byte[] { 192, 168, 1, 254 }, new byte[] { 0x00, 0x50, 0x56, 0x01, 0x02, 0x03 })]
|
||||
[InlineData(new byte[] { 172, 16, 254, 3 }, new byte[] { 0xF0, 0x9F, 0xC2, 0x00, 0x00, 0x01 })]
|
||||
public void ComputeGroupId_MatchesRealmJoinReferenceImplementation(byte[] ip, byte[] mac)
|
||||
{
|
||||
Assert.Equal(RealmJoinReferenceGuid(ip, mac), DoGroupIdService.ComputeGroupId(ip, mac));
|
||||
}
|
||||
|
||||
/// <summary>Verbatim port of RealmJoin's ConvertToGuid — the compatibility contract.</summary>
|
||||
private static Guid RealmJoinReferenceGuid(byte[] ipBytes, byte[] address)
|
||||
{
|
||||
byte[] prefix = { 101, 48, 6 };
|
||||
var stuff = new byte[4];
|
||||
stuff[0] = ipBytes[1];
|
||||
stuff[2] = ipBytes[2];
|
||||
stuff[3] = ipBytes[3];
|
||||
|
||||
return new Guid(prefix
|
||||
.Concat(new byte[16 - prefix.Length - stuff.Length - address.Length])
|
||||
.Concat(stuff)
|
||||
.Concat(address)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGroupId_RejectsNonIpv4AndBadMacLengths()
|
||||
{
|
||||
var ip4 = new byte[] { 10, 0, 0, 1 };
|
||||
var mac = new byte[] { 1, 2, 3, 4, 5, 6 };
|
||||
|
||||
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(new byte[16], mac));
|
||||
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(null!, mac));
|
||||
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, new byte[8]));
|
||||
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeGroupId_RegistryFormat_IsBracelessLowercase()
|
||||
{
|
||||
// RealmJoin writes SetValue(name, Guid) which stringifies via Guid.ToString() ("d"
|
||||
// format) — our written value must be the identical string form.
|
||||
var text = DoGroupIdService.ComputeGroupId(
|
||||
new byte[] { 10, 1, 2, 3 }, new byte[] { 0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03 }).ToString();
|
||||
|
||||
Assert.DoesNotContain("{", text);
|
||||
Assert.Equal(text.ToLowerInvariant(), text);
|
||||
Assert.Equal(36, text.Length);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// DecideAction — ownership matrix
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private const string Computed = "00063065-0000-00a8-b201-001122334455";
|
||||
private const string Other = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void DecideAction_NoExistingValue_Writes(string? existing)
|
||||
{
|
||||
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(existing, Computed, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideAction_ExistingEqualsComputed_IsNoOp()
|
||||
{
|
||||
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(Computed, Computed, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideAction_ExistingEqualsComputed_ToleratesBracesAndCase()
|
||||
{
|
||||
// A value written by another tool in "{...}"/uppercase form is still semantically ours.
|
||||
var braced = "{" + Computed.ToUpperInvariant() + "}";
|
||||
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(braced, Computed, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideAction_ExistingIsOurStaleValue_WritesUpdate()
|
||||
{
|
||||
// Gateway changed: the registry still holds the GUID we wrote for the old network.
|
||||
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(Other, Computed, Other));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideAction_ForeignValue_Skips()
|
||||
{
|
||||
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, null));
|
||||
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, Computed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecideAction_NonGuidGarbageExisting_SkipsAsForeign()
|
||||
{
|
||||
// Never overwrite a value we can't even parse — conservative by design.
|
||||
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction("not-a-guid", Computed, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
@@ -11,8 +12,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
internal sealed class FakeSignalIngressSink : ISignalIngressSink
|
||||
{
|
||||
private readonly List<PostedSignal> _posted = new List<PostedSignal>();
|
||||
private readonly object _gate = new object();
|
||||
|
||||
public IReadOnlyList<PostedSignal> Posted => _posted;
|
||||
/// <summary>
|
||||
/// Snapshot, not the live list: producers post from timer / worker threads (e.g. the
|
||||
/// parked-dwell tripwire) while tests poll this in a SpinWait loop. Enumerating the live
|
||||
/// list across an Add throws "Collection was modified" — a rare CI-only failure
|
||||
/// (run 32644425162) with no assertion text.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PostedSignal> Posted
|
||||
{
|
||||
get { lock (_gate) return _posted.ToArray(); }
|
||||
}
|
||||
|
||||
/// <summary>When non-null, <see cref="Post"/> throws instead of capturing.</summary>
|
||||
public Exception? ThrowOnPost { get; set; }
|
||||
@@ -27,7 +38,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
object? typedPayload = null)
|
||||
{
|
||||
if (ThrowOnPost != null) throw ThrowOnPost;
|
||||
_posted.Add(new PostedSignal(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion, typedPayload));
|
||||
lock (_gate)
|
||||
_posted.Add(new PostedSignal(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion, typedPayload));
|
||||
}
|
||||
|
||||
internal sealed class PostedSignal
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// SystemTimelineWatcherHost — thin ICollectorHost wrapper over SystemTimelineTracker.
|
||||
/// Real event-log arming stays untested by convention (ConsoleBypassWatcher precedent);
|
||||
/// these tests pin the lifecycle contract only.
|
||||
/// </summary>
|
||||
public sealed class SystemTimelineWatcherHostTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private SystemTimelineWatcherHost CreateHost() => new SystemTimelineWatcherHost(
|
||||
sessionId: "sess-stl",
|
||||
tenantId: "tenant-stl",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc)),
|
||||
backfillLookbackMinutes: 0,
|
||||
stateDirectory: _tmp.Path);
|
||||
|
||||
[Fact]
|
||||
public void Stop_BeforeStart_DoesNotThrow()
|
||||
{
|
||||
using (var host = CreateHost())
|
||||
{
|
||||
host.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_Twice_DoesNotThrow()
|
||||
{
|
||||
var host = CreateHost();
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_IsStable()
|
||||
{
|
||||
using (var host = CreateHost())
|
||||
{
|
||||
Assert.Equal("SystemTimelineTracker", host.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -182,6 +182,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("FromCache", data["outcome"]);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
// Startup fetch failed (network link not up yet), then the post-registration
|
||||
// recovery fetched ConfigVersion 39. The event must keep the original failure
|
||||
// details AND mark the heal.
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 39, // CurrentConfig now holds the recovered config
|
||||
attempts: 4,
|
||||
failureType: "HttpRequestException",
|
||||
failureMessage: "simulated dead network link",
|
||||
recovered: true);
|
||||
|
||||
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
|
||||
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
|
||||
Assert.Equal("UsedDefaults", data["outcome"]); // startup failure record intact
|
||||
Assert.Equal(4, data["attempts"]);
|
||||
Assert.Equal(true, data["recovered"]);
|
||||
Assert.Equal(39, data["recoveredConfigVersion"]);
|
||||
Assert.Equal(nameof(EventSeverity.Info), rig.Sink.Posted[0].Payload!["severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_WithoutRecovery_StaysWarning()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 0,
|
||||
attempts: 4,
|
||||
failureType: "HttpRequestException",
|
||||
failureMessage: "still down");
|
||||
|
||||
LifecycleEmitters.EmitRemoteConfigFetchFailedIfAny(
|
||||
rig.Post, rig.AgentConfig, rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "remote_config_fetch_failed");
|
||||
Assert.False(data.ContainsKey("recovered"));
|
||||
Assert.False(data.ContainsKey("recoveredConfigVersion"));
|
||||
Assert.Equal(nameof(EventSeverity.Warning), rig.Sink.Posted[0].Payload!["severity"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_AfterRecovery_FlagsLiveConfig()
|
||||
{
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(
|
||||
RemoteConfigFetchOutcome.UsedDefaults,
|
||||
configVersion: 39,
|
||||
recovered: true);
|
||||
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
|
||||
Assert.Equal(39, data["configVersion"]);
|
||||
Assert.Equal(true, data["remoteConfigFetched"]); // running on a live-fetched config
|
||||
Assert.Equal("UsedDefaults", data["remoteConfigOutcome"]); // startup outcome stays honest
|
||||
Assert.Equal(true, data["remoteConfigRecovered"]);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, object> AssertSingleEmit(
|
||||
@@ -258,8 +325,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
int attempts = 1,
|
||||
string? failureType = null,
|
||||
string? failureMessage = null,
|
||||
int? authStatusCode = null)
|
||||
int? authStatusCode = null,
|
||||
bool recovered = false)
|
||||
{
|
||||
typeof(RemoteConfigService).GetProperty(nameof(RecoveredAfterFetchFailure))?.SetValue(this, recovered);
|
||||
// CurrentConfig is set via the protected SetConfig path — easiest:
|
||||
// a real FetchConfigAsync replacement that just stashes a canned config.
|
||||
typeof(RemoteConfigService).GetMethod("SetConfig",
|
||||
|
||||
+108
@@ -339,6 +339,114 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
|
||||
Assert.False(StartupEnvironmentProbes.IsNtpWithinTolerance(null!));
|
||||
}
|
||||
|
||||
// ================================================================= DO GroupId auto-set
|
||||
// Builder/fingerprint tests only — TrySetGroupId itself does live registry + ARP I/O and
|
||||
// must never run in a unit test (it would write HKLM on the build machine).
|
||||
|
||||
[Fact]
|
||||
public void BuildDoGroupIdEvent_written_success_is_info_with_full_data()
|
||||
{
|
||||
var result = new DoGroupIdSetResult
|
||||
{
|
||||
Success = true,
|
||||
Written = true,
|
||||
GroupId = "00063065-0000-00a8-b201-001122334455",
|
||||
PreviousValue = null,
|
||||
GatewayIp = "192.168.178.1",
|
||||
GatewayMac = "00-11-22-33-44-55",
|
||||
};
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
|
||||
|
||||
Assert.Equal("do_group_id_auto_set", evt.EventType);
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.Equal("StartupEnvironmentProbes", evt.Source);
|
||||
Assert.Equal("S1", evt.SessionId);
|
||||
Assert.Equal("T1", evt.TenantId);
|
||||
Assert.Contains("00063065-0000-00a8-b201-001122334455", evt.Message);
|
||||
Assert.Equal("192.168.178.1", evt.Data["gatewayIp"]);
|
||||
Assert.Equal("00-11-22-33-44-55", evt.Data["gatewayMac"]);
|
||||
Assert.Equal(true, evt.Data["written"]);
|
||||
Assert.Equal(true, evt.Data["success"]);
|
||||
Assert.Equal(false, evt.Data["skipped"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoGroupIdEvent_noop_success_says_already_set()
|
||||
{
|
||||
var result = new DoGroupIdSetResult
|
||||
{
|
||||
Success = true,
|
||||
Written = false,
|
||||
GroupId = "00063065-0000-00a8-b201-001122334455",
|
||||
};
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.Contains("already set", evt.Message);
|
||||
Assert.Equal(false, evt.Data["written"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoGroupIdEvent_skip_is_info_with_reason()
|
||||
{
|
||||
var result = new DoGroupIdSetResult
|
||||
{
|
||||
Skipped = true,
|
||||
SkipReason = "foreign_value_present",
|
||||
PreviousValue = "11111111-2222-3333-4444-555555555555",
|
||||
};
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.Contains("foreign_value_present", evt.Message);
|
||||
Assert.Equal("foreign_value_present", evt.Data["skipReason"]);
|
||||
Assert.Equal("11111111-2222-3333-4444-555555555555", evt.Data["previousValue"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoGroupIdEvent_error_is_warning()
|
||||
{
|
||||
var result = new DoGroupIdSetResult { Error = "readback mismatch" };
|
||||
|
||||
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, evt.Severity);
|
||||
Assert.Contains("readback mismatch", evt.Message);
|
||||
Assert.Equal("readback mismatch", evt.Data["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoGroupIdFingerprint_roundtrips_ownership_marker_for_success()
|
||||
{
|
||||
var fp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
|
||||
{
|
||||
Success = true,
|
||||
GroupId = "00063065-0000-00a8-b201-001122334455",
|
||||
});
|
||||
|
||||
Assert.Equal("written:00063065-0000-00a8-b201-001122334455", fp);
|
||||
Assert.Equal("00063065-0000-00a8-b201-001122334455", StartupEnvironmentProbes.ExtractLastWrittenGroupId(fp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoGroupIdFingerprint_skip_carries_no_ownership_and_errors_never_dedupe()
|
||||
{
|
||||
var skipFp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
|
||||
{
|
||||
Skipped = true,
|
||||
SkipReason = "mdm_policy_present",
|
||||
});
|
||||
Assert.Equal("skipped:mdm_policy_present", skipFp);
|
||||
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(skipFp));
|
||||
|
||||
// Errors: no fingerprint → the probe re-runs AND re-emits each failing start.
|
||||
Assert.Null(StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult { Error = "boom" }));
|
||||
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_skips_geo_timezone_and_ntp_once_all_succeeded_only_power_state_emits()
|
||||
{
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins <see cref="EntraDeviceCertHelper.ParseGuidFromExtension"/>, the pure byte-level part of the
|
||||
/// cert probe. Both encodings Microsoft actually ships are covered: the ASN.1 OCTET STRING wrapper
|
||||
/// used by the MS-Organization-Access TenantId/DeviceId extensions (and Intune's
|
||||
/// <c>1.2.840.113556.5.6</c> / <c>5.14</c>), and the BARE 16-byte payload used by Intune's DeviceId
|
||||
/// extension <c>1.2.840.113556.5.4</c> — the latter was silently returning <c>null</c> before.
|
||||
/// The store-iteration shell around it needs a real machine cert store and is not unit-tested.
|
||||
/// </summary>
|
||||
public sealed class EntraDeviceCertHelperParseTests
|
||||
{
|
||||
// Synthetic — never a real tenant/device id. ToByteArray() gives the MS little-endian layout
|
||||
// that both cert encodings carry, so every expectation below is derived, not hand-transcribed.
|
||||
private static readonly Guid Sample = new Guid("11223344-5566-7788-99aa-bbccddeeff00");
|
||||
|
||||
private static byte[] Wrap(byte[] payload, params byte[] header)
|
||||
{
|
||||
var buf = new byte[header.Length + payload.Length];
|
||||
Buffer.BlockCopy(header, 0, buf, 0, header.Length);
|
||||
Buffer.BlockCopy(payload, 0, buf, header.Length, payload.Length);
|
||||
return buf;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_ShortForm_Parses()
|
||||
{
|
||||
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x10);
|
||||
|
||||
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_LongForm_Parses()
|
||||
{
|
||||
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x81, 0x10);
|
||||
|
||||
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regression this class exists for: Intune's <c>1.2.840.113556.5.4</c> extension value is
|
||||
/// exactly 16 bytes with no <c>04 10</c> prefix, so the strict OCTET-STRING parser bailed on the
|
||||
/// tag check and the DeviceId was unreadable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BareSixteenBytes_Parses()
|
||||
{
|
||||
var raw = Sample.ToByteArray();
|
||||
|
||||
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare GUID may legitimately start with 0x04 (the OCTET STRING tag). That must not be
|
||||
/// mistaken for a wrapper: a wrapped 16-byte GUID is always >= 18 bytes, so the two forms are
|
||||
/// disjoint by length and the bare path has to win here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BareSixteenBytes_StartingWithOctetStringTag_StillParses()
|
||||
{
|
||||
var guid = new Guid("aabbcc04-0110-4020-8030-405060708090");
|
||||
var raw = guid.ToByteArray();
|
||||
|
||||
Assert.Equal(0x04, raw[0]); // guard: the byte layout really does start with the ASN.1 tag
|
||||
Assert.Equal(16, raw.Length);
|
||||
Assert.Equal(guid, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_IsLittleEndianMsLayout()
|
||||
{
|
||||
// 04 10 + the canonical MS binary layout of {11223344-...} => first group byte-reversed.
|
||||
var raw = new byte[]
|
||||
{
|
||||
0x04, 0x10,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x66, 0x55,
|
||||
0x88, 0x77,
|
||||
0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00
|
||||
};
|
||||
|
||||
Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_ReturnsNull()
|
||||
{
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_ReturnsNull()
|
||||
{
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[0]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(15)]
|
||||
[InlineData(17)]
|
||||
public void BarePayload_OfWrongLength_ReturnsNull(int length)
|
||||
{
|
||||
var raw = new byte[length];
|
||||
for (var i = 0; i < length; i++) raw[i] = (byte)(0x40 + i); // no 0x04 tag, no valid wrapper
|
||||
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonOctetStringTag_ReturnsNull()
|
||||
{
|
||||
// e.g. Intune's 1.2.840.113556.5.15 => ASN.1 INTEGER 2.
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[] { 0x02, 0x01, 0x02 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_WithNonGuidContentLength_ReturnsNull()
|
||||
{
|
||||
var raw = Wrap(new byte[8], 0x04, 0x08);
|
||||
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_TruncatedContent_ReturnsNull()
|
||||
{
|
||||
// Declares 16 content bytes but only carries 10.
|
||||
var raw = Wrap(new byte[10], 0x04, 0x10);
|
||||
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetString_LongFormWithOversizedLengthHeader_ReturnsNull()
|
||||
{
|
||||
// 0x85 => 5 length bytes, above the 4 the parser accepts.
|
||||
var raw = Wrap(Sample.ToByteArray(), 0x04, 0x85, 0x00, 0x00, 0x00, 0x00, 0x10);
|
||||
|
||||
Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-3
@@ -97,7 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_after_five_exceptions_without_auth_error()
|
||||
public async Task Fails_after_six_exceptions_without_auth_error()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
@@ -111,8 +111,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
backoffDelay: _ => Task.CompletedTask);
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(5, apiClient.CallCount);
|
||||
Assert.Contains("transient attempt 5", result.ErrorMessage);
|
||||
Assert.Equal(6, apiClient.CallCount);
|
||||
Assert.Contains("transient attempt 6", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Waits_for_network_link_before_first_attempt()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var order = new System.Collections.Generic.List<string>();
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
{
|
||||
order.Add("register");
|
||||
return new RegisterSessionResponse { Success = true, SessionId = config.SessionId };
|
||||
});
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
networkLinkWait: _ => { order.Add("link-wait"); return Task.CompletedTask; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(new[] { "link-wait", "register" }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_link_wait_failure_does_not_block_registration()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ =>
|
||||
new RegisterSessionResponse { Success = true, SessionId = config.SessionId });
|
||||
|
||||
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
|
||||
apiClient, config, agentVersion: "0.0.0", logger: logger,
|
||||
backoffDelay: _ => Task.CompletedTask,
|
||||
networkLinkWait: _ => throw new InvalidOperationException("probe broken"));
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
carries the V2 assembly namespace so GatherRuleGuards locates it under
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
|
||||
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
|
||||
<!-- Built-in fallback rule catalogs (BuiltInConfigCatalog): the SAME CI-combined
|
||||
rules/dist artifacts the backend embeds and serves. Snapshotted per agent build so
|
||||
the no-config/no-cache fallback still runs full IME tracking instead of nothing. -->
|
||||
<EmbeddedResource Include="..\..\..\rules\dist\ime-log-patterns.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.ime-log-patterns.json" />
|
||||
<EmbeddedResource Include="..\..\..\rules\dist\gather-rules.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.gather-rules.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool EnableTimezoneAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
|
||||
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible).
|
||||
/// Existing DOGroupId/DOGroupIdSource policies are never overwritten.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableDoGroupIdAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
|
||||
/// remote config): stamped into the esp_config_detected decision-signal payload so
|
||||
@@ -254,6 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool UnrestrictedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of <c>AnalyzerConfiguration.EnableRealmJoinWatcher</c> (remote, per tenant).
|
||||
/// Gates the RealmJoin built-in sections of the diagnostics package — the package
|
||||
/// service only sees this configuration, not the remote response. The RealmJoin host
|
||||
/// itself keeps reading the remote Analyzers block. Default: false.
|
||||
/// </summary>
|
||||
public bool EnableRealmJoinWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Sanitized command-line arguments for the agent_started event.
|
||||
/// Secrets (e.g. bootstrap token) are redacted before assignment.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiled-in fallback copies of the global rule catalogs, embedded from the same
|
||||
/// CI-generated <c>rules/dist</c> artifacts the backend seeds and serves — single
|
||||
/// source by construction: each agent release snapshots the catalog state of its own
|
||||
/// build, so the offline fallback can never drift further than the running agent
|
||||
/// version itself (no manually maintained default to forget).
|
||||
/// <para>
|
||||
/// Consumed only by <see cref="RemoteConfigService"/>'s built-in default config,
|
||||
/// i.e. when a session has NO live config and NO cache. Before this existed, that
|
||||
/// path shipped an empty <c>ImeLogPatterns</c> list, which left the entire IME
|
||||
/// tracking pipeline (app installs, platform/remediation scripts, health-script
|
||||
/// compliance, IME ESP-phase signals) dead for the whole session — the core of
|
||||
/// enrollment monitoring. A live fetch (startup, post-registration recovery or
|
||||
/// rotate_config) always wins over this catalog.
|
||||
/// </para>
|
||||
/// Fail-soft: any load/parse error returns an empty list (the pre-catalog behaviour).
|
||||
/// </summary>
|
||||
internal static class BuiltInConfigCatalog
|
||||
{
|
||||
/// <summary>Enabled built-in IME log patterns from the embedded catalog.</summary>
|
||||
public static List<ImeLogPattern> GetEnabledImeLogPatterns(AgentLogger logger)
|
||||
=> LoadEnabled<ImeLogPattern>("ime-log-patterns.json", p => p.Enabled, logger);
|
||||
|
||||
/// <summary>Enabled built-in gather rules from the embedded catalog.</summary>
|
||||
public static List<GatherRule> GetEnabledGatherRules(AgentLogger logger)
|
||||
=> LoadEnabled<GatherRule>("gather-rules.json", r => r.Enabled, logger);
|
||||
|
||||
private static List<T> LoadEnabled<T>(string name, Func<T, bool> isEnabled, AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = typeof(BuiltInConfigCatalog).Assembly;
|
||||
using (var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources." + name))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
logger?.Warning($"Built-in config catalog resource '{name}' not found — fallback runs without it.");
|
||||
return new List<T>();
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
var wrapper = JsonConvert.DeserializeObject<RulesFile<T>>(reader.ReadToEnd());
|
||||
var all = wrapper?.Rules ?? new List<T>();
|
||||
return all.Where(isEnabled).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"Built-in config catalog '{name}' failed to load ({ex.Message}) — fallback runs without it.");
|
||||
return new List<T>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shape of the CI-combined catalog files: <c>{ "rules": [...] }</c>.</summary>
|
||||
private sealed class RulesFile<T>
|
||||
{
|
||||
[JsonProperty("rules")]
|
||||
public List<T> Rules { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,12 +94,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
// a tenant admin can clear a previously-applied custom NTP server.
|
||||
agentConfig.NtpServer = remoteConfig.NtpServer;
|
||||
agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet;
|
||||
agentConfig.EnableDoGroupIdAutoSet = remoteConfig.EnableDoGroupIdAutoSet;
|
||||
|
||||
// ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off"
|
||||
// which must clear any CLI-initial default).
|
||||
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
|
||||
agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
|
||||
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List<DiagnosticsLogPath>();
|
||||
// The diagnostics package gates its RealmJoin sections on the tenant's watcher
|
||||
// toggle but never sees the remote response — mirror the one Analyzers knob here.
|
||||
// The remaining Analyzers flags flow untouched to DefaultComponentFactory.
|
||||
agentConfig.EnableRealmJoinWatcher = remoteConfig.Analyzers?.EnableRealmJoinWatcher ?? false;
|
||||
|
||||
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
TimeSpan.FromSeconds(60),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
|
||||
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
|
||||
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
|
||||
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
|
||||
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration (thread-safe)
|
||||
/// </summary>
|
||||
@@ -76,6 +85,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// <summary>HTTP status for auth failures (401/403), null otherwise.</summary>
|
||||
public int? LastFetchAuthStatusCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once <see cref="TryRecoverConfigAsync"/> replaced a failed startup fetch with a
|
||||
/// live config. The <c>LastFetch*</c> properties intentionally keep describing the failed
|
||||
/// startup fetch so the wire event can report both the original failure and the recovery.
|
||||
/// </summary>
|
||||
public bool RecoveredAfterFetchFailure { get; private set; }
|
||||
|
||||
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null, AuthFailureTracker authFailureTracker = null)
|
||||
{
|
||||
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
@@ -116,6 +132,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
LastFetchFailureMessage = null;
|
||||
LastFetchAuthStatusCode = null;
|
||||
|
||||
// Initial startup fetch only: give a still-associating NIC a chance to come up
|
||||
// before the first attempt. Without this the retry budget (10/30/60s) is burned
|
||||
// into a provably dead link — the agent starts at boot, Wi-Fi associates minutes
|
||||
// later, and the session runs on defaults although the backend was never the
|
||||
// problem. The rotate_config path (retryOnTransientErrors:false) runs mid-session
|
||||
// on a proven link and must stay latency-bounded, so it skips the gate.
|
||||
if (retryOnTransientErrors)
|
||||
{
|
||||
try { await WaitForNetworkLinkAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.Debug($"Pre-fetch network wait failed ({ex.Message}) — fetching anyway."); }
|
||||
}
|
||||
|
||||
var maxAttempts = retryOnTransientErrors ? InitialFetchRetryBackoff.Length + 1 : 1;
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -203,14 +231,63 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fall back to defaults
|
||||
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
|
||||
// Fall back to built-in defaults (collector/analyzer defaults from the shared
|
||||
// model initializers + the compiled-in rule catalogs).
|
||||
var defaults = CreateDefaultConfig();
|
||||
_logger.Info($"No cached config available, using built-in defaults (ConfigVersion=0, " +
|
||||
$"{defaults.ImeLogPatterns?.Count ?? 0} built-in IME patterns, " +
|
||||
$"{defaults.GatherRules?.Count ?? 0} built-in gather rules)");
|
||||
SetConfig(defaults);
|
||||
LastFetchOutcome = RemoteConfigFetchOutcome.UsedDefaults;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-attempt recovery fetch for a session whose startup fetch failed. Called right
|
||||
/// after session registration succeeded — registration proves the backend is reachable,
|
||||
/// so the config that Phase 4 could not get is one cheap call away instead of the whole
|
||||
/// session being stranded on cache/defaults (only a manual <c>rotate_config</c> action
|
||||
/// could rescue it before this existed).
|
||||
/// <para>
|
||||
/// Unlike <see cref="FetchConfigAsync"/> this does NOT reset the <c>LastFetch*</c>
|
||||
/// observability state — those keep describing the failed startup fetch so
|
||||
/// <c>remote_config_fetch_failed</c> can report the original failure AND the recovery.
|
||||
/// Returns the recovered config, or null when there is nothing to recover (startup fetch
|
||||
/// succeeded / never ran) or the recovery attempt itself failed. Never throws.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResponse> TryRecoverConfigAsync()
|
||||
{
|
||||
if (LastFetchOutcome != RemoteConfigFetchOutcome.FromCache &&
|
||||
LastFetchOutcome != RemoteConfigFetchOutcome.UsedDefaults)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Info("Retrying remote config fetch after successful session registration...");
|
||||
var config = await CallBackendAsync().ConfigureAwait(false);
|
||||
if (config == null)
|
||||
{
|
||||
_logger.Warning("Config recovery fetch returned empty response — keeping fallback config.");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Remote config recovered: ConfigVersion={config.ConfigVersion}, " +
|
||||
$"GatherRules={config.GatherRules?.Count ?? 0}");
|
||||
LogCollectorSettings(config.Collectors);
|
||||
SetConfig(config);
|
||||
CacheConfig(config);
|
||||
_authFailureTracker?.RecordSuccess();
|
||||
RecoveredAfterFetchFailure = true;
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Config recovery fetch failed ({ex.Message}) — keeping fallback config.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetConfig(AgentConfigResponse config)
|
||||
{
|
||||
lock (_configLock)
|
||||
@@ -319,6 +396,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
private AgentConfigResponse CreateDefaultConfig()
|
||||
{
|
||||
// Everything not set here falls to the shared AgentConfigResponse property
|
||||
// initializers — the same values the backend uses as its `??` fallbacks, so the
|
||||
// scalar knobs cannot drift. The rule catalogs are the one thing initializers
|
||||
// cannot supply: without them the whole IME tracking pipeline is dead, so they
|
||||
// come from the compiled-in snapshot of the served catalogs (BuiltInConfigCatalog).
|
||||
return new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 0,
|
||||
@@ -330,8 +412,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
EnableGatherRuleDebugLog = false,
|
||||
EnableEspContinueAnywayObservation = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
GatherRules = BuiltInConfigCatalog.GetEnabledGatherRules(_logger),
|
||||
ImeLogPatterns = BuiltInConfigCatalog.GetEnabledImeLogPatterns(_logger)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,6 +440,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
protected virtual Task DelayBetweenAttemptsAsync(TimeSpan delay)
|
||||
=> Task.Delay(delay);
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the pre-fetch network-link gate (initial startup fetch only). Production
|
||||
/// polls the link-level signal via <see cref="NetworkLinkWait"/> bounded by
|
||||
/// <see cref="InitialFetchNetworkLinkWaitMax"/>; tests subclass to record the call
|
||||
/// and return immediately.
|
||||
/// </summary>
|
||||
protected virtual Task WaitForNetworkLinkAsync()
|
||||
=> NetworkLinkWait.WaitAsync(_logger, InitialFetchNetworkLinkWaitMax, "Remote config fetch");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
|
||||
+681
@@ -0,0 +1,681 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches the <c>System</c> event log for two enrollment blind spots and forwards them as
|
||||
/// timeline ground truth:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>System clock steps</b> (Kernel-General EventID 1) → <c>system_clock_changed</c>.
|
||||
/// The payload carries <c>oldTime</c>/<c>newTime</c>/<c>timeDeltaMs</c> plus the
|
||||
/// setting process — the "clock set from X to Y" fact that today has to be inferred
|
||||
/// from timestamp discontinuities. w32time also logs frequent 1 ms micro-slews on the
|
||||
/// same EventID, so emissions are gated on <see cref="MinClockDeltaMs"/>.</item>
|
||||
/// <item><b>Completed sleep episodes</b> → <c>system_sleep_episode</c>. Classic S3/S4 comes
|
||||
/// from Power-Troubleshooter EventID 1 (one event per episode at wake, carrying
|
||||
/// SleepTime + WakeTime). Modern Standby comes from Kernel-Power EventID 507 (exit),
|
||||
/// whose payload carries the scenario duration and the actual sleep residency; the
|
||||
/// enter time is derived as exit − duration. Kernel-Power 506 (enter) is deliberately
|
||||
/// NOT consumed: 507 already carries everything, and pairing 506/507 would need an
|
||||
/// orphan-tolerant state machine across agent restarts for zero information gain —
|
||||
/// an episode without a 507 never completed and would not be emitted under any design.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Payload times are authoritative.</b> The decisive clock correction (w32time fixing a
|
||||
/// wrong BIOS clock) happens exactly when the device clock is untrustworthy, and the backend
|
||||
/// clamps implausible event timestamps to server time. All semantic instants therefore travel
|
||||
/// as explicit ISO-8601 payload fields, never only as the event's own <c>Timestamp</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill + cross-restart dedup</b> mirror <see cref="WindowsUpdateTracker"/>: the System
|
||||
/// .evtx persists across the OOBE reboots, so a startup scan recovers pre-agent clock steps
|
||||
/// and standby episodes; a persisted RecordId watermark plus an intra-run seen-set (a HashSet,
|
||||
/// NOT a high-water mark — see the field comment) keep restarts from re-emitting.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SystemTimelineTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "System";
|
||||
|
||||
internal const string ProviderKernelGeneral = "Microsoft-Windows-Kernel-General"; // EventID 1: system time changed
|
||||
internal const string ProviderPowerTroubleshooter = "Microsoft-Windows-Power-Troubleshooter"; // EventID 1: resumed from classic sleep/hibernate
|
||||
internal const string ProviderKernelPower = "Microsoft-Windows-Kernel-Power"; // EventID 507: Modern Standby exit
|
||||
|
||||
internal const int EventId_ClockChange = 1;
|
||||
internal const int EventId_ClassicResume = 1;
|
||||
internal const int EventId_ModernStandbyExit = 507;
|
||||
|
||||
internal const string WatermarkStateFileName = "system-timeline-watermark.json";
|
||||
|
||||
// w32time logs Kernel-General 1 for routine 1 ms slew corrections (verified live: several
|
||||
// per hour). Only genuine steps are timeline-relevant; anything below this is claimed in
|
||||
// the dedup set but never emitted.
|
||||
internal const long MinClockDeltaMs = 2000;
|
||||
|
||||
// A step of at least 5 minutes can invalidate token/cert validation windows and visibly
|
||||
// reorders the timeline (matches the web's TimeJumpBadge threshold) — surface it fast.
|
||||
internal const long ClockDeltaWarningMs = 5 * 60 * 1000;
|
||||
|
||||
// Modern Standby logs a 506/507 pair for every screen-off scenario, including seconds-long
|
||||
// ones where the device never actually slept (SleepDurationInUs 0). Only episodes with
|
||||
// real sleep residency are emitted. Classic Power-Troubleshooter episodes have no floor:
|
||||
// they only exist for genuinely completed S3/S4 transitions.
|
||||
internal const long MinSleepDurationSeconds = 60;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
|
||||
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
|
||||
//
|
||||
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
|
||||
// Immutable during this run — records at/below it were already emitted before this process
|
||||
// started and must not be re-emitted by the backfill scan.
|
||||
private long _restartWatermark = -1;
|
||||
// RecordIds claimed in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
|
||||
// high-water mark: the live watcher is armed before the backfill runs, so a live record with
|
||||
// a HIGHER RecordId can be processed before/during backfill. A high-water mark would then
|
||||
// make the dedup skip every older, never-emitted backfill record — silently dropping exactly
|
||||
// the pre-agent clock corrections and standby episodes this tracker exists for. The targeted
|
||||
// records are low-volume, so the set stays tiny.
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
// Highest RecordId claimed so far (max of restart watermark + this run). Persisted so the
|
||||
// next run's restart watermark is correct.
|
||||
private long _maxEmittedRecordId = -1;
|
||||
private readonly object _watermarkLock = new object();
|
||||
|
||||
public SystemTimelineTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 1440,
|
||||
string stateDirectory = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
LoadWatermark();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
BackfillRecentEvents();
|
||||
else
|
||||
_logger.Info("SystemTimeline backfill disabled by config");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("SystemTimeline watcher stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping SystemTimeline watcher", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, BuildXPath());
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"SystemTimeline watcher started: {Channel} (clock changes + sleep episodes)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"SystemTimeline event log not found: {Channel} (normal on non-Windows test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"SystemTimeline watcher access denied for {Channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start SystemTimeline watcher for {Channel}", ex);
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "SystemTimelineTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provider-qualified XPath for the three targeted signals. Provider filtering is mandatory
|
||||
/// on the System channel — EventID 1 alone matches many unrelated providers.
|
||||
/// Exposed as internal for tests.
|
||||
/// </summary>
|
||||
internal static string BuildXPath() =>
|
||||
"*[System[" + ProviderIdClauses() + "]]";
|
||||
|
||||
/// <summary>Backfill variant of <see cref="BuildXPath"/> with the lookback window applied.</summary>
|
||||
internal static string BuildBackfillXPath(long lookbackMs) =>
|
||||
"*[System[(" + ProviderIdClauses() + $") and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
|
||||
private static string ProviderIdClauses() =>
|
||||
$"(Provider[@Name='{ProviderKernelGeneral}'] and EventID={EventId_ClockChange})" +
|
||||
$" or (Provider[@Name='{ProviderPowerTroubleshooter}'] and EventID={EventId_ClassicResume})" +
|
||||
$" or (Provider[@Name='{ProviderKernelPower}'] and EventID={EventId_ModernStandbyExit})";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event processing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing SystemTimeline event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, bool isBackfill)
|
||||
{
|
||||
var recordId = record.RecordId ?? -1;
|
||||
|
||||
// Cheap dedup gate BEFORE the XML rendering work.
|
||||
if (IsAlreadyProcessed(recordId))
|
||||
return;
|
||||
|
||||
string xml = null;
|
||||
try { xml = record.ToXml(); }
|
||||
catch { /* fall through with empty EventData */ }
|
||||
|
||||
ProcessEvent(
|
||||
providerName: record.ProviderName,
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
eventData: ParseEventData(xml),
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core processing extracted to primitive inputs so tests can drive it without synthesizing
|
||||
/// an abstract, Windows-only <see cref="EventRecord"/> (WindowsUpdateTracker test-seam
|
||||
/// pattern; a plain dictionary instead of named args because three providers with ~10
|
||||
/// payload fields each make a fixed signature impractical).
|
||||
/// </summary>
|
||||
internal void ProcessEvent(
|
||||
string providerName,
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
Dictionary<string, string> eventData,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (!MarkProcessed(recordId))
|
||||
return; // already emitted (cross-restart or duplicate delivery)
|
||||
|
||||
eventData = eventData ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.Equals(providerName, ProviderKernelGeneral, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClockChange)
|
||||
HandleClockChange(recordId, timeCreatedUtc, eventData, isBackfill);
|
||||
else if (string.Equals(providerName, ProviderPowerTroubleshooter, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClassicResume)
|
||||
HandleClassicResume(recordId, timeCreatedUtc, eventData, isBackfill);
|
||||
else if (string.Equals(providerName, ProviderKernelPower, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ModernStandbyExit)
|
||||
HandleModernStandbyExit(recordId, timeCreatedUtc, eventData, isBackfill);
|
||||
// Anything else slipped past the XPath filter — ignore.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// system_clock_changed (Kernel-General EventID 1)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void HandleClockChange(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
|
||||
{
|
||||
eventData.TryGetValue("NewTime", out var newTimeRaw);
|
||||
eventData.TryGetValue("OldTime", out var oldTimeRaw);
|
||||
var newTime = TryParseUtc(newTimeRaw);
|
||||
var oldTime = TryParseUtc(oldTimeRaw);
|
||||
|
||||
// Signed delta preferably from the authoritative instants (NewTime − OldTime carries
|
||||
// the direction unambiguously); TimeDeltaInMs is the fallback when either fails to
|
||||
// parse. Records with neither are unusable and skipped (already claimed by dedup).
|
||||
long deltaMs;
|
||||
if (newTime.HasValue && oldTime.HasValue)
|
||||
{
|
||||
deltaMs = (long)Math.Round((newTime.Value - oldTime.Value).TotalMilliseconds);
|
||||
}
|
||||
else if (eventData.TryGetValue("TimeDeltaInMs", out var deltaRaw) &&
|
||||
long.TryParse(deltaRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDelta))
|
||||
{
|
||||
deltaMs = parsedDelta;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug($"SystemTimeline: clock-change record {recordId} has no usable times/delta — skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.Abs(deltaMs) < MinClockDeltaMs)
|
||||
return; // w32time micro-slew — claimed (MarkProcessed) but never emitted
|
||||
|
||||
eventData.TryGetValue("Reason", out var reasonRaw);
|
||||
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason);
|
||||
var reasonText = reason == 1 ? "application_set"
|
||||
: reason == 2 ? "hardware_clock_sync"
|
||||
: "unknown";
|
||||
|
||||
eventData.TryGetValue("ProcessName", out var processName);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeDeltaMs", deltaMs },
|
||||
{ "reason", reason },
|
||||
{ "reasonText", reasonText },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
// Authoritative instants: re-normalized ISO-8601 when parseable, raw pass-through
|
||||
// otherwise — the payload must survive even a malformed provider rendering.
|
||||
data["newTime"] = newTime.HasValue ? newTime.Value.ToString("o") : (object)(newTimeRaw ?? string.Empty);
|
||||
data["oldTime"] = oldTime.HasValue ? oldTime.Value.ToString("o") : (object)(oldTimeRaw ?? string.Empty);
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
if (!string.IsNullOrEmpty(processName)) data["processName"] = processName;
|
||||
if (eventData.TryGetValue("ProcessID", out var pidRaw) &&
|
||||
int.TryParse(pidRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
|
||||
{
|
||||
data["processId"] = pid;
|
||||
}
|
||||
|
||||
var direction = deltaMs >= 0 ? "forward" : "backward";
|
||||
var processLeaf = ProcessLeafName(processName);
|
||||
var message = $"System clock stepped {direction} {FormatDuration(TimeSpan.FromMilliseconds(Math.Abs(deltaMs)))}" +
|
||||
$" (reason: {reasonText}" + (processLeaf != null ? $", process: {processLeaf})" : ")");
|
||||
|
||||
var isLargeStep = Math.Abs(deltaMs) >= ClockDeltaWarningMs;
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
// The record's own creation time, not UtcNow: backfilled pre-agent clock steps must
|
||||
// land on the timeline where they happened (WindowsUpdateTracker convention).
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SystemClockChanged,
|
||||
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
|
||||
Source = "SystemTimelineWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = isLargeStep,
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// system_sleep_episode — classic sleep/hibernate (Power-Troubleshooter EventID 1)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void HandleClassicResume(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
|
||||
{
|
||||
eventData.TryGetValue("SleepTime", out var sleepRaw);
|
||||
eventData.TryGetValue("WakeTime", out var wakeRaw);
|
||||
var enteredAt = TryParseUtc(sleepRaw);
|
||||
var exitedAt = TryParseUtc(wakeRaw);
|
||||
|
||||
// EffectiveState 5 = hibernate (S4); TargetState is the fallback when the effective
|
||||
// state is missing (older event versions).
|
||||
eventData.TryGetValue("EffectiveState", out var effectiveState);
|
||||
eventData.TryGetValue("TargetState", out var targetState);
|
||||
var stateValue = !string.IsNullOrEmpty(effectiveState) ? effectiveState : targetState;
|
||||
var kind = stateValue == "5" ? "hibernate" : "sleep";
|
||||
|
||||
long? durationSeconds = null;
|
||||
if (enteredAt.HasValue && exitedAt.HasValue && exitedAt.Value > enteredAt.Value)
|
||||
durationSeconds = (long)Math.Round((exitedAt.Value - enteredAt.Value).TotalSeconds);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", kind },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
data["enteredAt"] = enteredAt.HasValue ? enteredAt.Value.ToString("o") : (object)(sleepRaw ?? string.Empty);
|
||||
data["exitedAt"] = exitedAt.HasValue ? exitedAt.Value.ToString("o") : (object)(wakeRaw ?? string.Empty);
|
||||
if (durationSeconds.HasValue) data["durationSeconds"] = durationSeconds.Value;
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
if (eventData.TryGetValue("WakeSourceType", out var wakeTypeRaw) &&
|
||||
int.TryParse(wakeTypeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var wakeType))
|
||||
{
|
||||
data["wakeSourceType"] = wakeType;
|
||||
}
|
||||
eventData.TryGetValue("WakeSourceText", out var wakeText);
|
||||
if (!string.IsNullOrEmpty(wakeText)) data["wakeSourceText"] = wakeText;
|
||||
|
||||
var durationLabel = durationSeconds.HasValue
|
||||
? " after " + FormatDuration(TimeSpan.FromSeconds(durationSeconds.Value))
|
||||
: string.Empty;
|
||||
var wakeLabel = !string.IsNullOrEmpty(wakeText) ? $" (wake source: {wakeText})" : string.Empty;
|
||||
|
||||
EmitSleepEpisode(timeCreatedUtc, data,
|
||||
$"Device resumed from {kind}{durationLabel}{wakeLabel}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// system_sleep_episode — Modern Standby (Kernel-Power EventID 507)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void HandleModernStandbyExit(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
|
||||
{
|
||||
eventData.TryGetValue("SleepDurationInUs", out var sleepUsRaw);
|
||||
long.TryParse(sleepUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sleepUs);
|
||||
var sleepDurationSeconds = sleepUs / 1_000_000;
|
||||
|
||||
// Every screen-off scenario logs a 507, including seconds-long ones where the device
|
||||
// never slept (SleepDurationInUs 0, incl. SleepEntered=false) — one gate covers both.
|
||||
if (sleepDurationSeconds < MinSleepDurationSeconds)
|
||||
return; // claimed but not an episode worth the timeline
|
||||
|
||||
eventData.TryGetValue("DurationInUs", out var durationUsRaw);
|
||||
long.TryParse(durationUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var durationUs);
|
||||
if (durationUs < sleepUs) durationUs = sleepUs; // scenario duration can never undercut its sleep residency
|
||||
|
||||
var durationSeconds = durationUs / 1_000_000;
|
||||
var exitedAt = timeCreatedUtc; // 507 is written at scenario exit
|
||||
DateTime? enteredAt = exitedAt.HasValue
|
||||
? exitedAt.Value.AddMilliseconds(-(durationUs / 1000.0))
|
||||
: (DateTime?)null;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "kind", "modern_standby" },
|
||||
{ "durationSeconds", durationSeconds },
|
||||
{ "sleepDurationSeconds", sleepDurationSeconds },
|
||||
{ "backfilled", isBackfill },
|
||||
};
|
||||
if (enteredAt.HasValue) data["enteredAt"] = enteredAt.Value.ToString("o");
|
||||
if (exitedAt.HasValue) data["exitedAt"] = exitedAt.Value.ToString("o");
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
|
||||
if (eventData.TryGetValue("Reason", out var reasonRaw) &&
|
||||
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason))
|
||||
{
|
||||
data["reason"] = reason;
|
||||
}
|
||||
bool? onAcPower = null;
|
||||
if (eventData.TryGetValue("PowerStateAc", out var acRaw) && bool.TryParse(acRaw, out var ac))
|
||||
{
|
||||
onAcPower = ac;
|
||||
data["onAcPower"] = ac;
|
||||
}
|
||||
if (eventData.TryGetValue("BatteryRemainingCapacityOnExit", out var battRaw) &&
|
||||
long.TryParse(battRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var batt))
|
||||
{
|
||||
data["batteryRemainingCapacityOnExit"] = batt;
|
||||
}
|
||||
|
||||
var powerLabel = onAcPower.HasValue ? (onAcPower.Value ? ", on AC" : ", on battery") : string.Empty;
|
||||
EmitSleepEpisode(timeCreatedUtc, data,
|
||||
$"Device exited Modern Standby after {FormatDuration(TimeSpan.FromSeconds(durationSeconds))}" +
|
||||
$" (slept {FormatDuration(TimeSpan.FromSeconds(sleepDurationSeconds))}{powerLabel})");
|
||||
}
|
||||
|
||||
private void EmitSleepEpisode(DateTime? timeCreatedUtc, Dictionary<string, object> data, string message)
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
// Wake/exit moment (the record's creation time) — backfilled pre-agent episodes
|
||||
// must land on the timeline where the device actually woke.
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
EventType = Constants.EventTypes.SystemSleepEpisode,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "SystemTimelineWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Extracts <c><Data Name="...">value</Data></c> pairs from an event's rendered
|
||||
/// XML, keyed case-insensitively. Namespace-agnostic (matches on local name). Returns an
|
||||
/// empty map on null/malformed XML — never throws.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseEventData(string xml)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(xml)) return result;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Data"))
|
||||
{
|
||||
var nameAttr = el.Attribute("Name");
|
||||
if (nameAttr == null || string.IsNullOrEmpty(nameAttr.Value)) continue;
|
||||
if (!result.ContainsKey(nameAttr.Value))
|
||||
result[nameAttr.Value] = el.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unexpected XML — best effort, return what we have.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static DateTime? TryParseUtc(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
|
||||
? parsed
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>Compact human duration for messages: "2h 05m", "56m 12s", "45s".</summary>
|
||||
internal static string FormatDuration(TimeSpan span)
|
||||
{
|
||||
if (span.TotalHours >= 1)
|
||||
return $"{(long)span.TotalHours}h {span.Minutes:D2}m";
|
||||
if (span.TotalMinutes >= 1)
|
||||
return $"{span.Minutes}m {span.Seconds:D2}s";
|
||||
return $"{(long)Math.Ceiling(span.TotalSeconds)}s";
|
||||
}
|
||||
|
||||
/// <summary>Leaf file name of a kernel device path like <c>\Device\HarddiskVolume3\Windows\System32\svchost.exe</c>.</summary>
|
||||
internal static string ProcessLeafName(string devicePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(devicePath)) return null;
|
||||
var idx = devicePath.LastIndexOf('\\');
|
||||
var leaf = idx >= 0 ? devicePath.Substring(idx + 1) : devicePath;
|
||||
return string.IsNullOrEmpty(leaf) ? null : leaf;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Backfill
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void BackfillRecentEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
_logger.Info($"SystemTimeline backfill: scanning {Channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, restartWatermark={_restartWatermark})");
|
||||
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, BuildBackfillXPath(lookbackMs));
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
ProcessRecord(record, isBackfill: true);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
_logger.Debug($"SystemTimeline backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
|
||||
else
|
||||
_logger.Info($"SystemTimeline backfill: scanned {processed} record(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"SystemTimeline event log not found during backfill: {Channel} (normal on non-Windows test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"SystemTimeline backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"SystemTimeline backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
|
||||
/// see it (caller should evaluate/emit), false if it was already processed — either in a
|
||||
/// PRIOR run (at/below the restart watermark) or already this run (in the seen-set). Only
|
||||
/// advances + persists the max on a genuinely-new, higher RecordId. Records without a
|
||||
/// RecordId (-1) are always processed, never tracked or persisted. Note: threshold-suppressed
|
||||
/// records are claimed too, so a restart's backfill does not re-evaluate them.
|
||||
/// </summary>
|
||||
private bool MarkProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return false;
|
||||
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = _maxEmittedRecordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) PersistWatermark(toPersist);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
if (!File.Exists(filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"SystemTimeline watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load SystemTimeline watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistWatermark(long recordId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
|
||||
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
|
||||
var tempPath = filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, filePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist SystemTimeline watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// ARP resolution via <c>iphlpapi!SendARP</c> — resolves the MAC address of an on-link IPv4
|
||||
/// host (in practice: the default gateway). IPv4 only by API design; the OS answers from its
|
||||
/// ARP cache or sends a real ARP request, so the target must be on the local subnet.
|
||||
/// </summary>
|
||||
internal static class ArpNativeMethods
|
||||
{
|
||||
[DllImport("iphlpapi.dll", ExactSpelling = true)]
|
||||
private static extern int SendARP(uint destIP, uint srcIP, byte[] macAddress, ref uint macAddressLength);
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft MAC resolution. Returns false (with a diagnostic in <paramref name="error"/>)
|
||||
/// for non-IPv4 addresses, SendARP failures, or unexpected reply lengths — never throws.
|
||||
/// </summary>
|
||||
internal static bool TryResolveMac(IPAddress? ipv4Address, out byte[] mac, out string? error)
|
||||
{
|
||||
mac = new byte[6];
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
if (ipv4Address == null || ipv4Address.AddressFamily != AddressFamily.InterNetwork)
|
||||
{
|
||||
error = "not an IPv4 address";
|
||||
return false;
|
||||
}
|
||||
|
||||
// SendARP takes the IPv4 address as a uint in network byte order (first octet in
|
||||
// the lowest byte).
|
||||
var addressBytes = ipv4Address.GetAddressBytes();
|
||||
uint dest = ((uint)addressBytes[3] << 24)
|
||||
| ((uint)addressBytes[2] << 16)
|
||||
| ((uint)addressBytes[1] << 8)
|
||||
| addressBytes[0];
|
||||
|
||||
uint len = (uint)mac.Length;
|
||||
var err = SendARP(dest, 0, mac, ref len);
|
||||
if (err != 0)
|
||||
{
|
||||
error = $"SendARP returned {err}";
|
||||
return false;
|
||||
}
|
||||
if (len != 6)
|
||||
{
|
||||
error = $"SendARP returned unexpected MAC length {len}";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared "which NIC is the device actually using" heuristic: Up, not Loopback/Tunnel,
|
||||
/// carries a non-0.0.0.0 default gateway. Fail-soft — any enumeration error yields null.
|
||||
/// </summary>
|
||||
internal static class NetworkInterfaceLocator
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
internal static NetworkInterface? FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>First IPv4 default gateway of the interface (0.0.0.0 excluded), or null.</summary>
|
||||
internal static IPAddress? GetIpv4Gateway(NetworkInterface nic)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var gw in nic.GetIPProperties().GatewayAddresses)
|
||||
{
|
||||
var address = gw?.Address;
|
||||
if (address != null
|
||||
&& address.AddressFamily == AddressFamily.InterNetwork
|
||||
&& address.ToString() != "0.0.0.0")
|
||||
{
|
||||
return address;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
-88
@@ -10,7 +10,9 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
@@ -124,62 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private readonly string _imeLogFolder;
|
||||
private readonly string _agentLogFolder;
|
||||
private readonly string _agentStateFolder;
|
||||
private readonly string _agentSpoolFolder;
|
||||
private readonly string _agentDataFolder;
|
||||
// Built-in sections come from the Shared catalog (DiagnosticsBuiltInSections.All) — the
|
||||
// same list the backend serves to the portal, so what administrators see is exactly
|
||||
// what this service collects. Test seam: section id → folder; production leaves the
|
||||
// map empty and every section resolves from the catalog's (unexpanded) source folder.
|
||||
private readonly IReadOnlyDictionary<string, string> _sectionFolderOverrides;
|
||||
|
||||
private static readonly string DefaultImeLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
|
||||
|
||||
private static readonly string DefaultAgentLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
|
||||
private static readonly string DefaultAgentStateFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
|
||||
|
||||
private static readonly string DefaultAgentSpoolFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory);
|
||||
|
||||
private static readonly string DefaultAgentDataFolder =
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
|
||||
|
||||
/// <summary>
|
||||
/// File extensions collected from built-in log folders (Agent + IME).
|
||||
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
|
||||
/// </summary>
|
||||
private static readonly string[] LogFilePatterns = new[]
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Patterns collected from the agent state folder. Adds completion markers on top of
|
||||
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
|
||||
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
|
||||
/// </summary>
|
||||
private static readonly string[] StateFilePatterns = new[]
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
|
||||
"*.complete", "*.marker"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
|
||||
/// completion/exit markers, no session.id / bootstrap.json (those are config, not
|
||||
/// forensic state).
|
||||
/// </summary>
|
||||
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry spool: only the JSON-shaped files. The spool folder may also contain
|
||||
/// quarantine subfolders, so subfolders are excluded to keep the archive focused
|
||||
/// on what is actually pending upload.
|
||||
/// </summary>
|
||||
private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" };
|
||||
// Scenario oracle for DiagnosticsSectionCondition.DevicePreparation. Production reads
|
||||
// the deterministic WDP registry marker (the same gate every other WDP switch uses);
|
||||
// tests inject a constant so they never touch the registry.
|
||||
private readonly Func<bool> _devicePreparationProbe;
|
||||
|
||||
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
|
||||
: this(configuration, logger, apiClient, null, null, null, null, null)
|
||||
@@ -188,7 +144,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a
|
||||
// temp dir without touching real %ProgramData% paths. Production callers always go
|
||||
// through the public ctor.
|
||||
// through the public ctor. The five named overrides map onto the catalog's section
|
||||
// ids; sectionFolderOverrides covers every other section (RealmJoin*, ImeBootstrapper…).
|
||||
internal DiagnosticsPackageService(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
@@ -197,18 +154,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
string imeLogFolderOverride,
|
||||
string agentStateFolderOverride,
|
||||
string agentSpoolFolderOverride,
|
||||
string agentDataFolderOverride)
|
||||
string agentDataFolderOverride,
|
||||
IReadOnlyDictionary<string, string> sectionFolderOverrides = null,
|
||||
Func<bool> devicePreparationProbe = null)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_apiClient = apiClient;
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
_agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder;
|
||||
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
|
||||
_agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder;
|
||||
_agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder;
|
||||
_agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder;
|
||||
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
void Map(string sectionId, string folder)
|
||||
{
|
||||
if (folder != null) overrides[sectionId] = folder;
|
||||
}
|
||||
Map("AgentLogs", agentLogFolderOverride);
|
||||
Map("ImeLogs", imeLogFolderOverride);
|
||||
Map("AgentState", agentStateFolderOverride);
|
||||
Map("AgentSpool", agentSpoolFolderOverride);
|
||||
Map("AgentMarkers", agentDataFolderOverride);
|
||||
if (sectionFolderOverrides != null)
|
||||
{
|
||||
foreach (var kv in sectionFolderOverrides)
|
||||
overrides[kv.Key] = kv.Value;
|
||||
}
|
||||
_sectionFolderOverrides = overrides;
|
||||
|
||||
// Tests that pass folder overrides but no probe must stay registry-free: a seam
|
||||
// caller gets "not WDP" by default, the production ctor gets the real marker.
|
||||
_devicePreparationProbe = devicePreparationProbe
|
||||
?? (overrides.Count == 0
|
||||
? (Func<bool>)EnrollmentRegistryDetector.IsDeterministicDevicePreparation
|
||||
: () => false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -370,33 +347,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// 1. sessioninfo.txt — informational metadata, not subject to caps.
|
||||
AddSessionInfo(archive, enrollmentSucceeded);
|
||||
|
||||
// 2. Agent logs
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern, tracker);
|
||||
// 2. Built-in sections — ONE catalog shared with the backend (the portal
|
||||
// shows exactly this list): agent logs, IME logs (+ the Device Preparation
|
||||
// bootstrapper event log on WDP), agent state (recursive: `.quarantine`
|
||||
// and `.part1-<utc>/` buckets ride along), telemetry spool, top-level
|
||||
// markers, and the RealmJoin logs behind the tenant's watcher toggle.
|
||||
// The scenario probe is a registry read — evaluate it once per build.
|
||||
var isDevicePreparation = ProbeDevicePreparation();
|
||||
ManifestLine($"SCENARIO: devicePreparation={isDevicePreparation} realmJoinWatcher={_configuration.EnableRealmJoinWatcher}");
|
||||
foreach (var section in DiagnosticsBuiltInSections.All)
|
||||
AddBuiltInSection(archive, section, tracker, isDevicePreparation);
|
||||
|
||||
// 3. IME logs (all relevant files in the IME log folder)
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern, tracker);
|
||||
|
||||
// 4. Agent state: snapshot, journal, signal-log, ime-tracker-state,
|
||||
// enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json.
|
||||
// Recursive to include `.quarantine` subfolders AND `.part1-<utc>/` buckets
|
||||
// that StateArchiver creates on WG Part-2 boot — both ride along under
|
||||
// AgentState/ via the same recursive walk.
|
||||
foreach (var pattern in StateFilePatterns)
|
||||
AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, tracker, includeSubfolders: true);
|
||||
|
||||
// 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?)
|
||||
foreach (var pattern in SpoolFilePatterns)
|
||||
AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, tracker, includeSubfolders: false);
|
||||
|
||||
// 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2
|
||||
// bridge + ghost-restart guard. Top-directory only to avoid re-zipping
|
||||
// State/Spool/Logs/Agent which already have their own sections.
|
||||
foreach (var pattern in RootMarkerPatterns)
|
||||
AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, tracker, includeSubfolders: false);
|
||||
|
||||
// 7. Configured additional log paths (global + tenant, validated by guards)
|
||||
// 3. Configured additional log paths (global + tenant, validated by guards)
|
||||
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
|
||||
{
|
||||
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
|
||||
@@ -447,6 +409,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private bool ProbeDevicePreparation()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _devicePreparationProbe();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail toward Classic like every other WDP gate: the evtx export is skipped,
|
||||
// never the whole package.
|
||||
_logger.Warning($"Device Preparation probe failed — treating as Classic: {ex.Message}");
|
||||
ManifestLine($"SCENARIO PROBE FAILED (treated as Classic): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure gate: is this built-in section collected under the given configuration and
|
||||
/// enrollment scenario? Unknown conditions (a newer Shared catalog than this agent)
|
||||
/// fail closed.
|
||||
/// </summary>
|
||||
internal static bool IsSectionActive(DiagnosticsBuiltInSection section, AgentConfiguration configuration, bool isDevicePreparation)
|
||||
{
|
||||
switch (section.Condition)
|
||||
{
|
||||
case DiagnosticsSectionCondition.Always:
|
||||
return true;
|
||||
case DiagnosticsSectionCondition.RealmJoinWatcher:
|
||||
return configuration.EnableRealmJoinWatcher;
|
||||
case DiagnosticsSectionCondition.DevicePreparation:
|
||||
return isDevicePreparation;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeInactiveCondition(DiagnosticsSectionCondition condition)
|
||||
{
|
||||
switch (condition)
|
||||
{
|
||||
case DiagnosticsSectionCondition.RealmJoinWatcher:
|
||||
return "RealmJoin Watcher disabled";
|
||||
case DiagnosticsSectionCondition.DevicePreparation:
|
||||
return "not a Device Preparation enrollment";
|
||||
default:
|
||||
return $"unknown condition {condition}";
|
||||
}
|
||||
}
|
||||
|
||||
private void AddBuiltInSection(ZipArchive archive, DiagnosticsBuiltInSection section, BudgetTracker tracker, bool isDevicePreparation)
|
||||
{
|
||||
if (!IsSectionActive(section, _configuration, isDevicePreparation))
|
||||
{
|
||||
ManifestLine($"BUILT-IN SKIPPED ({DescribeInactiveCondition(section.Condition)}): {section.Id}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Override (test seam) or catalog source folder with %ProgramData% /
|
||||
// %LOGGED_ON_USER_PROFILE% expanded; null means the token is present but no
|
||||
// interactive user has been detected yet — same skip as configured paths.
|
||||
var folder = _sectionFolderOverrides.TryGetValue(section.Id, out var overrideFolder)
|
||||
? overrideFolder
|
||||
: UserProfileResolver.ExpandCustomTokens(section.SourceFolder);
|
||||
if (folder == null)
|
||||
{
|
||||
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
|
||||
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
|
||||
return;
|
||||
}
|
||||
|
||||
ManifestLine($"BUILT-IN: {section.Id} -> folder='{folder}' zip='{section.ZipFolder}' recursive={section.IncludeSubfolders}");
|
||||
foreach (var pattern in section.Patterns)
|
||||
AddLogFiles(archive, folder, section.ZipFolder, pattern, tracker, section.IncludeSubfolders);
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -20,7 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static class UserProfileResolver
|
||||
{
|
||||
public const string Token = "%LOGGED_ON_USER_PROFILE%";
|
||||
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
|
||||
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
|
||||
+2
-30
@@ -922,36 +922,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
private static NetworkInterface FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static NetworkInterface FindActiveNetworkInterface() =>
|
||||
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
|
||||
|
||||
+2
-30
@@ -420,36 +420,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
private static NetworkInterface FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static NetworkInterface FindActiveNetworkInterface() =>
|
||||
NetworkInterfaceLocator.FindActiveNetworkInterface();
|
||||
|
||||
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
public class DoGroupIdSetResult
|
||||
{
|
||||
/// <summary>The DOGroupId value is the computed one after this run (written or already there).</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>True when this run actually wrote the registry value (false for the idempotent no-op).</summary>
|
||||
public bool Written { get; set; }
|
||||
|
||||
/// <summary>Deliberate non-action (existing foreign policy/value, no usable network) — not an error.</summary>
|
||||
public bool Skipped { get; set; }
|
||||
public string? SkipReason { get; set; }
|
||||
|
||||
public string? GroupId { get; set; }
|
||||
public string? PreviousValue { get; set; }
|
||||
public string? GatewayIp { get; set; }
|
||||
public string? GatewayMac { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
internal enum DoGroupIdAction
|
||||
{
|
||||
Write,
|
||||
NoOp,
|
||||
SkipForeign,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Delivery Optimization group ID policy value (<c>DOGroupId</c> under the GPO
|
||||
/// DeliveryOptimization key) from a network fingerprint: a deterministic GUID derived from the
|
||||
/// default gateway's IPv4 address and MAC address, so all devices behind the same gateway land
|
||||
/// in the same DO peering group. The byte layout is RealmJoin-compatible — devices managed by
|
||||
/// RealmJoin on the same network compute the identical GUID and peer with ours.
|
||||
/// <para>
|
||||
/// Deliberately conservative where RealmJoin is not: no random-GUID fallback (a per-device
|
||||
/// random ID would isolate the device in a one-member group — worse than the OS default
|
||||
/// grouping), and existing foreign configuration always wins: a DOGroupIdSource policy, an
|
||||
/// MDM-managed DOGroupId, or a GPO value we did not write ourselves all result in a skip.
|
||||
/// Never throws.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DoGroupIdService
|
||||
{
|
||||
internal const string GroupIdValueName = "DOGroupId";
|
||||
internal const string GroupIdSourceValueName = "DOGroupIdSource";
|
||||
|
||||
// RealmJoin's fingerprint-GUID prefix — part of the compatible byte layout, do not change.
|
||||
private static readonly byte[] GuidPrefix = { 101, 48, 6 };
|
||||
|
||||
/// <summary>
|
||||
/// Computes and applies the fingerprint GroupId. <paramref name="lastWrittenGroupId"/> is
|
||||
/// the GUID a previous agent run wrote (null when none is recorded) — it lets us update our
|
||||
/// own value after a gateway change while never touching a foreign one.
|
||||
/// </summary>
|
||||
public static DoGroupIdSetResult TrySetGroupId(string? lastWrittenGroupId, AgentLogger logger)
|
||||
{
|
||||
var result = new DoGroupIdSetResult();
|
||||
|
||||
try
|
||||
{
|
||||
// ---- Network fingerprint -------------------------------------------------
|
||||
var nic = NetworkInterfaceLocator.FindActiveNetworkInterface();
|
||||
var gateway = nic == null ? null : NetworkInterfaceLocator.GetIpv4Gateway(nic);
|
||||
if (gateway == null)
|
||||
{
|
||||
result.Skipped = true;
|
||||
result.SkipReason = "no_ipv4_gateway";
|
||||
logger.Info("DO GroupId auto-set: no active interface with an IPv4 default gateway — skipping.");
|
||||
return result;
|
||||
}
|
||||
result.GatewayIp = gateway.ToString();
|
||||
|
||||
if (!ArpNativeMethods.TryResolveMac(gateway, out var mac, out var arpError))
|
||||
{
|
||||
result.Skipped = true;
|
||||
result.SkipReason = "arp_failed";
|
||||
result.Error = arpError;
|
||||
logger.Warning($"DO GroupId auto-set: could not resolve gateway MAC ({arpError}) — skipping.");
|
||||
return result;
|
||||
}
|
||||
result.GatewayMac = BitConverter.ToString(mac);
|
||||
|
||||
var computed = ComputeGroupId(gateway.GetAddressBytes(), mac).ToString();
|
||||
result.GroupId = computed;
|
||||
|
||||
// ---- Foreign-configuration checks ----------------------------------------
|
||||
// DOGroupIdSource (either store) selects a dynamic group source — a fingerprint
|
||||
// GroupId would fight it. An MDM-managed DOGroupId means Intune owns the setting.
|
||||
if (ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdSourceValueName) != null
|
||||
|| ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdSourceValueName) != null)
|
||||
{
|
||||
result.Skipped = true;
|
||||
result.SkipReason = "group_id_source_policy";
|
||||
logger.Info("DO GroupId auto-set: DOGroupIdSource policy is configured — skipping.");
|
||||
return result;
|
||||
}
|
||||
if (ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdValueName) != null)
|
||||
{
|
||||
result.Skipped = true;
|
||||
result.SkipReason = "mdm_policy_present";
|
||||
logger.Info("DO GroupId auto-set: DOGroupId is managed via MDM policy — skipping.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Ownership decision ---------------------------------------------------
|
||||
var existing = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
|
||||
result.PreviousValue = existing;
|
||||
|
||||
switch (DecideAction(existing, computed, lastWrittenGroupId))
|
||||
{
|
||||
case DoGroupIdAction.NoOp:
|
||||
result.Success = true;
|
||||
logger.Info($"DO GroupId auto-set: already set to {computed} — nothing to do.");
|
||||
return result;
|
||||
|
||||
case DoGroupIdAction.SkipForeign:
|
||||
result.Skipped = true;
|
||||
result.SkipReason = "foreign_value_present";
|
||||
logger.Info($"DO GroupId auto-set: existing DOGroupId '{existing}' was not written by us — skipping.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Write + readback -----------------------------------------------------
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
using (var key = hklm.CreateSubKey(DoThrottlePolicyReader.GpoKeyPath))
|
||||
{
|
||||
key.SetValue(GroupIdValueName, computed, RegistryValueKind.String);
|
||||
}
|
||||
|
||||
var readback = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
|
||||
if (!string.Equals(readback, computed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Error = $"readback mismatch: wrote '{computed}', read '{readback ?? "<null>"}'";
|
||||
logger.Warning($"DO GroupId auto-set failed: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
result.Written = true;
|
||||
logger.Info($"DO GroupId auto-set: DOGroupId set to {computed} (gateway {result.GatewayIp}, previous: {existing ?? "<none>"})");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = ex.Message;
|
||||
logger.Warning($"DO GroupId auto-set failed: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RealmJoin-compatible fingerprint GUID: 16 bytes laid out as
|
||||
/// prefix {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac[6], passed to
|
||||
/// <c>new Guid(byte[])</c>. The IP byte shuffle mirrors RealmJoin's "swap for
|
||||
/// new Guid()" step — the layout must stay bit-identical for cross-product peering.
|
||||
/// </summary>
|
||||
internal static Guid ComputeGroupId(byte[] ipv4Bytes, byte[] macBytes)
|
||||
{
|
||||
if (ipv4Bytes == null || ipv4Bytes.Length != 4)
|
||||
throw new ArgumentException("IPv4 address bytes required", nameof(ipv4Bytes));
|
||||
if (macBytes == null || macBytes.Length != 6)
|
||||
throw new ArgumentException("6-byte MAC required", nameof(macBytes));
|
||||
|
||||
var bytes = new byte[16];
|
||||
Array.Copy(GuidPrefix, 0, bytes, 0, GuidPrefix.Length);
|
||||
// bytes[3..5] stay zero (RealmJoin's zero padding)
|
||||
bytes[6] = ipv4Bytes[1];
|
||||
// bytes[7] stays zero
|
||||
bytes[8] = ipv4Bytes[2];
|
||||
bytes[9] = ipv4Bytes[3];
|
||||
Array.Copy(macBytes, 0, bytes, 10, macBytes.Length);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure ownership decision. GUID comparison tolerates case and braces so a value written
|
||||
/// by another tool in "{...}" form still matches semantically.
|
||||
/// </summary>
|
||||
internal static DoGroupIdAction DecideAction(string? existing, string computed, string? lastWrittenByUs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(existing))
|
||||
return DoGroupIdAction.Write;
|
||||
if (GuidEquals(existing, computed))
|
||||
return DoGroupIdAction.NoOp;
|
||||
if (GuidEquals(existing, lastWrittenByUs))
|
||||
return DoGroupIdAction.Write; // our own stale value — gateway changed
|
||||
return DoGroupIdAction.SkipForeign;
|
||||
}
|
||||
|
||||
private static bool GuidEquals(string? a, string? b)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
|
||||
return Guid.TryParse(a, out var ga) && Guid.TryParse(b, out var gb) && ga == gb;
|
||||
}
|
||||
|
||||
private static object? ReadPolicyValue(string keyPath, string valueName)
|
||||
{
|
||||
// Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy
|
||||
// stores live in the 64-bit view regardless of the agent's bitness.
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
using (var key = hklm.OpenSubKey(keyPath))
|
||||
{
|
||||
return key?.GetValue(valueName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Boot-time NIC grace shared by every first-contact backend call (remote-config fetch,
|
||||
/// session registration, emergency-break reporter pattern): polls the cheap link-level
|
||||
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
|
||||
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
|
||||
/// being burned into a link that is provably still down (BootTrigger relaunch after a
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
|
||||
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
|
||||
/// never the caller's operation.
|
||||
/// </summary>
|
||||
internal static class NetworkLinkWait
|
||||
{
|
||||
internal static async Task WaitAsync(AgentLogger logger, TimeSpan maxWait, string context)
|
||||
{
|
||||
if (NetworkInterface.GetIsNetworkAvailable()) return;
|
||||
|
||||
logger?.Info($"{context}: no network link yet — waiting up to {maxWait.TotalSeconds:F0}s.");
|
||||
var deadline = DateTime.UtcNow + maxWait;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
if (NetworkInterface.GetIsNetworkAvailable())
|
||||
{
|
||||
logger?.Info($"{context}: network link is up.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
logger?.Warning($"{context}: still no network link after {maxWait.TotalSeconds:F0}s — attempting anyway.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,6 +557,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
|
||||
// System timeline watcher — System channel: Kernel-General 1 (system clock stepped,
|
||||
// ground truth for time jumps incl. old/new time + setting process) and completed
|
||||
// sleep/standby episodes (Power-Troubleshooter 1, Kernel-Power 507). Backfill recovers
|
||||
// the pre-agent OOBE clock correction and standby episodes from the persistent .evtx.
|
||||
if (collectors.EnableSystemTimelineWatcher)
|
||||
{
|
||||
hosts.Add(new SystemTimelineWatcherHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
backfillLookbackMinutes: collectors.SystemEventBackfillLookbackMinutes,
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
|
||||
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
|
||||
|
||||
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
|
||||
|
||||
+60
-14
@@ -15,10 +15,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>
|
||||
/// Live AC/battery watcher: subscribes to the WMI push event
|
||||
/// <c>Win32_PowerManagementEvent WHERE EventCode = 10</c> (PBT_APMPOWERSTATUSCHANGE — fires on
|
||||
/// AC↔DC switches and battery-percentage steps; the event carries no payload, so each arrival
|
||||
/// re-probes via <see cref="PowerStateProbe"/>) and diffs the snapshots through the pure
|
||||
/// <see cref="PowerStateTransitionTracker"/> into <c>power_state_change</c> events. No polling:
|
||||
/// between pushes the host is completely idle. Complements the one-shot startup
|
||||
/// AC↔DC switches and battery-percentage steps), falling back to the intrinsic
|
||||
/// <c>__InstanceModificationEvent WITHIN 30 … Win32_Battery</c> where the legacy push provider
|
||||
/// no longer loads (Windows 11 24H2/25H2, builds 26200+: activation fails WBEM_E_NOT_FOUND
|
||||
/// although the provider is still registered — session 161b838c). Either way the arriving
|
||||
/// event's payload is ignored; each arrival re-probes via <see cref="PowerStateProbe"/> and
|
||||
/// diffs the snapshots through the pure <see cref="PowerStateTransitionTracker"/> into
|
||||
/// <c>power_state_change</c> events. No polling in this process (the fallback's WITHIN
|
||||
/// sampling runs inside WinMgmt). Complements the one-shot startup
|
||||
/// <c>power_state_check</c> (<c>StartupEnvironmentProbes</c>), which stays the baseline event.
|
||||
/// <para>
|
||||
/// Devices without a battery (or with a failing probe) never arm the WMI subscription — zero
|
||||
@@ -117,10 +121,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (!armWmi) return;
|
||||
|
||||
// Arm chain: the extrinsic push event first (zero WinMgmt cost, still alive on older
|
||||
// builds), then the intrinsic Win32_Battery modification event. On Windows 11 24H2/25H2
|
||||
// (observed on builds 26200/26220, session 161b838c) the MS_Power_Management_Event_Provider
|
||||
// is still REGISTERED but its activation fails with WBEM_E_NOT_FOUND — the legacy provider
|
||||
// no longer loads, so the push query throws on exactly the devices that enroll today.
|
||||
// The fallback's WITHIN polling runs inside WinMgmt (a Win32_Battery snapshot every 30 s),
|
||||
// NOT in this process; the handler path is identical (payload ignored, re-probe + diff),
|
||||
// worst-case detection latency ≈ 35 s incl. debounce.
|
||||
if (TryArmWatcher(
|
||||
"SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10",
|
||||
"power_management_event (push)", baseline, out var pushError))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryArmWatcher(
|
||||
"SELECT * FROM __InstanceModificationEvent WITHIN 30 WHERE TargetInstance ISA 'Win32_Battery'",
|
||||
"battery_instance_poll (WinMgmt WITHIN 30s)", baseline, out var fallbackError))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Warning($"[{Name}] could not arm any WMI power watcher — " +
|
||||
$"push: {Describe(pushError)}; fallback: {Describe(fallbackError)} — " +
|
||||
"live power-state transitions will not be observed this run");
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed",
|
||||
fallbackError ?? pushError);
|
||||
}
|
||||
|
||||
private static string Describe(Exception? ex)
|
||||
=> ex == null ? "n/a" : $"{ex.GetType().Name}: {ex.Message}";
|
||||
|
||||
/// <summary>
|
||||
/// One arm attempt for the given WQL event query, with the full dispose-race dance
|
||||
/// (ConsoleBypassWatcher pattern). Returns true when the chain should stop: the watcher
|
||||
/// armed, or disposal won the race (then arming anything would leak a subscription).
|
||||
/// Returns false with the captured exception so the caller can try the next query.
|
||||
/// </summary>
|
||||
private bool TryArmWatcher(string wql, string modeLabel, PowerStateResult baseline, out Exception? error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery("SELECT * FROM Win32_PowerManagementEvent WHERE EventCode = 10");
|
||||
var watcher = new ManagementEventWatcher(query);
|
||||
var watcher = new ManagementEventWatcher(new WqlEventQuery(wql));
|
||||
watcher.EventArrived += OnPowerStatusChange;
|
||||
|
||||
lock (_lock)
|
||||
@@ -130,7 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Dispose won the race against the background start — don't arm.
|
||||
try { watcher.EventArrived -= OnPowerStatusChange; } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
_watcher = watcher;
|
||||
}
|
||||
@@ -151,25 +195,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
try { watcher.EventArrived -= OnPowerStatusChange; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.Info($"[{Name}] watching Win32_PowerManagementEvent (EventCode 10) — " +
|
||||
_logger.Info($"[{Name}] armed via {modeLabel} — " +
|
||||
$"baseline: {(baseline.OnAcPower ? "AC" : "battery")}, {baseline.BatteryPercent?.ToString() ?? "?"}%");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[{Name}] could not arm WMI power watcher ({ex.GetType().Name}: {ex.Message}) — " +
|
||||
"live power-state transitions will not be observed this run");
|
||||
error = ex;
|
||||
_logger.Info($"[{Name}] arm attempt '{modeLabel}' failed ({ex.GetType().Name}: {ex.Message})");
|
||||
lock (_lock) { _watcher = null; }
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPowerStatusChange(object sender, EventArrivedEventArgs e)
|
||||
{
|
||||
// The WMI event carries no usable payload; it only tells us "power status changed".
|
||||
// Debounce, then re-probe — never call watcher.Stop() from inside this callback.
|
||||
// Push and fallback events are treated identically: the payload is ignored, the event
|
||||
// only tells us "power status may have changed". Debounce, then re-probe — never call
|
||||
// watcher.Stop() from inside this callback.
|
||||
try { ScheduleDebouncedTick(); }
|
||||
catch (Exception ex) { _logger.Debug($"[{Name}] power event handler error: {ex.Message}"); }
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standalone host for the <see cref="SystemTimelineTracker"/> — watches the <c>System</c>
|
||||
/// event log for system clock steps (Kernel-General 1) and completed sleep/standby episodes
|
||||
/// (Power-Troubleshooter 1 / Kernel-Power 507) and forwards them via the shared single-rail
|
||||
/// ingress. Mirrors <see cref="WindowsUpdateWatcherHost"/>: builds its own
|
||||
/// <see cref="InformationalEventPost"/> and delegates Start/Stop to the tracker.
|
||||
/// </summary>
|
||||
internal sealed class SystemTimelineWatcherHost : ICollectorHost
|
||||
{
|
||||
public string Name => "SystemTimelineTracker";
|
||||
|
||||
private readonly SystemTimelineTracker _tracker;
|
||||
private readonly AgentLogger _logger;
|
||||
private int _disposed;
|
||||
|
||||
public SystemTimelineWatcherHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
int backfillLookbackMinutes,
|
||||
string stateDirectory)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_tracker = new SystemTimelineTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_logger.Info("SystemTimelineWatcherHost: started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"SystemTimelineWatcherHost: stop failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// detection — the exact scenario the power watcher exists to observe.
|
||||
SharedConstants.EventTypes.PowerStateChange,
|
||||
|
||||
// Same reasoning for the system timeline watcher: a clock step, a wake from standby,
|
||||
// or a backfilled episode at agent start is environment observation and must not
|
||||
// reset the idle/stall clocks.
|
||||
SharedConstants.EventTypes.SystemClockChanged,
|
||||
SharedConstants.EventTypes.SystemSleepEpisode,
|
||||
|
||||
// Agent health / control / transport — not device/enrollment progress.
|
||||
SharedConstants.EventTypes.CollectorDegraded,
|
||||
SharedConstants.EventTypes.TelemetryUploadPoisoned,
|
||||
|
||||
@@ -124,6 +124,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Persistence
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only peek of the key's current fingerprint (possibly restored from a previous
|
||||
/// agent run), or null when none is recorded. Like <see cref="HasFingerprint"/> this makes
|
||||
/// NO claim — use it when the fingerprint carries state the caller needs to read back
|
||||
/// (e.g. the DO GroupId ownership marker).
|
||||
/// </summary>
|
||||
public string? GetFingerprint(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return null;
|
||||
lock (_lock)
|
||||
{
|
||||
return _entries.TryGetValue(key, out var entry) ? entry.Fingerprint : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retry-until-success: true when <see cref="MarkSucceeded"/> latched this key in any run.</summary>
|
||||
public bool AlreadySucceeded(string key)
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
/// Always emitted — offset >60s is Warning, smaller is Info, failure is Warning.</item>
|
||||
/// <item><b>power_state_check</b> — AC vs. battery snapshot via Win32 <c>GetSystemPowerStatus</c>.
|
||||
/// On battery with <80% charge is Warning, otherwise Info, probe failure is Warning.</item>
|
||||
/// <item><b>do_group_id_auto_set</b> — only when <c>EnableDoGroupIdAutoSet=true</c>. Sets the
|
||||
/// Delivery Optimization <c>DOGroupId</c> policy value from a network fingerprint via
|
||||
/// <see cref="DoGroupIdService"/>; skips (Info) rather than fight foreign policy values.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event,
|
||||
@@ -78,6 +81,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
await RunNtpCheck(configuration, logger, post, gate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (configuration.EnableDoGroupIdAutoSet)
|
||||
RunDoGroupIdAutoSet(configuration, logger, post, gate);
|
||||
|
||||
RunPowerStateCheck(configuration, logger, post);
|
||||
}
|
||||
|
||||
@@ -331,6 +337,99 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot DO GroupId apply with change-based restart dedup: the gate fingerprint doubles
|
||||
/// as the ownership marker ("written:<guid>" records the GUID we last wrote, so a later
|
||||
/// run can distinguish our own stale value from a foreign one after a gateway change).
|
||||
/// Unchanged outcomes (same GUID, same skip reason) re-run silently without a duplicate
|
||||
/// event; errors never claim a fingerprint so each failing start keeps its retry trail.
|
||||
/// </summary>
|
||||
private static void RunDoGroupIdAutoSet(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
Orchestration.InformationalEventPost post,
|
||||
Persistence.StartupEventGate? gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lastWritten = ExtractLastWrittenGroupId(gate?.GetFingerprint(Constants.EventTypes.DoGroupIdAutoSet));
|
||||
var result = DoGroupIdService.TrySetGroupId(lastWritten, logger);
|
||||
|
||||
var fingerprint = ComputeDoGroupIdFingerprint(result);
|
||||
if (fingerprint != null && gate != null
|
||||
&& !gate.ShouldEmit(Constants.EventTypes.DoGroupIdAutoSet, fingerprint))
|
||||
{
|
||||
logger.Debug("StartupEnvironmentProbes: DO GroupId outcome unchanged — skipping duplicate event (restart dedup).");
|
||||
return;
|
||||
}
|
||||
|
||||
SafeEmit(post, logger, BuildDoGroupIdEvent(configuration, result));
|
||||
|
||||
if (fingerprint != null)
|
||||
gate?.MarkEmitted(Constants.EventTypes.DoGroupIdAutoSet);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"StartupEnvironmentProbes: DO GroupId auto-set threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal const string DoGroupIdWrittenFingerprintPrefix = "written:";
|
||||
|
||||
/// <summary>
|
||||
/// Success (written or already correct) fingerprints as "written:<guid>", a skip as
|
||||
/// "skipped:<reason>"; errors return null so they are never deduped.
|
||||
/// </summary>
|
||||
internal static string? ComputeDoGroupIdFingerprint(DoGroupIdSetResult result)
|
||||
{
|
||||
if (result.Success) return DoGroupIdWrittenFingerprintPrefix + result.GroupId;
|
||||
if (result.Skipped) return "skipped:" + result.SkipReason;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Ownership marker readback: the GUID from a "written:" fingerprint, else null.</summary>
|
||||
internal static string? ExtractLastWrittenGroupId(string? fingerprint) =>
|
||||
fingerprint != null && fingerprint.StartsWith(DoGroupIdWrittenFingerprintPrefix, StringComparison.Ordinal)
|
||||
? fingerprint.Substring(DoGroupIdWrittenFingerprintPrefix.Length)
|
||||
: null;
|
||||
|
||||
internal static EnrollmentEvent BuildDoGroupIdEvent(AgentConfiguration configuration, DoGroupIdSetResult result)
|
||||
{
|
||||
string message;
|
||||
if (result.Success && result.Written)
|
||||
message = $"Delivery Optimization GroupId set to {result.GroupId} (gateway {result.GatewayIp})";
|
||||
else if (result.Success)
|
||||
message = $"Delivery Optimization GroupId already set to {result.GroupId}";
|
||||
else if (result.Skipped)
|
||||
message = $"Delivery Optimization GroupId not set: {result.SkipReason}";
|
||||
else
|
||||
message = $"Delivery Optimization GroupId auto-set failed: {result.Error}";
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
SessionId = configuration.SessionId,
|
||||
TenantId = configuration.TenantId,
|
||||
EventType = Constants.EventTypes.DoGroupIdAutoSet,
|
||||
Severity = result.Success || result.Skipped ? EventSeverity.Info : EventSeverity.Warning,
|
||||
Source = "StartupEnvironmentProbes",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "groupId", result.GroupId ?? "" },
|
||||
{ "previousValue", result.PreviousValue ?? "" },
|
||||
{ "gatewayIp", result.GatewayIp ?? "" },
|
||||
{ "gatewayMac", result.GatewayMac ?? "" },
|
||||
{ "written", result.Written },
|
||||
{ "skipped", result.Skipped },
|
||||
{ "skipReason", result.SkipReason ?? "" },
|
||||
{ "success", result.Success },
|
||||
{ "error", result.Error ?? "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static void RunPowerStateCheck(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
@@ -23,6 +24,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The sibling <c>Microsoft Intune MDM Device CA</c> client cert (the one the agent authenticates
|
||||
/// with, see <see cref="CertificateHelper"/>) carries a DIFFERENT OID arc that identifies the same
|
||||
/// device from Intune's side. Observed on a live Intune-enrolled Windows 11 device:
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>OID</term><description>Meaning / encoding</description></listheader>
|
||||
/// <item><term><c>1.2.840.113556.5.4</c></term><description>Intune DeviceId — BARE 16-byte GUID, no ASN.1 wrapper. Equals the cert's Subject CN.</description></item>
|
||||
/// <item><term><c>1.2.840.113556.5.6</c></term><description>Intune AccountId (the Intune tenant account, NOT the Entra TenantId) — OCTET STRING GUID.</description></item>
|
||||
/// <item><term><c>1.2.840.113556.5.14</c></term><description>Entra TenantId — OCTET STRING GUID. This is the one to compare against an Entra tenant id.</description></item>
|
||||
/// <item><term><c>1.2.840.113556.5.11</c></term><description>Further GUID, OCTET STRING; semantics not documented by Microsoft and not verified here.</description></item>
|
||||
/// <item><term><c>1.2.840.113556.5.15/.16/.17/.18/.19/.23</c></term><description>ASN.1 INTEGER enums (platform / enrollment flavour); semantics undocumented.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The bare-GUID encoding of <c>1.2.840.113556.5.4</c> is why
|
||||
/// <see cref="ParseGuidFromExtension"/> accepts an unwrapped 16-byte payload as well —
|
||||
/// a strict OCTET-STRING parser silently returns <c>null</c> for that extension.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All probes are best-effort: never throw, return <c>null</c> on miss, log a Warning
|
||||
/// with the specific failure reason (no matching cert / extension missing / ASN.1
|
||||
/// parse failed) so the agent log explains why the fallback didn't help.
|
||||
@@ -31,8 +50,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public static class EntraDeviceCertHelper
|
||||
{
|
||||
public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
|
||||
public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
|
||||
public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
|
||||
public const string TenantIdOid = MsDeviceCertificateOids.EntraJoinCertTenantIdOid;
|
||||
public const string DeviceIdOid = MsDeviceCertificateOids.EntraJoinCertDeviceIdOid;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
|
||||
@@ -88,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
continue;
|
||||
}
|
||||
|
||||
var guid = ParseGuidFromAsn1OctetString(ext.RawData);
|
||||
var guid = ParseGuidFromExtension(ext.RawData);
|
||||
if (guid != null)
|
||||
{
|
||||
logger?.Info(
|
||||
@@ -98,7 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
|
||||
logger?.Warning(
|
||||
$"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
|
||||
$"EntraDeviceCertHelper: failed to parse {label} GUID extension in cert thumbprint={cert.Thumbprint} " +
|
||||
$"(raw {ext.RawData.Length} bytes); trying next candidate.");
|
||||
}
|
||||
|
||||
@@ -116,46 +135,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
/// <summary>
|
||||
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
|
||||
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
|
||||
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
|
||||
/// per <see cref="Guid(byte[])"/>) out of a certificate extension's raw value.
|
||||
/// Two encodings are accepted, because Microsoft uses both:
|
||||
/// <list type="number">
|
||||
/// <item><description>Wrapped in an ASN.1 OCTET STRING — short-form (<c>04 10 ...</c>) or
|
||||
/// long-form (<c>04 81 10 ...</c>) length. Used by the MS-Organization-Access TenantId/DeviceId
|
||||
/// extensions and by the Intune <c>…5.6</c> / <c>…5.14</c> extensions.</description></item>
|
||||
/// <item><description>A bare, unwrapped 16-byte payload. Used by the Intune DeviceId extension
|
||||
/// <c>1.2.840.113556.5.4</c> — verified against a live Intune MDM device cert, whose raw value is
|
||||
/// exactly 16 bytes with no <c>04 10</c> prefix.</description></item>
|
||||
/// </list>
|
||||
/// A valid OCTET-STRING wrapper around 16 bytes is at least 18 bytes long, so the two forms
|
||||
/// never collide for well-formed input. A 16-byte value is always read as the bare form —
|
||||
/// a truncated wrapper of exactly that length is indistinguishable from it.
|
||||
/// Returns <c>null</c> on any structural mismatch — never throws.
|
||||
/// <para>
|
||||
/// The decoding itself lives in <see cref="MsDeviceCertificateOids.TryParseGuid"/> so the agent
|
||||
/// and the backend's client-cert tenant binding share one implementation; this stays as the
|
||||
/// agent-side nullable-returning façade.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
|
||||
{
|
||||
if (raw == null || raw.Length < 2) return null;
|
||||
|
||||
// ASN.1 OCTET STRING tag.
|
||||
if (raw[0] != 0x04) return null;
|
||||
|
||||
int contentStart;
|
||||
int contentLen;
|
||||
|
||||
if (raw[1] < 0x80)
|
||||
{
|
||||
// short-form length
|
||||
contentLen = raw[1];
|
||||
contentStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// long-form length: lower 7 bits = number of following length bytes.
|
||||
int numLenBytes = raw[1] & 0x7F;
|
||||
if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
|
||||
|
||||
contentLen = 0;
|
||||
for (int i = 0; i < numLenBytes; i++)
|
||||
contentLen = (contentLen << 8) | raw[2 + i];
|
||||
|
||||
contentStart = 2 + numLenBytes;
|
||||
}
|
||||
|
||||
if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
|
||||
|
||||
var guidBytes = new byte[16];
|
||||
Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
|
||||
|
||||
try { return new Guid(guidBytes); }
|
||||
catch { return null; }
|
||||
}
|
||||
public static Guid? ParseGuidFromExtension(byte[] raw) =>
|
||||
MsDeviceCertificateOids.TryParseGuid(raw, out var value) ? value : (Guid?)null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <b>Retry contract (V1 <c>MonitoringService.RegisterSessionAsync</c>):</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>5 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s).</item>
|
||||
/// <item>Boot-time NIC grace before the first attempt: when no network link is up yet
|
||||
/// (BootTrigger relaunch after a mid-enrollment reboot, Wi-Fi still associating) wait
|
||||
/// up to <see cref="NetworkLinkWaitMax"/> for the link-level signal. Best-effort and
|
||||
/// free on the normal path — a live link returns immediately.</item>
|
||||
/// <item>6 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s, 32s —
|
||||
/// ~62 s of backoff). The original V1 budget of 5 attempts / ~30 s was too short for the
|
||||
/// reboot-relaunch on Wi-Fi kiosks: the relaunched agent gave up and exited 7 before
|
||||
/// the network came back, leaving the session silent forever (tenant aebdce78,
|
||||
/// 2026-08-23 audit). A relaunch that misses here is lost — nothing retries later.</item>
|
||||
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
|
||||
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
|
||||
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
|
||||
@@ -37,16 +45,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public static class SessionRegistrationHelper
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
private const int MaxAttempts = 6;
|
||||
|
||||
/// <summary>Upper bound for the pre-registration link wait (see class remarks).</summary>
|
||||
internal static readonly TimeSpan NetworkLinkWaitMax = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the 5-retry register-session handshake against the backend. Returns the
|
||||
/// Executes the retrying register-session handshake against the backend. Returns the
|
||||
/// response + classified outcome. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="backoffDelay">
|
||||
/// Optional delay-provider used by tests to avoid real-time waits. Production callers
|
||||
/// pass <c>null</c> → V1 parity <c>Task.Delay(2^attempt * 1000)</c> between attempts.
|
||||
/// </param>
|
||||
/// <param name="networkLinkWait">
|
||||
/// Optional pre-registration link wait used by tests. Production callers pass
|
||||
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
|
||||
/// </param>
|
||||
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
|
||||
BackendApiClient apiClient,
|
||||
AgentConfiguration agentConfig,
|
||||
@@ -56,14 +71,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
EmergencyReporter emergencyReporter = null,
|
||||
Func<int, Task> backoffDelay = null,
|
||||
Func<Exception, Task> onTerminalTransportFailure = null,
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null)
|
||||
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
|
||||
Func<AgentLogger, Task> networkLinkWait = null)
|
||||
{
|
||||
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
|
||||
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
backoffDelay ??= DefaultBackoffDelay;
|
||||
networkLinkWait ??= WaitForNetworkLinkAsync;
|
||||
var registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
|
||||
try { await networkLinkWait(logger).ConfigureAwait(false); }
|
||||
catch (Exception ex) { logger.Debug($"Pre-registration network wait failed ({ex.Message}) — registering anyway."); }
|
||||
string lastError = null;
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -113,7 +133,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
|
||||
if (attempt < MaxAttempts)
|
||||
{
|
||||
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16
|
||||
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16, 32
|
||||
logger.Info($"Retrying session registration in {delaySeconds}s");
|
||||
await backoffDelay(attempt).ConfigureAwait(false);
|
||||
}
|
||||
@@ -143,6 +163,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static Task DefaultBackoffDelay(int attempt)
|
||||
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time NIC grace before the first registration attempt — shared polling loop in
|
||||
/// <see cref="NetworkLinkWait"/>, bounded by <see cref="NetworkLinkWaitMax"/>. Probe
|
||||
/// errors end the wait, never the registration.
|
||||
/// </summary>
|
||||
internal static Task WaitForNetworkLinkAsync(AgentLogger logger)
|
||||
=> NetworkLinkWait.WaitAsync(logger, NetworkLinkWaitMax, "Session registration");
|
||||
|
||||
private static SessionRegistration BuildRegistration(
|
||||
AgentConfiguration agentConfig,
|
||||
string agentVersion,
|
||||
@@ -194,7 +222,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>401/403 from the backend — cert/token rejected. No retry was attempted.</summary>
|
||||
AuthFailed = 1,
|
||||
|
||||
/// <summary>All 5 attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
|
||||
Failed = 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +274,15 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
return registration.ExitCode;
|
||||
}
|
||||
|
||||
// Phase 6.5 (config self-heal): when the Phase 4 fetch ran before the network link
|
||||
// was up, registration has just proven the backend reachable — retry the fetch once
|
||||
// so the session runs on the live tenant config instead of cache/defaults.
|
||||
// See AgentRuntimeConfig.TryRecoverAfterRegistration for what is deliberately
|
||||
// NOT re-run here (kill-signal check, endpoint migration).
|
||||
Runtime.AgentRuntimeConfig.TryRecoverAfterRegistration(
|
||||
bootstrap.AgentConfig, auth, runtimeConfig, GetAgentVersion(), consoleMode, logger);
|
||||
|
||||
// Phase 7+8 (orchestrator + shutdown lifecycle + onIngressReady wiring +
|
||||
// initial signal posts + analyzer/probes startup + shutdown.Wait + finally)
|
||||
// is encapsulated in AgentRuntimeHost. See Runtime/AgentRuntimeHost.cs.
|
||||
|
||||
@@ -69,6 +69,63 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
var configMergeResult = ApplyFetchedConfig(agentConfig, remoteConfig, auth, agentVersion, consoleMode, logger);
|
||||
|
||||
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
|
||||
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
|
||||
/// registration has just proven the backend reachable. Retry the fetch once and apply
|
||||
/// the live tenant config instead of stranding the whole session on cache/defaults.
|
||||
/// <para>
|
||||
/// Deliberately NOT re-run here: the kill-signal check (post-registration kills arrive
|
||||
/// via the telemetry-channel control signals on every upload response) and endpoint
|
||||
/// migration (registration already succeeded against the current base URL; a migration
|
||||
/// signal is honoured at the next agent start). Never throws — recovery is strictly
|
||||
/// best-effort on top of an already-degraded-but-working fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void TryRecoverAfterRegistration(
|
||||
AgentConfiguration agentConfig,
|
||||
BackendAuthBundle auth,
|
||||
RuntimeConfigBundle bundle,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = bundle?.RemoteConfigService;
|
||||
if (service == null) return;
|
||||
|
||||
var recovered = service.TryRecoverConfigAsync().GetAwaiter().GetResult();
|
||||
if (recovered == null) return;
|
||||
|
||||
var mergeResult = ApplyFetchedConfig(agentConfig, recovered, auth, agentVersion, consoleMode, logger);
|
||||
bundle.ApplyRecovered(recovered, mergeResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"Post-registration config recovery failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
|
||||
/// and the post-registration recovery path so both apply identical semantics.
|
||||
/// </summary>
|
||||
private static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
BackendAuthBundle auth,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
AgentLogger logger)
|
||||
{
|
||||
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
|
||||
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
|
||||
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
|
||||
@@ -91,7 +148,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
|
||||
|
||||
return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
|
||||
return configMergeResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -175,10 +232,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
internal sealed class RuntimeConfigBundle
|
||||
{
|
||||
public RemoteConfigService RemoteConfigService { get; }
|
||||
public AgentConfigResponse RemoteConfig { get; }
|
||||
public RemoteConfigMergeResult MergeResult { get; }
|
||||
public AgentConfigResponse RemoteConfig { get; private set; }
|
||||
public RemoteConfigMergeResult MergeResult { get; private set; }
|
||||
public string PendingMigrationTarget { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Swaps in the config recovered by <see cref="AgentRuntimeConfig.TryRecoverAfterRegistration"/>
|
||||
/// so Phase 7+8 (orchestrator, collectors, gather rules, IME patterns, the
|
||||
/// unrestricted-mode audit) run on the live tenant config instead of the fallback.
|
||||
/// </summary>
|
||||
public void ApplyRecovered(AgentConfigResponse recoveredConfig, RemoteConfigMergeResult mergeResult)
|
||||
{
|
||||
RemoteConfig = recoveredConfig;
|
||||
MergeResult = mergeResult;
|
||||
}
|
||||
|
||||
public RuntimeConfigBundle(
|
||||
RemoteConfigService remoteConfigService,
|
||||
AgentConfigResponse remoteConfig,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 6 of <see cref="Program"/>'s <c>RunAgent</c>: blocking
|
||||
/// <c>POST /api/agent/register-session</c> with 5-retry exponential backoff before the
|
||||
/// <c>POST /api/agent/register-session</c> with link-wait + 6-retry exponential backoff before the
|
||||
/// orchestrator starts. V1 parity (MonitoringService.RegisterSessionAsync) — without
|
||||
/// this call the backend's Sessions table never gets a row for this session, so
|
||||
/// <c>IncrementSessionEventCountAsync</c> / <c>UpdateSessionStatusAsync</c> silently
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// prior to this, ConfigVersion=0 was only deducible by inferring from
|
||||
// the absence of tenant-controlled knobs in the DataJson.
|
||||
var outcome = remoteConfigService?.LastFetchOutcome ?? RemoteConfigFetchOutcome.NotAttempted;
|
||||
var recovered = remoteConfigService?.RecoveredAfterFetchFailure == true;
|
||||
var configVersion = remoteConfigService?.CurrentConfig?.ConfigVersion ?? 0;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
@@ -66,8 +67,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
|
||||
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
|
||||
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
|
||||
// remoteConfigFetched answers "is the agent running on a live-fetched
|
||||
// config" — true also when the post-registration recovery replaced a
|
||||
// failed startup fetch (remoteConfigOutcome then still names the startup
|
||||
// failure; remoteConfigRecovered marks the heal).
|
||||
{ "configVersion", configVersion },
|
||||
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded },
|
||||
{ "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded || recovered },
|
||||
{ "remoteConfigOutcome", outcome.ToString() },
|
||||
// WinRT OOBE state sampled at this start. Together with previousExitType
|
||||
// this makes late starts provable: "completed" on a first_run means OOBE
|
||||
@@ -75,6 +80,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
|
||||
};
|
||||
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
|
||||
data["previousCrashException"] = previousExit.CrashExceptionType;
|
||||
|
||||
@@ -135,12 +143,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
? $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); using cached config."
|
||||
: $"Remote config fetch failed after {remoteConfigService.LastFetchAttempts} attempt(s); running with built-in defaults (ConfigVersion=0).";
|
||||
|
||||
// Post-registration recovery (Phase 6.5) replaced the fallback with a live
|
||||
// fetch before this emitter ran: keep the failure details (the network-late
|
||||
// condition is worth surfacing) but mark the heal and demote to Info — the
|
||||
// session is NOT degraded.
|
||||
var recovered = remoteConfigService.RecoveredAfterFetchFailure;
|
||||
if (recovered)
|
||||
{
|
||||
var recoveredVersion = remoteConfigService.CurrentConfig?.ConfigVersion ?? 0;
|
||||
data["recovered"] = true;
|
||||
data["recoveredConfigVersion"] = recoveredVersion;
|
||||
msg += $" Recovered after session registration (ConfigVersion={recoveredVersion}).";
|
||||
}
|
||||
|
||||
post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = agentConfig.SessionId,
|
||||
TenantId = agentConfig.TenantId,
|
||||
EventType = SharedConstants.EventTypes.RemoteConfigFetchFailed,
|
||||
Severity = EventSeverity.Warning,
|
||||
Severity = recovered ? EventSeverity.Info : EventSeverity.Warning,
|
||||
Source = "Agent",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = msg,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Security;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CERT-TENANT-BINDING-SHADOW — stage 1 coverage.
|
||||
///
|
||||
/// The agent authenticates with the Intune MDM device certificate, whose chain is pinned to
|
||||
/// Microsoft's Intune roots. Those roots are shared by every Intune tenant, so a valid chain alone
|
||||
/// does not prove the caller belongs to the tenant it claims. The CA stamps the Entra tenant id
|
||||
/// into OID 1.2.840.113556.5.14, which lets the backend bind the two.
|
||||
///
|
||||
/// These tests pin three things:
|
||||
/// - the tenant id is actually extracted from a REAL field certificate (not a synthetic fixture),
|
||||
/// - the pure comparison covers every outcome the telemetry can report,
|
||||
/// - shadow mode observes without deciding: a cross-tenant certificate must still pass the
|
||||
/// certificate stage in stage 1, or we would be enforcing before we measured.
|
||||
/// </summary>
|
||||
public class CertTenantBindingTests
|
||||
{
|
||||
private const string DeviceSamplePem = "device-cert-sample.pem";
|
||||
|
||||
/// <summary>
|
||||
/// Tenant stamped into <c>device-cert-sample.pem</c> by the Intune MDM Device CA. The sample is
|
||||
/// a public-key-only cert from an internal test device; the GUID is the test tenant's own id.
|
||||
/// </summary>
|
||||
private static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
|
||||
|
||||
private static string SampleCertBase64()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertTenantBindingTests).Assembly.Location)!;
|
||||
var pem = File.ReadAllText(Path.Combine(assemblyDir, DeviceSamplePem));
|
||||
using var cert = X509Certificate2.CreateFromPem(pem);
|
||||
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- extraction
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_RealIntuneCert_ExtractsEntraTenantId()
|
||||
{
|
||||
// The load-bearing test for the whole feature: if Microsoft ever stops stamping OID
|
||||
// 1.2.840.113556.5.14, or changes its encoding, enforcement would lock every device out.
|
||||
var result = CertificateValidator.ValidateCertificate(SampleCertBase64());
|
||||
|
||||
Assert.True(result.IsValid, $"Real device cert rejected: {result.ErrorMessage}");
|
||||
Assert.Equal(CertTenantIdStatus.Present, result.CertTenantIdStatus);
|
||||
Assert.Equal(SampleCertTenantId, result.CertTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_CachedResult_KeepsTenantIdAndFlagsCacheHit()
|
||||
{
|
||||
// FromCache is what samples the high-volume "Match" telemetry down to ~one line per cert
|
||||
// per cache window, so it has to survive the cache and not corrupt the cached entry.
|
||||
var b64 = SampleCertBase64();
|
||||
|
||||
_ = CertificateValidator.ValidateCertificate(b64);
|
||||
var second = CertificateValidator.ValidateCertificate(b64);
|
||||
var third = CertificateValidator.ValidateCertificate(b64);
|
||||
|
||||
Assert.True(second.FromCache);
|
||||
Assert.Equal(SampleCertTenantId, second.CertTenantId);
|
||||
|
||||
// The cache-hit copy must not have mutated the shared cached instance.
|
||||
Assert.True(third.FromCache);
|
||||
Assert.Equal(SampleCertTenantId, third.CertTenantId);
|
||||
Assert.True(third.IsValid);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- encoding
|
||||
|
||||
[Fact]
|
||||
public void TryParseGuid_PinsRealFieldEncodings()
|
||||
{
|
||||
// Exact bytes lifted from the sample cert. OID 5.14 nests the GUID in an OCTET STRING,
|
||||
// OID 5.4 stores the 16 bytes bare — both shapes must decode, and both are little-endian.
|
||||
var wrapped = Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2a359");
|
||||
var bare = Convert.FromHexString("6701bc076190b743b2430bb6aeda736e");
|
||||
|
||||
Assert.True(MsDeviceCertificateOids.TryParseGuid(wrapped, out var tenantId));
|
||||
Assert.Equal(SampleCertTenantId, tenantId);
|
||||
|
||||
Assert.True(MsDeviceCertificateOids.TryParseGuid(bare, out var deviceId));
|
||||
// The Intune device id is also the certificate's Subject CN.
|
||||
Assert.Equal(Guid.Parse("07bc0167-9061-43b7-b243-0bb6aeda736e"), deviceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGuid_AccountIdOidIsNotTheTenantId()
|
||||
{
|
||||
// Guards the mistake this feature was nearly built on: OID 5.6 carries the Intune ACCOUNT
|
||||
// id, a different GUID. Comparing it against the tenant id would reject every request.
|
||||
var accountIdExt = Convert.FromHexString("04103d85dede74e26b409d0d46b6fe65b440");
|
||||
|
||||
Assert.True(MsDeviceCertificateOids.TryParseGuid(accountIdExt, out var accountId));
|
||||
Assert.NotEqual(SampleCertTenantId, accountId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")] // empty
|
||||
[InlineData("0400")] // OCTET STRING, zero length
|
||||
[InlineData("020102")] // not an OCTET STRING
|
||||
[InlineData("0411afc14db52053604fb5d4821e0cf2a35900")] // wrapper claims 17 bytes
|
||||
[InlineData("0410afc14db52053604fb5d4821e0cf2a3")] // wrapper claims 16, only 15 present
|
||||
public void TryParseGuid_RejectsMalformed(string hex)
|
||||
{
|
||||
Assert.False(MsDeviceCertificateOids.TryParseGuid(Convert.FromHexString(hex), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGuid_AnySixteenBytesDecodeAsBareGuid()
|
||||
{
|
||||
// Documents a deliberate limit rather than a gap: the bare form (OID 5.4) is exactly 16
|
||||
// bytes with no tag, so a 16-byte input is indistinguishable from a truncated wrapper and
|
||||
// is always read as a GUID. Harmless here — the value is only ever compared for equality
|
||||
// against a known tenant id, never trusted as a claim in its own right.
|
||||
Assert.True(MsDeviceCertificateOids.TryParseGuid(
|
||||
Convert.FromHexString("0410afc14db52053604fb5d4821e0cf2"), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseGuid_RejectsNull()
|
||||
{
|
||||
Assert.False(MsDeviceCertificateOids.TryParseGuid(null!, out _));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- comparison
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MatchingTenant_ReturnsMatch()
|
||||
{
|
||||
var outcome = CertTenantBinding.Evaluate(
|
||||
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
|
||||
|
||||
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
|
||||
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MatchingTenant_IsCaseInsensitive()
|
||||
{
|
||||
// Tenant ids travel as strings in headers/routes and casing must not decide access.
|
||||
var outcome = CertTenantBinding.Evaluate(
|
||||
SampleCertTenantId, CertTenantIdStatus.Present, SampleCertTenantId.ToString().ToUpperInvariant());
|
||||
|
||||
Assert.Equal(CertTenantBinding.Outcome.Match, outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ForeignTenantCert_ReturnsMismatch()
|
||||
{
|
||||
// The attack this feature exists for: a valid Intune cert from the attacker's own tenant,
|
||||
// replayed against a victim tenant whose device serial the attacker knows.
|
||||
var outcome = CertTenantBinding.Evaluate(
|
||||
SampleCertTenantId, CertTenantIdStatus.Present, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
Assert.Equal(CertTenantBinding.Outcome.Mismatch, outcome);
|
||||
Assert.True(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CertTenantIdStatus.ExtensionMissing, CertTenantBinding.Outcome.ExtensionMissing)]
|
||||
[InlineData(CertTenantIdStatus.Unparseable, CertTenantBinding.Outcome.Unparseable)]
|
||||
public void Evaluate_UndecodableCert_ReportsWhyWithoutClaimingMismatch(
|
||||
CertTenantIdStatus status, string expected)
|
||||
{
|
||||
// These must stay distinguishable from Mismatch: they measure rollout readiness
|
||||
// (how many field certs can be enforced against), not an attack.
|
||||
var outcome = CertTenantBinding.Evaluate(null, status, SampleCertTenantId.ToString());
|
||||
|
||||
Assert.Equal(expected, outcome);
|
||||
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_PresentButNullGuid_FallsBackToExtensionMissing()
|
||||
{
|
||||
var outcome = CertTenantBinding.Evaluate(null, CertTenantIdStatus.Present, SampleCertTenantId.ToString());
|
||||
|
||||
Assert.Equal(CertTenantBinding.Outcome.ExtensionMissing, outcome);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-guid")]
|
||||
public void Evaluate_NonGuidRequestTenant_IsReportedSeparately(string? requestedTenantId)
|
||||
{
|
||||
var outcome = CertTenantBinding.Evaluate(
|
||||
SampleCertTenantId, CertTenantIdStatus.Present, requestedTenantId);
|
||||
|
||||
Assert.Equal(CertTenantBinding.Outcome.RequestTenantNotAGuid, outcome);
|
||||
Assert.False(CertTenantBinding.WouldRejectUnderEnforcement(outcome));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shadow mode
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequestAsync_ForeignTenantCert_IsObservedButNotRejected()
|
||||
{
|
||||
// Stage 1 contract: a cross-tenant certificate is logged and still passes the certificate
|
||||
// stage. If this test ever fails, enforcement was switched on without the telemetry review.
|
||||
var logger = new CapturingLogger();
|
||||
var validator = BuildValidator(logger);
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
BuildRequestWithCert(SampleCertBase64()),
|
||||
tenantId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
|
||||
// Deliberately accepted: stage 1 measures, it does not enforce. This assertion is the one
|
||||
// that must be inverted (to a 403) when stage 2 turns enforcement on.
|
||||
Assert.True(result.IsValid, $"Shadow mode changed the outcome: {result.ErrorMessage} / {result.Details}");
|
||||
Assert.NotEqual(HttpStatusCode.Unauthorized, result.StatusCode);
|
||||
|
||||
var observed = Assert.Single(logger.Entries, e => e.Message.Contains("AgentCertTenantBinding"));
|
||||
Assert.Equal(LogLevel.Warning, observed.Level);
|
||||
Assert.Contains(CertTenantBinding.Outcome.Mismatch, observed.Message);
|
||||
Assert.Contains("enforced=False", observed.Message);
|
||||
Assert.Contains("wouldReject=True", observed.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequestAsync_MatchingTenantCert_IsAccepted()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var validator = BuildValidator(logger);
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
BuildRequestWithCert(SampleCertBase64()),
|
||||
tenantId: SampleCertTenantId.ToString());
|
||||
|
||||
Assert.True(result.IsValid, $"Matching-tenant request rejected: {result.ErrorMessage}");
|
||||
|
||||
// Match is only logged on a fresh validation; the cache is process-wide and shared with the
|
||||
// other tests here, so assert the outcome only when a line was actually emitted.
|
||||
var observed = logger.Entries.FirstOrDefault(e => e.Message.Contains("AgentCertTenantBinding"));
|
||||
if (observed != null)
|
||||
{
|
||||
Assert.Equal(LogLevel.Information, observed.Level);
|
||||
Assert.Contains(CertTenantBinding.Outcome.Match, observed.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
private static SecurityValidator BuildValidator(ILogger logger)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
// AllowInsecureAgentRequests clears the "at least one device validator" gate so the request
|
||||
// reaches — and passes — the certificate stage without needing Graph mocks.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
AllowInsecureAgentRequests = true,
|
||||
ManufacturerWhitelist = "*",
|
||||
ModelWhitelist = "*",
|
||||
};
|
||||
|
||||
var configServiceMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
configServiceMock
|
||||
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((config, true));
|
||||
|
||||
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfigServiceMock
|
||||
.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
return new SecurityValidator(
|
||||
configService: configServiceMock.Object,
|
||||
adminConfigService: adminConfigServiceMock.Object,
|
||||
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
logger: logger,
|
||||
bootstrapSessionService: null,
|
||||
deviceAssociationValidator: null);
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequestWithCert(string certBase64)
|
||||
{
|
||||
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
|
||||
var reqMock = new Mock<HttpRequestData>(contextMock.Object);
|
||||
|
||||
var headers = new HttpHeadersCollection
|
||||
{
|
||||
{ "X-ARR-ClientCert", certBase64 },
|
||||
// The hardware whitelist stage runs after the certificate stage and rejects requests
|
||||
// with no hardware headers, which would mask what these tests are actually asserting.
|
||||
{ "X-Device-Manufacturer", "Contoso" },
|
||||
{ "X-Device-Model", "TestBook" },
|
||||
{ "X-Device-SerialNumber", "TESTSERIAL01" },
|
||||
};
|
||||
reqMock.SetupGet(r => r.Headers).Returns(headers);
|
||||
reqMock.SetupGet(r => r.Url).Returns(new Uri("https://example.invalid/api/telemetry"));
|
||||
|
||||
return reqMock.Object;
|
||||
}
|
||||
|
||||
private sealed record LogEntry(LogLevel Level, string Message);
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<LogEntry> Entries { get; } = new();
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TableStorageService.BuildCveIndexEntities"/> collapses report findings to one
|
||||
/// CveIndex entity per CVE. Production reports routinely list the same CVE under many
|
||||
/// findings (one RD CVE across 14 installed RD components), which previously produced
|
||||
/// 14 concurrent upserts racing on the same PK/RK.
|
||||
/// </summary>
|
||||
public class CveIndexEntityMergeTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Session = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static Dictionary<string, object> Finding(string software, string risk, params Dictionary<string, object>[] vulns)
|
||||
=> new()
|
||||
{
|
||||
["softwareName"] = software,
|
||||
["riskLevel"] = risk,
|
||||
["vulnerabilities"] = vulns.ToList(),
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> Vuln(string cve, double score, string severity, bool kev = false)
|
||||
=> new()
|
||||
{
|
||||
["cveId"] = cve,
|
||||
["cvssScore"] = score,
|
||||
["cvssSeverity"] = severity,
|
||||
["isKev"] = kev,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SameCveAcrossManyFindings_CollapsesToOneEntity()
|
||||
{
|
||||
var findings = Enumerable.Range(0, 14)
|
||||
.Select(i => Finding($"RD Component {i}", "high", Vuln("CVE-2025-29966", 8.8, "HIGH")))
|
||||
.ToList();
|
||||
|
||||
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, findings);
|
||||
|
||||
var e = Assert.Single(entities);
|
||||
Assert.Equal($"{Tenant}_CVE-2025-29966", e.PartitionKey);
|
||||
Assert.Equal(Session, e.RowKey);
|
||||
Assert.Equal("RD Component 0", e.GetString("SoftwareName")); // tie keeps report order
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_HighestScoreWins_KevOrs_RiskMaxes()
|
||||
{
|
||||
var findings = new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "medium", Vuln("CVE-1", 5.0, "MEDIUM", kev: true)),
|
||||
Finding("App B", "critical", Vuln("CVE-1", 9.1, "CRITICAL")),
|
||||
Finding("App C", "low", Vuln("CVE-1", 3.0, "LOW")),
|
||||
};
|
||||
|
||||
var e = Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
|
||||
|
||||
Assert.Equal(9.1, e.GetDouble("CvssScore"));
|
||||
Assert.Equal("CRITICAL", e.GetString("CvssSeverity"));
|
||||
Assert.Equal("App B", e.GetString("SoftwareName"));
|
||||
Assert.True(e.GetBoolean("IsKev"));
|
||||
Assert.Equal("critical", e.GetString("OverallRisk"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistinctCves_StayDistinct_AndInvalidEntriesAreSkipped()
|
||||
{
|
||||
var findings = new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("App A", "high", Vuln("CVE-1", 7.0, "HIGH"), Vuln("CVE-2", 6.0, "MEDIUM")),
|
||||
Finding("App B", "high", new Dictionary<string, object> { ["cvssScore"] = 9.0 }), // no cveId
|
||||
new() { ["softwareName"] = "No vulns" },
|
||||
};
|
||||
|
||||
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, findings);
|
||||
|
||||
Assert.Equal(2, entities.Count);
|
||||
Assert.Contains(entities, x => x.GetString("CveId") == "CVE-1");
|
||||
Assert.Contains(entities, x => x.GetString("CveId") == "CVE-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CveIdComparison_IsCaseInsensitive()
|
||||
{
|
||||
var findings = new List<Dictionary<string, object>>
|
||||
{
|
||||
Finding("A", "high", Vuln("CVE-2024-1", 7.0, "HIGH")),
|
||||
Finding("B", "high", Vuln("cve-2024-1", 7.0, "HIGH")),
|
||||
};
|
||||
|
||||
Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invariants of the built-in diagnostics catalog (<see cref="DiagnosticsBuiltInSections"/>).
|
||||
/// Shared has no test project, so the catalog is pinned here: the agent iterates it to build the
|
||||
/// archive and the backend serves it to the portal, so a malformed entry would either break
|
||||
/// collection on every device or mislead every administrator reading the list.
|
||||
/// </summary>
|
||||
public class DiagnosticsBuiltInSectionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SectionIds_AreUnique()
|
||||
{
|
||||
var ids = DiagnosticsBuiltInSections.All.Select(s => s.Id).ToArray();
|
||||
Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZipFolderAndSourceFolderPairs_AreUnique()
|
||||
{
|
||||
// Two sections may share a ZIP folder (ImeLogs + the bootstrapper evtx) only when they
|
||||
// read from different source folders — the same (zip, source) pair would double-add
|
||||
// every file under duplicate entry names.
|
||||
var pairs = DiagnosticsBuiltInSections.All
|
||||
.Select(s => $"{s.ZipFolder}|{s.SourceFolder}".ToLowerInvariant())
|
||||
.ToArray();
|
||||
Assert.Equal(pairs.Length, pairs.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysSections_KeepHistoricalArchiveLayoutAndOrder()
|
||||
{
|
||||
// Archive-layout lock: these five sections (and their order) are what every diagnostics
|
||||
// ZIP has carried since the PR1-B forensics work; tooling and support habits rely on it.
|
||||
var always = DiagnosticsBuiltInSections.All
|
||||
.Where(s => s.Condition == DiagnosticsSectionCondition.Always)
|
||||
.Select(s => s.Id)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { "AgentLogs", "ImeLogs", "AgentState", "AgentSpool", "AgentMarkers" }, always);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalSections_CarryTheExpectedGate()
|
||||
{
|
||||
foreach (var section in DiagnosticsBuiltInSections.All)
|
||||
{
|
||||
if (section.Id.StartsWith("RealmJoin", StringComparison.Ordinal))
|
||||
Assert.Equal(DiagnosticsSectionCondition.RealmJoinWatcher, section.Condition);
|
||||
if (section.Id == "ImeBootstrapperEventLog")
|
||||
Assert.Equal(DiagnosticsSectionCondition.DevicePreparation, section.Condition);
|
||||
}
|
||||
Assert.Contains(DiagnosticsBuiltInSections.All, s => s.Id == "ImeBootstrapperEventLog");
|
||||
Assert.Equal(5, DiagnosticsBuiltInSections.All.Count(s => s.Condition == DiagnosticsSectionCondition.RealmJoinWatcher));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfileTokenSections_AreNeverAlwaysOn()
|
||||
{
|
||||
// The token resolves only once an interactive user exists; an Always section reading
|
||||
// from the user profile would be skipped on every pre-desktop package and mislead the
|
||||
// portal's "collected by every agent" wording.
|
||||
var tokenSections = DiagnosticsBuiltInSections.All
|
||||
.Where(s => s.SourceFolder.Contains(DiagnosticsBuiltInSections.UserProfileToken, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
Assert.NotEmpty(tokenSections);
|
||||
Assert.All(tokenSections, s => Assert.NotEqual(DiagnosticsSectionCondition.Always, s.Condition));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Patterns_AreFileNamesWithoutPathSeparators()
|
||||
{
|
||||
Assert.All(DiagnosticsBuiltInSections.All, s =>
|
||||
{
|
||||
Assert.NotEmpty(s.Patterns);
|
||||
Assert.All(s.Patterns, p =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(p));
|
||||
Assert.DoesNotContain('\\', p);
|
||||
Assert.DoesNotContain('/', p);
|
||||
});
|
||||
Assert.False(string.IsNullOrWhiteSpace(s.Description));
|
||||
Assert.False(s.ZipFolder.StartsWith("/") || s.ZipFolder.EndsWith("/"));
|
||||
Assert.DoesNotContain('\\', s.ZipFolder);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoBuiltInSection_UsesTheConfiguredPathsZipFolder()
|
||||
{
|
||||
// AdditionalLogs/<folder> is reserved for admin-configured entries.
|
||||
Assert.All(DiagnosticsBuiltInSections.All,
|
||||
s => Assert.False(s.ZipFolder.StartsWith("AdditionalLogs", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
|
||||
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
|
||||
/// </summary>
|
||||
public class DiscordRendererTests
|
||||
{
|
||||
private static JsonElement RenderEmbed(NotificationAlert alert)
|
||||
{
|
||||
var json = new DiscordRenderer().RenderToJson(alert);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var embeds = doc.RootElement.GetProperty("embeds");
|
||||
Assert.Equal(1, embeds.GetArrayLength());
|
||||
return embeds[0];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersSingleEmbed_WithTitleColorFieldsAndLinks()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "Enrollment Succeeded",
|
||||
Summary = "Enrollment Succeeded: DESK-001",
|
||||
Severity = NotificationSeverity.Success,
|
||||
ThemeColor = "00B050",
|
||||
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
|
||||
Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } },
|
||||
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
|
||||
};
|
||||
|
||||
var embed = RenderEmbed(alert);
|
||||
|
||||
Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString());
|
||||
Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32());
|
||||
|
||||
var description = embed.GetProperty("description").GetString()!;
|
||||
Assert.StartsWith("Enrollment Succeeded: DESK-001", description);
|
||||
Assert.Contains("**Details**\nAll apps installed.", description);
|
||||
// Webhooks cannot post buttons — actions become markdown links.
|
||||
Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description);
|
||||
|
||||
var fields = embed.GetProperty("fields");
|
||||
Assert.Equal(1, fields.GetArrayLength());
|
||||
Assert.Equal("Device", fields[0].GetProperty("name").GetString());
|
||||
Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString());
|
||||
Assert.True(fields[0].GetProperty("inline").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Success, 0x2ECC71)]
|
||||
[InlineData(NotificationSeverity.Error, 0xE74C3C)]
|
||||
[InlineData(NotificationSeverity.Warning, 0xF1C40F)]
|
||||
[InlineData(NotificationSeverity.Info, 0x3498DB)]
|
||||
public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected)
|
||||
{
|
||||
foreach (var themeColor in new[] { "", "not-hex" })
|
||||
{
|
||||
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor });
|
||||
Assert.Equal(expected, embed.GetProperty("color").GetInt32());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fields_CappedAt25_AndEmptyFactsSkipped()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T", Summary = "S" };
|
||||
alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" });
|
||||
alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " });
|
||||
for (var i = 0; i < 30; i++)
|
||||
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" });
|
||||
|
||||
var fields = RenderEmbed(alert).GetProperty("fields");
|
||||
|
||||
Assert.Equal(25, fields.GetArrayLength());
|
||||
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldValue_TruncatedTo1024_WithEllipsis()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T" };
|
||||
alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) });
|
||||
|
||||
var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!;
|
||||
|
||||
Assert.Equal(1024, value.Length);
|
||||
Assert.EndsWith("…", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "T", Summary = "S" };
|
||||
for (var i = 0; i < 25; i++)
|
||||
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) });
|
||||
|
||||
var embed = RenderEmbed(alert);
|
||||
var fields = embed.GetProperty("fields");
|
||||
|
||||
var total = embed.GetProperty("title").GetString()!.Length
|
||||
+ embed.GetProperty("description").GetString()!.Length;
|
||||
for (var i = 0; i < fields.GetArrayLength(); i++)
|
||||
total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length;
|
||||
|
||||
Assert.True(fields.GetArrayLength() < 25);
|
||||
Assert.True(total <= 6000);
|
||||
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyDescription_Omitted_AndProviderTypeIsDiscord()
|
||||
{
|
||||
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info });
|
||||
|
||||
Assert.False(embed.TryGetProperty("description", out _));
|
||||
Assert.False(embed.TryGetProperty("fields", out _));
|
||||
Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the provider-neutral <see cref="EmailService"/> (welcome + farewell mails).
|
||||
/// The provider HTTP call is driven through a stub handler, so the Mandrill wire format,
|
||||
/// the result-array interpretation and the fail-soft contract are pinned without network.
|
||||
/// <list type="bullet">
|
||||
/// <item>Neither send method ever throws — not on a missing key, missing recipient,
|
||||
/// HTTP 500, a rejected recipient, nor a malformed body.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>Tracking is off on the wire (trust-page claim: provider receives only the
|
||||
/// recipient address and tenant domain).</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class EmailServiceTests
|
||||
{
|
||||
private const string TenantId = "88888888-8888-8888-8888-888888888888";
|
||||
|
||||
// ----- configuration gates -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var (sut, _, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendPreviewApprovedEmailAsync_EmptyApiKey_NoOps()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
// ----- wire format -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendPreviewApprovedEmailAsync_PostsMandrillPayload_WithTrackingOff()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||||
Assert.Equal(EmailService.DefaultEndpoint, handler.LastRequest.RequestUri!.ToString());
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("md-test-key", root.GetProperty("key").GetString());
|
||||
|
||||
var message = root.GetProperty("message");
|
||||
Assert.Equal(EmailService.DefaultFromAddress, message.GetProperty("from_email").GetString());
|
||||
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
|
||||
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
|
||||
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
|
||||
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
|
||||
Assert.True(message.GetProperty("auto_text").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_opens").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_clicks").GetBoolean());
|
||||
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Information && e.Message.Contains("Preview approval email sent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_UsesFarewellTemplate_AndFarewellTag()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Equal("offboarding-farewell", message.GetProperty("tags")[0].GetString());
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Information && e.Message.Contains("Offboard farewell email sent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfiguredEndpointAndSender_OverrideDefaults()
|
||||
{
|
||||
var (sut, handler, _) = Build(apiKey: "md-test-key", extra: new Dictionary<string, string?>
|
||||
{
|
||||
["Email:Endpoint"] = "https://mail.example.invalid/send",
|
||||
["Email:FromAddress"] = "hello@example.invalid",
|
||||
["Email:FromName"] = "Example Sender",
|
||||
});
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Equal("https://mail.example.invalid/send", handler.LastRequest!.RequestUri!.ToString());
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal("hello@example.invalid", message.GetProperty("from_email").GetString());
|
||||
Assert.Equal("Example Sender", message.GetProperty("from_name").GetString());
|
||||
}
|
||||
|
||||
// ----- provider responses / fail-soft -----
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderHttpError_DoesNotThrow_AndLogsWarning_WithoutSuccessLine()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.InternalServerError,
|
||||
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderRejectsRecipient_LogsWarningWithReason_WithoutSuccessLine()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sent")]
|
||||
[InlineData("queued")]
|
||||
[InlineData("scheduled")]
|
||||
public async Task ProviderAcceptedStatuses_CountAsSuccess(string status)
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedProviderBody_DoesNotThrow_AndLogsWarning()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK, "not json"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransportException_DoesNotThrow_AndLogsWarning()
|
||||
{
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => throw new HttpRequestException("simulated provider outage"));
|
||||
|
||||
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to send welcome mail"));
|
||||
}
|
||||
|
||||
// ----- templates -----
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
// ----- harness -----
|
||||
|
||||
private static HttpResponseMessage Json(HttpStatusCode status, string body)
|
||||
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
|
||||
|
||||
private static (EmailService sut, StubHandler handler, CapturingLogger<EmailService> logger) Build(
|
||||
string apiKey,
|
||||
Dictionary<string, string?>? extra = null,
|
||||
System.Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
|
||||
{
|
||||
var settings = new Dictionary<string, string?> { ["Email:ApiKey"] = apiKey };
|
||||
if (extra is not null)
|
||||
foreach (var kv in extra) settings[kv.Key] = kv.Value;
|
||||
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var handler = new StubHandler();
|
||||
if (responder is not null) handler.Responder = responder;
|
||||
var logger = new CapturingLogger<EmailService>();
|
||||
var templates = new EmailTemplateService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<EmailTemplateService>.Instance);
|
||||
var sut = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, logger);
|
||||
return (sut, handler, logger);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastBody { get; private set; }
|
||||
public System.Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
|
||||
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequest = request;
|
||||
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Responder(request);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
|
||||
System.Func<TState, System.Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the operator-override layer over the built-in transactional templates:
|
||||
/// override wins when stored, built-in otherwise; placeholder rendering matches the
|
||||
/// built-ins' empty-domain fallback; the send path is fail-soft on storage errors while the
|
||||
/// admin path is not; save validates and invalidates the cache; reset is idempotent.
|
||||
/// </summary>
|
||||
public sealed class EmailTemplateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NoOverride_ReturnsBuiltIn_Rendered()
|
||||
{
|
||||
var (sut, _) = Build();
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
|
||||
|
||||
Assert.Equal(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"), html);
|
||||
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_WinsAndRendersPlaceholder()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell"))
|
||||
.ReturnsAsync(new EmailTemplateOverride { Kind = "farewell", Html = "<p>Bye {{domainName}}</p>" });
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid");
|
||||
|
||||
Assert.Equal("<p>Bye contoso.invalid</p>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_EmptyDomain_FallsBackToGenericLabel_LikeBuiltIn()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome"))
|
||||
.ReturnsAsync(new EmailTemplateOverride { Kind = "welcome", Html = "Hi {{domainName}}" });
|
||||
|
||||
Assert.Equal("Hi your organization", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageError_SendPath_FallsBackToBuiltIn_AdminPath_Throws()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new System.InvalidOperationException("storage down"));
|
||||
|
||||
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
|
||||
await Assert.ThrowsAsync<System.InvalidOperationException>(() => sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_Validates_ThenInvalidatesCache()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync((EmailTemplateOverride?)null);
|
||||
|
||||
// warm the cache with "no override"
|
||||
Assert.Null(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
|
||||
await Assert.ThrowsAsync<System.ArgumentException>(() => sut.SaveOverrideAsync(EmailTemplateKind.Welcome, " ", "ga@contoso.invalid"));
|
||||
await Assert.ThrowsAsync<System.ArgumentException>(() =>
|
||||
sut.SaveOverrideAsync(EmailTemplateKind.Welcome, new string('x', EmailTemplateService.MaxHtmlLength + 1), "ga@contoso.invalid"));
|
||||
repo.Verify(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()), Times.Never);
|
||||
|
||||
EmailTemplateOverride? stored = null;
|
||||
repo.Setup(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()))
|
||||
.Callback<EmailTemplateOverride>(e => stored = e).Returns(Task.CompletedTask);
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync(() => stored);
|
||||
|
||||
var saved = await sut.SaveOverrideAsync(EmailTemplateKind.Welcome, "<p>{{domainName}}</p>", "ga@contoso.invalid");
|
||||
|
||||
Assert.Equal("welcome", saved.Kind);
|
||||
Assert.Equal("ga@contoso.invalid", saved.UpdatedBy);
|
||||
// cache was invalidated: the next read sees the stored override, not the cached null
|
||||
Assert.NotNull(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
|
||||
Assert.Equal("<p>contoso.invalid</p>", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_ClearsCache_ReturnsToBuiltIn()
|
||||
{
|
||||
var (sut, repo) = Build();
|
||||
var stored = new EmailTemplateOverride { Kind = "farewell", Html = "custom {{domainName}}" };
|
||||
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell")).ReturnsAsync(() => stored);
|
||||
Assert.Equal("custom contoso.invalid", await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
|
||||
|
||||
repo.Setup(r => r.DeleteEmailTemplateOverrideAsync("farewell")).Callback(() => stored = null!).Returns(Task.CompletedTask);
|
||||
await sut.DeleteOverrideAsync(EmailTemplateKind.Farewell, "ga@contoso.invalid");
|
||||
|
||||
Assert.Equal(EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"),
|
||||
await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("welcome", true)]
|
||||
[InlineData("Farewell", true)]
|
||||
[InlineData("invoice", false)]
|
||||
[InlineData(null, false)]
|
||||
public void TryParseKind_AcceptsOnlyKnownKinds(string? value, bool expected)
|
||||
=> Assert.Equal(expected, EmailTemplateService.TryParseKind(value, out _));
|
||||
|
||||
[Fact]
|
||||
public void BuiltInRaw_ContainsPlaceholder_ForBothKinds()
|
||||
{
|
||||
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Welcome));
|
||||
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Farewell));
|
||||
}
|
||||
|
||||
private static (EmailTemplateService sut, Mock<IConfigRepository> repo) Build()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>(MockBehavior.Loose);
|
||||
var sut = new EmailTemplateService(repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<EmailTemplateService>.Instance);
|
||||
return (sut, repo);
|
||||
}
|
||||
}
|
||||
+15
@@ -191,6 +191,21 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The built-in diagnostics catalog + global paths are platform-wide, secret-free data that
|
||||
/// every settings viewer needs (what is collected from the tenant's devices). Member tier,
|
||||
/// JWT-scoped — an upgrade to a global tier would hide it from tenant admins again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DiagnosticsPaths_IsMemberReadJwtScoped()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/diagnostics/paths");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.MemberRead, entry.Policy);
|
||||
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregistered routes must return null (fail-closed).
|
||||
/// </summary>
|
||||
|
||||
@@ -163,6 +163,12 @@ public class EnrollmentTimeoutClassifierTests
|
||||
// -------- ClassifyTimedOutSession --------
|
||||
|
||||
private static (SessionStatus, string) Classify(IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 72)
|
||||
{
|
||||
var (status, reason, _) = ClassifyWithRule(events, hoursSinceStart, grace);
|
||||
return (status, reason);
|
||||
}
|
||||
|
||||
private static (SessionStatus, string, string) ClassifyWithRule(IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 72)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var now = Start.AddHours(hoursSinceStart);
|
||||
@@ -220,7 +226,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Esp(DeviceSetup44), Esp(AccountSetup15),
|
||||
Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
|
||||
});
|
||||
var (status, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, now, graceHours: 72, lastEventAtUtc: lastEvent);
|
||||
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
@@ -471,4 +477,230 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.NotEqual(SessionStatus.Failed, status);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
|
||||
|
||||
private static readonly DateTime Resumed = Start.AddMinutes(20);
|
||||
|
||||
private static (SessionStatus, string) ClassifyWhiteGlove(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var now = Start.AddHours(hoursSinceStart);
|
||||
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
|
||||
return (status, reason);
|
||||
}
|
||||
|
||||
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
private static EnrollmentEvent[] WgParkedEvents() => new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
|
||||
{
|
||||
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
|
||||
Assert.True(wgOnly.HasTerminalComplete);
|
||||
Assert.False(wgOnly.HasEnrollmentComplete);
|
||||
|
||||
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
|
||||
Assert.True(real.HasTerminalComplete);
|
||||
Assert.True(real.HasEnrollmentComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
|
||||
{
|
||||
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
|
||||
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
|
||||
|
||||
// Powered off before Device Setup ever provisioned → not the parking state.
|
||||
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
|
||||
{
|
||||
foreach (var evidence in new[]
|
||||
{
|
||||
Esp(AccountSetup15), // Account Setup progressed → user was there
|
||||
Evt("desktop_arrived"), // real-user desktop
|
||||
Evt("hello_provisioning_completed"), // positive Hello terminal
|
||||
Evt("enrollment_complete"), // real completion
|
||||
Evt("enrollment_failed"), // explicit failure
|
||||
})
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The fairstone shape: technician powers the device off at the logon screen minutes
|
||||
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
Assert.Contains("sealed or powered off awaiting the end user", reason);
|
||||
Assert.Contains("(Account Setup 0/5)", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
|
||||
{
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
|
||||
Assert.DoesNotContain("Account Setup completed", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
|
||||
{
|
||||
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
|
||||
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
|
||||
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
|
||||
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
|
||||
var events = new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
};
|
||||
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
|
||||
|
||||
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Incomplete, pastGrace);
|
||||
Assert.Contains("1/5", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
|
||||
{
|
||||
// Both Classic completion prerequisites in → the user provably finished; the
|
||||
// user-completed reconcile (rule 4) owns this, not the parking gate.
|
||||
var (status, reason) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("user completed setup", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
|
||||
{
|
||||
var (status, _) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
|
||||
{
|
||||
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
|
||||
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
});
|
||||
var (status, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
// -------- Self-deploying profile gate (kiosk tenant aebdce78, audit 2026-08-23) --------
|
||||
|
||||
private static (SessionStatus, string) ClassifySelfDeploying(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 3, int grace = 51)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(hoursSinceStart), grace,
|
||||
lastEventAtUtc: Start.AddMinutes(4), isSelfDeployingProfile: true);
|
||||
return (status, reason);
|
||||
}
|
||||
|
||||
// Session 195593e2 replay: DeviceSetup 4/4 (fallback-confirmed), AccountSetup registry
|
||||
// at 0/5 (the IME false positive — user ESP never runs on this profile), Hello disabled,
|
||||
// SkipUser=True, then silence after the post-ESP reboot. No user will ever sign in.
|
||||
private static readonly EnrollmentEvent[] KioskSilentStream =
|
||||
{
|
||||
Esp(DeviceSetup44), Esp(AccountSetup05), Esp(DeviceSetupFallback),
|
||||
HelloPolicy(enabled: false), EspConfig(skipUser: true),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsSelfDeployingProvisioned_requires_flag_device_setup_and_no_failure()
|
||||
{
|
||||
var provisioned = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
|
||||
Assert.True(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: true));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: false));
|
||||
|
||||
var beforeDeviceSetup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(AccountSetup05) });
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(beforeDeviceSetup, isSelfDeployingProfile: true));
|
||||
|
||||
var failed = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(failed, isSelfDeployingProfile: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_within_grace()
|
||||
{
|
||||
var (status, reason) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 3);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("self-deploying profile", reason);
|
||||
Assert.Contains("no user / Account Setup phase", reason);
|
||||
Assert.Contains("Agent last reported 2026-07-06 15:04 UTC", reason);
|
||||
Assert.DoesNotContain("Account Setup completed", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_past_grace()
|
||||
{
|
||||
// Grace is a user-phase concept; it does not apply when there is no user phase.
|
||||
var (status, _) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts()
|
||||
{
|
||||
// Guard against the gate leaking into user-driven sessions: the identical event shape
|
||||
// on a user-driven profile is still "awaiting user" within grace / Incomplete past it.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
|
||||
var (within, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(3), 51);
|
||||
var (past, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(60), 51);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, within);
|
||||
Assert.Equal(SessionStatus.Incomplete, past);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_explicit_failure_stays_Failed()
|
||||
{
|
||||
var (status, _) = ClassifySelfDeploying(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
|
||||
Assert.Equal(SessionStatus.Failed, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_self_deploying_silent_before_device_setup_is_Incomplete()
|
||||
{
|
||||
// The profile flag alone proves nothing — Device ESP must have finished.
|
||||
var (status, _) = ClassifySelfDeploying(new[] { Esp(AccountSetup05), Evt("agent_started") });
|
||||
Assert.Equal(SessionStatus.Incomplete, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -32,6 +33,44 @@ public class GetAgentConfigFunctionTests
|
||||
Assert.Equal(expectedMajor, actual);
|
||||
}
|
||||
|
||||
// ── MergeDiagnosticsLogPaths — global ∪ tenant without duplicate ZIP entries ──────────
|
||||
|
||||
private static DiagnosticsLogPath P(string path, string description = "") =>
|
||||
new() { Path = path, Description = description };
|
||||
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_GlobalFirst_OrderPreserved_DuplicatesDroppedCaseInsensitively()
|
||||
{
|
||||
var global = new[] { P(@"C:\Windows\Panther\setupact.log", "global"), P(@"C:\Windows\Logs\DISM\dism.log") };
|
||||
var tenant = new[]
|
||||
{
|
||||
P(@"c:\windows\panther\SETUPACT.LOG", "tenant copy"), // same path, different case → dropped
|
||||
P(@" C:\Windows\Logs\DISM\dism.log "), // trimmed duplicate → dropped
|
||||
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
|
||||
};
|
||||
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
@"C:\Windows\Panther\setupact.log",
|
||||
@"C:\Windows\Logs\DISM\dism.log",
|
||||
@"C:\Windows\SetupDiag\*.log",
|
||||
}, merged.Select(p => p.Path));
|
||||
Assert.Equal("global", merged[0].Description); // first occurrence (global) wins
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
|
||||
{
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
|
||||
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
|
||||
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
|
||||
|
||||
Assert.Single(merged);
|
||||
Assert.Equal(@"C:\Install\Log\*.log", merged[0].Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// CustomerSas: gated on the per-tenant SAS URL being present.
|
||||
[InlineData("https://acct.blob.core.windows.net/c?sv=...", "CustomerSas", true)]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape of <c>GET /api/diagnostics/paths</c> (<see cref="GetDiagnosticsPathsFunction.BuildPayload"/>).
|
||||
/// The route is MemberRead, so the field set is locked: the payload must stay "what is
|
||||
/// collected" — never grow admin-only config.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsPathsPayloadTests
|
||||
{
|
||||
// Mirrors the worker's serializer (Program.cs: PropertyNamingPolicy = CamelCase) so the
|
||||
// nested DiagnosticsLogPath class serializes the way the portal actually receives it.
|
||||
private static readonly JsonSerializerOptions WorkerJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private static JsonElement Serialize(AdminConfiguration config)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetDiagnosticsPathsFunction.BuildPayload(config), WorkerJson);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_HasExactlyBuiltInAndGlobalPaths()
|
||||
{
|
||||
var element = Serialize(new AdminConfiguration());
|
||||
|
||||
var fieldNames = element.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
Assert.Equal(new[] { "builtIn", "globalPaths" }, fieldNames);
|
||||
Assert.Equal(JsonValueKind.Array, element.GetProperty("globalPaths").ValueKind);
|
||||
Assert.Equal(0, element.GetProperty("globalPaths").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltIn_MirrorsTheCatalog_WithConditionAsName()
|
||||
{
|
||||
var builtIn = Serialize(new AdminConfiguration()).GetProperty("builtIn");
|
||||
|
||||
Assert.Equal(DiagnosticsBuiltInSections.All.Count, builtIn.GetArrayLength());
|
||||
var first = builtIn[0];
|
||||
Assert.Equal("AgentLogs", first.GetProperty("id").GetString());
|
||||
Assert.Equal("AgentLogs", first.GetProperty("zipFolder").GetString());
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.LogDirectory, first.GetProperty("sourceFolder").GetString());
|
||||
Assert.Equal(DiagnosticsBuiltInSections.LogFilePatterns.Length, first.GetProperty("patterns").GetArrayLength());
|
||||
Assert.False(first.GetProperty("includeSubfolders").GetBoolean());
|
||||
Assert.Equal("Always", first.GetProperty("condition").GetString());
|
||||
|
||||
var conditions = builtIn.EnumerateArray().Select(s => s.GetProperty("condition").GetString()).ToArray();
|
||||
Assert.Contains("RealmJoinWatcher", conditions);
|
||||
Assert.Contains("DevicePreparation", conditions);
|
||||
Assert.All(conditions, c => Assert.False(int.TryParse(c, out _), "condition must serialize as the enum name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalPaths_RoundTripTheStoredJson()
|
||||
{
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
DiagnosticsGlobalLogPathsJson =
|
||||
"[{\"path\":\"C:\\\\Windows\\\\Panther\\\\setupact.log\",\"description\":\"Setup\",\"isBuiltIn\":true,\"includeSubfolders\":false}]",
|
||||
};
|
||||
|
||||
var globalPaths = Serialize(config).GetProperty("globalPaths");
|
||||
|
||||
Assert.Equal(1, globalPaths.GetArrayLength());
|
||||
Assert.Equal(@"C:\Windows\Panther\setupact.log", globalPaths[0].GetProperty("path").GetString());
|
||||
Assert.Equal("Setup", globalPaths[0].GetProperty("description").GetString());
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,13 @@ public class GraphFeatureCatalogSyncTests
|
||||
.Select(m => m.Groups["f"].Value)
|
||||
.ToArray();
|
||||
|
||||
// 'All' is a script-side meta-value that expands to every catalog feature — it is not
|
||||
// (and must never become) a GraphFeatureCatalog entry, but the script must keep offering it.
|
||||
Assert.Contains("All", scriptFeatures);
|
||||
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
||||
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
scriptFeatures.Where(f => f != "All").OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ReadGrantScript()
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that the ONE projected window scan the maintenance tick shares across its rolling
|
||||
/// sweeps (<c>GetMaintenanceWindowSessionsAsync</c> / <c>MaintenanceSweepSessionProjection</c>)
|
||||
/// carries every field the time-attribution, device-journey and verdict-calibration sweeps
|
||||
/// read. In Azure Table Storage a <c>$select</c> returns ONLY the listed properties, so the
|
||||
/// projected row here is built from the production array — dropping a column there fails the
|
||||
/// sweep that consumes it. Mirrors <see cref="UsageMetricsProjectionEquivalenceTests"/>.
|
||||
/// </summary>
|
||||
public class MaintenanceSweepProjectionEquivalenceTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-000000000abc";
|
||||
private static readonly DateTime Started = new(2026, 8, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly TableStorageService Sut =
|
||||
new(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
|
||||
|
||||
/// <summary>Full wide Sessions row with every column the sweeps read set to a distinct non-default value, plus noise.</summary>
|
||||
private static TableEntity FullRow() => new(TenantId, "11111111-1111-1111-1111-111111111111")
|
||||
{
|
||||
["Status"] = "Succeeded",
|
||||
["StartedAt"] = new DateTimeOffset(Started),
|
||||
["CompletedAt"] = new DateTimeOffset(Started.AddHours(1)),
|
||||
["LastEventAt"] = new DateTimeOffset(Started.AddHours(2)),
|
||||
["DurationSeconds"] = 3600,
|
||||
["EventCount"] = 321,
|
||||
["DeletionState"] = "None",
|
||||
["SerialNumber"] = "SN-1234",
|
||||
["Manufacturer"] = "Contoso",
|
||||
["Model"] = "Model-X",
|
||||
["EnrollmentType"] = "v2",
|
||||
["IsPreProvisioned"] = true,
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
["IsUserDriven"] = false,
|
||||
["ResumedAt"] = new DateTimeOffset(Started.AddMinutes(30)),
|
||||
["AdminMarkedAction"] = "Succeeded",
|
||||
["VerdictPath"] = "manual:succeeded",
|
||||
["PriorStatus"] = "Incomplete",
|
||||
["PriorVerdictPath"] = "sweep:r6",
|
||||
["FailureReason"] = "No Device Setup completion or explicit failure signal observed before timeout",
|
||||
["ReconcileReason"] = "Reconciled at timeout: user completed setup (desktop + Windows Hello)",
|
||||
["FailureSource"] = "manual",
|
||||
["EspSoftFailure"] = true,
|
||||
// Noise the sweeps never read — present on the wide row, dropped by the projection.
|
||||
["DeviceName"] = "PC-FULL",
|
||||
["OsName"] = "Windows 11",
|
||||
["OsBuild"] = "26200.1234",
|
||||
["GeoCountry"] = "DE",
|
||||
["AgentVersion"] = "2.9.0",
|
||||
["DiagnosticsBlobName"] = "diag.zip",
|
||||
["FailureSnapshotJson"] = "{\"big\":\"" + new string('x', 2000) + "\"}",
|
||||
};
|
||||
|
||||
private static TableEntity Project(TableEntity full)
|
||||
{
|
||||
var keep = new HashSet<string>(TableStorageService.MaintenanceSweepSessionProjection, StringComparer.Ordinal);
|
||||
var projected = new TableEntity(full.PartitionKey, full.RowKey);
|
||||
foreach (var kv in full)
|
||||
{
|
||||
if (keep.Contains(kv.Key)) projected[kv.Key] = kv.Value;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_drops_the_wide_columns()
|
||||
{
|
||||
var projected = Project(FullRow());
|
||||
Assert.False(projected.ContainsKey("FailureSnapshotJson"));
|
||||
Assert.False(projected.ContainsKey("GeoCountry"));
|
||||
Assert.False(projected.ContainsKey("OsBuild"));
|
||||
Assert.True(projected.Count < FullRow().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_field_the_sweeps_read_survives_the_projection()
|
||||
{
|
||||
var full = Sut.MapToSessionSummary(FullRow());
|
||||
var proj = Sut.MapToSessionSummary(Project(FullRow()));
|
||||
|
||||
// Shared / structural
|
||||
Assert.Equal(full.TenantId, proj.TenantId);
|
||||
Assert.Equal(full.SessionId, proj.SessionId);
|
||||
Assert.Equal(full.Status, proj.Status);
|
||||
Assert.Equal(full.StartedAt, proj.StartedAt);
|
||||
Assert.Equal(full.CompletedAt, proj.CompletedAt);
|
||||
Assert.Equal(full.DeletionState, proj.DeletionState);
|
||||
|
||||
// Time attribution: Status/DurationSeconds/DeletionState/EventCount + enrollment class
|
||||
Assert.Equal(full.DurationSeconds, proj.DurationSeconds);
|
||||
Assert.Equal(full.EventCount, proj.EventCount);
|
||||
Assert.Equal(TimeAttributionCalculator.GetEnrollmentClass(full), TimeAttributionCalculator.GetEnrollmentClass(proj));
|
||||
|
||||
// Device journeys: BuildSessionRef + BuildDeviceHistoryRow display fields
|
||||
Assert.Equal(full.SerialNumber, proj.SerialNumber);
|
||||
Assert.Equal(full.Manufacturer, proj.Manufacturer);
|
||||
Assert.Equal(full.Model, proj.Model);
|
||||
Assert.Equal(full.EnrollmentType, proj.EnrollmentType);
|
||||
Assert.Equal(full.IsPreProvisioned, proj.IsPreProvisioned);
|
||||
Assert.Equal(full.AdminMarkedAction, proj.AdminMarkedAction);
|
||||
var refFull = DeviceJourneyCalculator.BuildSessionRef(full)!;
|
||||
var refProj = DeviceJourneyCalculator.BuildSessionRef(proj)!;
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(refFull), System.Text.Json.JsonSerializer.Serialize(refProj));
|
||||
|
||||
// Verdict calibration: attribution + legacy derivation inputs + proxy timestamps
|
||||
Assert.Equal(full.LastEventAt, proj.LastEventAt);
|
||||
Assert.Equal(full.VerdictPath, proj.VerdictPath);
|
||||
Assert.Equal(full.PriorStatus, proj.PriorStatus);
|
||||
Assert.Equal(full.PriorVerdictPath, proj.PriorVerdictPath);
|
||||
Assert.Equal(full.FailureReason, proj.FailureReason);
|
||||
Assert.Equal(full.ReconcileReason, proj.ReconcileReason);
|
||||
Assert.Equal(full.FailureSource, proj.FailureSource);
|
||||
Assert.Equal(full.EspSoftFailure, proj.EspSoftFailure);
|
||||
Assert.Equal(VerdictPathDerivation.Derive(full), VerdictPathDerivation.Derive(proj));
|
||||
// Unstamped variant exercises the derivation on the projected inputs.
|
||||
var unstampedFull = FullRow(); unstampedFull.Remove("VerdictPath"); unstampedFull.Remove("AdminMarkedAction"); unstampedFull.Remove("FailureSource");
|
||||
Assert.Equal(VerdictPathDerivation.Derive(Sut.MapToSessionSummary(unstampedFull)),
|
||||
VerdictPathDerivation.Derive(Sut.MapToSessionSummary(Project(unstampedFull))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregation_cores_produce_identical_rows_from_the_projected_scan()
|
||||
{
|
||||
var now = new DateTime(2026, 8, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
var fullList = new List<SessionSummary> { Sut.MapToSessionSummary(FullRow()) };
|
||||
var projList = new List<SessionSummary> { Sut.MapToSessionSummary(Project(FullRow())) };
|
||||
var histories = new Dictionary<string, List<DeviceHistory>>();
|
||||
|
||||
var a = MaintenanceService.BuildVerdictCalibrationAggregates(fullList, histories, now);
|
||||
var b = MaintenanceService.BuildVerdictCalibrationAggregates(projList, histories, now);
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(a), System.Text.Json.JsonSerializer.Serialize(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SliceSweepWindow_keeps_sessions_from_the_window_start()
|
||||
{
|
||||
var window = new List<SessionSummary>
|
||||
{
|
||||
new() { SessionId = "old", StartedAt = Started.AddDays(-40) },
|
||||
new() { SessionId = "edge", StartedAt = Started },
|
||||
new() { SessionId = "new", StartedAt = Started.AddDays(1) },
|
||||
};
|
||||
var sliced = MaintenanceService.SliceSweepWindow(window, Started);
|
||||
Assert.Equal(new[] { "edge", "new" }, sliced.Select(s => s.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_window_covers_every_sweeps_horizon()
|
||||
{
|
||||
// 7d window + 28d baseline anchored on yesterday = 35 days back ≥ the 30-day rolling sweeps.
|
||||
Assert.Equal(35, MaintenanceService.SweepWindowDays);
|
||||
Assert.True(MaintenanceService.SweepWindowDays >= 30);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
@@ -64,6 +65,17 @@ public class NotificationChannelsTests
|
||||
Assert.Empty(slack.GetCustomHeaders());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSigningSecret_OnlyForGenericJson()
|
||||
{
|
||||
var secret = "0123456789abcdef";
|
||||
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
|
||||
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
|
||||
}
|
||||
|
||||
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -141,7 +153,7 @@ public class NotificationChannelsTests
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
NotificationChannelsJson = "[" +
|
||||
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," +
|
||||
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
|
||||
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
|
||||
};
|
||||
|
||||
@@ -151,8 +163,10 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("ch-1", redacted[0].Id); // structure visible
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
|
||||
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
|
||||
Assert.Null(redacted[1].SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -169,7 +183,7 @@ public class NotificationChannelsTests
|
||||
{
|
||||
NotificationChannelsJson = "[" +
|
||||
ChannelJson("ch-1", url: "https://real.example/hook",
|
||||
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]",
|
||||
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
|
||||
};
|
||||
|
||||
// Round-trip the redacted view back onto a save (defense-in-depth path).
|
||||
@@ -179,6 +193,7 @@ public class NotificationChannelsTests
|
||||
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
|
||||
Assert.Equal("https://real.example/hook", ch.Url);
|
||||
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
|
||||
Assert.Equal("real-secret-0123", ch.SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -244,4 +259,40 @@ public class NotificationChannelsTests
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("headers", error);
|
||||
}
|
||||
|
||||
// ── Signing-secret validation ─────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("0123456789abcdef")] // 16 chars (minimum)
|
||||
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
||||
// 128 chars (maximum) and a typical generated 64-char hex secret
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("short-15-chars.")] // below minimum
|
||||
[InlineData("has a space in the middle!")] // whitespace
|
||||
[InlineData("line\nbreak-0123456789")] // control char
|
||||
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
|
||||
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
|
||||
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
|
||||
{
|
||||
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
|
||||
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("signing secret", error);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
|
||||
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
|
||||
/// without booting the real Resend client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// to simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// </summary>
|
||||
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
|
||||
|
||||
+4
-4
@@ -221,7 +221,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailNull()
|
||||
{
|
||||
// Tenants that never set a preview-notification email get null capture; the handler
|
||||
// skip-gates the send so no Resend call is even attempted.
|
||||
// skip-gates the send so no provider call is even attempted.
|
||||
var harness = Harness.New();
|
||||
Assert.Null(harness.Repo.History[HistoryRowKey].NotificationEmail);
|
||||
|
||||
@@ -235,7 +235,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailWhitespace()
|
||||
{
|
||||
// Defensive: legacy History rows may have a non-null empty/whitespace value. Treat
|
||||
// these as "no email captured" so we never call Resend with a useless string.
|
||||
// these as "no email captured" so we never call the mail provider with a useless string.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = " ";
|
||||
|
||||
@@ -248,11 +248,11 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
[Fact]
|
||||
public async Task PostDrain_FarewellEmail_SenderThrows_HandlerSurvives_HistoryStillCompleted()
|
||||
{
|
||||
// The send is best-effort. A Resend outage / template bug / serializer crash must
|
||||
// The send is best-effort. A mail-provider outage / template bug / serializer crash must
|
||||
// NOT propagate to the queue worker — that would re-poison a Completed offboard.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "ops@contoso.invalid";
|
||||
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated Resend outage");
|
||||
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated mail-provider outage");
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
|
||||
@@ -112,9 +112,11 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate, int maxResults = int.MaxValue, bool excludeInFlightDeletions = false) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetMaintenanceWindowSessionsAsync(DateTime startDate, DateTime endDate) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetGeoWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetLegacyTimeoutFailedSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSelfDeployingSilentSessionsAsync(string tenantId, int maxResults) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsLeanAsync(string tenantId) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetAgentSilentSessionsAsync(string tenantId, DateTime silenceCutoff, DateTime hardCutoff) => throw new NotImplementedException();
|
||||
public Task<List<string>> GetAllTenantIdsAsync() => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PlanManagementFunction.ApplyPlanChanges"/> — the pure mutation core
|
||||
/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE
|
||||
/// Pro→Community transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/>, any
|
||||
/// effectively-Pro outcome clears it, and a planTier downgrade under an active trial does
|
||||
/// NOT stamp (the edition is still Pro — the later trial expiry is the anchor then).
|
||||
/// </summary>
|
||||
public class PlanManagementTransitionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Caller = "ga@operator.example";
|
||||
|
||||
private static (TenantEdition Before, TenantEdition After, Dictionary<string, string> Changes) Apply(
|
||||
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
|
||||
{
|
||||
var changes = new Dictionary<string, string>();
|
||||
var (before, after) = PlanManagementFunction.ApplyPlanChanges(
|
||||
config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
|
||||
return (before, after, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Downgrade_ProToCommunity_StampsAnchorAndRecordsChange()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
|
||||
Assert.True(changes.ContainsKey("PlanTier"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upgrade_CommunityToPro_ClearsAnchor()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-10)
|
||||
};
|
||||
|
||||
var (_, after, changes) = Apply(config, planTier: "pro");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Downgrade_WithActiveTrial_DoesNotStamp_EditionStaysPro()
|
||||
{
|
||||
// planTier drops but the trial keeps the EFFECTIVE edition Pro: no anchor now —
|
||||
// the trial's own expiry timestamp anchors the grace later, read-time.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "pro",
|
||||
TrialExpiresUtc = Now.AddDays(5)
|
||||
};
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.False(changes.ContainsKey("ProDowngradedUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndingTrialExplicitly_StampsAnchor()
|
||||
{
|
||||
// GA sets trialExpiresUtc: null on a trial-Pro tenant — TrialExpiresUtc is gone, so
|
||||
// WITHOUT the explicit stamp the grace would have no anchor at all.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "free",
|
||||
TrialExpiresUtc = Now.AddDays(5)
|
||||
};
|
||||
|
||||
var (before, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: null);
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
Assert.Null(config.TrialExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrantingTrial_ToDowngradedTenant_ClearsAnchor()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-10)
|
||||
};
|
||||
|
||||
var (_, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: Now.AddDays(30));
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, after);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
Assert.Equal(Caller, config.TrialGrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpPatch_SameTier_RecordsNothing()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "community" };
|
||||
|
||||
var (before, after, changes) = Apply(config, planTier: "community");
|
||||
|
||||
Assert.Equal(TenantEdition.Community, before);
|
||||
Assert.Equal(TenantEdition.Community, after);
|
||||
Assert.Empty(changes);
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedDowngradeAfterReUpgrade_RestampsWithNewTimestamp()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
|
||||
|
||||
Apply(config, planTier: "community");
|
||||
Assert.Equal(Now, config.ProDowngradedUtc);
|
||||
|
||||
Apply(config, planTier: "pro");
|
||||
Assert.Null(config.ProDowngradedUtc);
|
||||
|
||||
var changes = new Dictionary<string, string>();
|
||||
var later = Now.AddDays(40);
|
||||
PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes);
|
||||
Assert.Equal(later, config.ProDowngradedUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Concurrent;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Both sliding-window limiters keep the request history and its lock in one cache entry.
|
||||
/// Previously the entry was re-Set without its eviction callback, which (a) leaked per-key lock
|
||||
/// objects and (b) let two threads mutate the same List under different locks. These tests hammer
|
||||
/// one key from many threads: the count of allowed requests must be exactly the limit and nothing
|
||||
/// may throw (a List mutated concurrently throws InvalidOperationException or corrupts Count).
|
||||
/// </summary>
|
||||
public class RateLimitServiceConcurrencyTests
|
||||
{
|
||||
private const int Threads = 16;
|
||||
private const int RequestsPerThread = 500;
|
||||
|
||||
[Fact]
|
||||
public void CheckRateLimit_ManyThreadsSameKey_AllowsExactlyTheLimit()
|
||||
{
|
||||
var sut = new RateLimitService(new MemoryCache(new MemoryCacheOptions()), NullLogger<RateLimitService>.Instance);
|
||||
const int limit = 1000;
|
||||
var allowed = 0;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
RunInParallel(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (sut.CheckRateLimit("thumb-shared", limit).IsAllowed)
|
||||
Interlocked.Increment(ref allowed);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.Equal(limit, allowed);
|
||||
Assert.False(sut.CheckRateLimit("thumb-shared", limit).IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistressCheck_ManyThreadsSameIp_AllowsExactlyTheIpLimit()
|
||||
{
|
||||
var sut = new DistressRateLimitService(new MemoryCache(new MemoryCacheOptions()), NullLogger<DistressRateLimitService>.Instance);
|
||||
var allowed = 0;
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
|
||||
RunInParallel(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (sut.Check("10.0.0.9", "a1b2c3d4-e5f6-7890-abcd-ef1234567890").IsAllowed)
|
||||
Interlocked.Increment(ref allowed);
|
||||
}
|
||||
catch (Exception ex) { errors.Add(ex); }
|
||||
});
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.Equal(5, allowed); // MaxPerIp
|
||||
}
|
||||
|
||||
private static void RunInParallel(Action body)
|
||||
{
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var tasks = Enumerable.Range(0, Threads).Select(_ => Task.Run(() =>
|
||||
{
|
||||
gate.Wait();
|
||||
for (var i = 0; i < RequestsPerThread; i++) body();
|
||||
})).ToArray();
|
||||
gate.Set();
|
||||
Task.WaitAll(tasks);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — every test drives one of the no-op gates. Pins:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
|
||||
/// is empty and not when the recipient is missing. The handler's fail-soft
|
||||
/// try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ResendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["RESEND_API_KEY"] = apiKey,
|
||||
})
|
||||
.Build();
|
||||
return new ResendEmailService(config, logger);
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public readonly List<(LogLevel Level, string Message)> Entries = new();
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
|
||||
System.Func<TState, System.Exception?, string> formatter)
|
||||
{
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,9 @@ public class SessionIndexFieldManifestTests
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
["FailureSnapshotJson"] = "{\"x\":1}",
|
||||
["AdminMarkedAction"] = "failed",
|
||||
["VerdictPath"] = "sweep:r5_incomplete",
|
||||
["PriorStatus"] = "Incomplete",
|
||||
["PriorVerdictPath"] = "maxlife:r6",
|
||||
["DurationSeconds"] = 360,
|
||||
["DiagnosticsBlobName"] = "diag-0001.zip",
|
||||
["DiagnosticsBlobDestination"] = "Hosted",
|
||||
|
||||
@@ -331,8 +331,9 @@ public class SessionRetentionFanoutServiceTests
|
||||
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var deadline = baseTime.AddMinutes(50);
|
||||
var clockCalls = 0;
|
||||
// Call sequence: tenant-A boundary (1), session s0 (2), session s1 (3), session s2 (4 → past deadline).
|
||||
var harness = new Harness(utcNow: () => ++clockCalls >= 4 ? deadline.AddMinutes(1) : baseTime);
|
||||
// Call sequence: tenant-A boundary (1), retention resolution (2), session s0 (3),
|
||||
// session s1 (4), session s2 (5 → past deadline).
|
||||
var harness = new Harness(utcNow: () => ++clockCalls >= 5 ? deadline.AddMinutes(1) : baseTime);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
for (int i = 0; i < 4; i++) sessions.Add(Summary(TenantA, $"s{i}"));
|
||||
@@ -388,6 +389,38 @@ public class SessionRetentionFanoutServiceTests
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================== Retention downgrade grace ====
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_InRetentionGrace_KeepsProCutoff()
|
||||
{
|
||||
// Downgraded 10 days ago, stored retention 365: the grace keeps the Pro cap, so the
|
||||
// cutoff must be now-365d — NOT now-90d (which would enqueue the 90–365d band).
|
||||
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(utcNow: () => now);
|
||||
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
|
||||
planTier: "community", proDowngradedUtc: now.AddDays(-10));
|
||||
|
||||
await harness.RunAsync();
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
"t1", now.AddDays(-365), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_AfterRetentionGrace_CommunityCutoffApplies()
|
||||
{
|
||||
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var harness = new Harness(utcNow: () => now);
|
||||
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
|
||||
planTier: "community", proDowngradedUtc: now.AddDays(-31));
|
||||
|
||||
await harness.RunAsync();
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
"t1", now.AddDays(-90), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private sealed class Harness
|
||||
@@ -450,11 +483,18 @@ public class SessionRetentionFanoutServiceTests
|
||||
return result;
|
||||
}
|
||||
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free",
|
||||
DateTime? proDowngradedUtc = null)
|
||||
{
|
||||
_tenantIds.Add(tenantId);
|
||||
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
|
||||
.ReturnsAsync(new TenantConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DataRetentionDays = retentionDays,
|
||||
PlanTier = planTier,
|
||||
ProDowngradedUtc = proDowngradedUtc,
|
||||
});
|
||||
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
|
||||
}
|
||||
|
||||
+52
-6
@@ -74,7 +74,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: poisoned);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
TenantId, SessionId, SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending,
|
||||
earliestEventTimestamp: poisoned, latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
@@ -93,7 +93,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: earliest);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
TenantId, SessionId, SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
@@ -101,6 +101,52 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
// =========================== system-timeline rows are not session anchors ====
|
||||
|
||||
[Fact]
|
||||
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
|
||||
{
|
||||
Assert.False(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
|
||||
Assert.True(TableStorageService.IsSessionAnchorEligible(
|
||||
new TableEntity("p", "r"))); // legacy row without Source projection
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_skips_backfilled_system_timeline_row_and_anchors_on_activity()
|
||||
{
|
||||
// Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from the
|
||||
// Windows event log, original timestamp ~14min before the first real activity) was
|
||||
// the partition's earliest row and re-anchored StartedAt. The probe must skip
|
||||
// SystemTimelineWatcher rows and anchor on the first activity event instead —
|
||||
// even when the watcher row sits well inside the 2h plausibility window.
|
||||
var clockEventAt = StartedAt.AddMinutes(-90);
|
||||
var firstActivity = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), eventRows: new[]
|
||||
{
|
||||
EventRow(clockEventAt, sequence: 18, source: "SystemTimelineWatcher"),
|
||||
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
|
||||
});
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending, VerdictPaths.AgentWhiteGlovePending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
|
||||
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
|
||||
{
|
||||
["OccurredUtc"] = new DateTimeOffset(occurredUtc),
|
||||
["Source"] = source,
|
||||
};
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private static TableEntity SessionRow()
|
||||
@@ -126,7 +172,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null, TableEntity[]? eventRows = null)
|
||||
{
|
||||
var sessions = new Mock<TableClient>();
|
||||
|
||||
@@ -150,7 +196,7 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
});
|
||||
|
||||
var events = new Mock<TableClient>();
|
||||
var eventRows = earliestStoredEvent.HasValue
|
||||
var servedRows = eventRows ?? (earliestStoredEvent.HasValue
|
||||
? new[]
|
||||
{
|
||||
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
|
||||
@@ -158,12 +204,12 @@ public class SessionStartedAtBackwardShiftGuardTests
|
||||
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
|
||||
},
|
||||
}
|
||||
: Array.Empty<TableEntity>();
|
||||
: Array.Empty<TableEntity>());
|
||||
events.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
|
||||
{
|
||||
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
|
||||
Page<TableEntity>.FromValues(servedRows, null, new Mock<Response>().Object),
|
||||
}));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ANALYZE-DEV-012 — pins the firing behaviour of the built-in that consumes the agent's
|
||||
/// <c>system_sleep_episode</c> events (SystemTimelineWatcherHost): informational, fires once
|
||||
/// (trigger single) when a completed sleep/hibernate/Modern-Standby episode of at least
|
||||
/// 5 minutes is observed. Single-required-condition on durationSeconds so the match is pinned
|
||||
/// to one event instance and the interpolated duration is the matched value. Also pins that the
|
||||
/// agent's numeric payload (long) satisfies the rule's string "300" via numeric gte.
|
||||
/// </summary>
|
||||
public class SleepEpisodeRuleTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "66666666-7777-8888-9999-aaaaaaaaaaaa";
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_012_fires_info_on_a_five_minute_episode()
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("info", rule.Severity);
|
||||
Assert.False(rule.MarkSessionAsFailedDefault); // environment observation, never a KO verdict
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SleepEpisode(durationSeconds: 300, kind: "modern_standby"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-DEV-012", result.RuleId);
|
||||
Assert.Equal("info", result.Severity);
|
||||
|
||||
// durationSeconds is the matched condition's dataField, so {{durationSeconds}} in the
|
||||
// explanation interpolates the matched value (the DEV-009 whitelist lesson).
|
||||
var matched = AsDict(result.MatchedConditions["slept_during_enrollment"]);
|
||||
Assert.Equal("durationSeconds", AsString(matched["field"]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(60)] // real but short episode — below the rule's 5-minute floor
|
||||
[InlineData(299)] // boundary: gte 300 must not fire at 299
|
||||
public async Task ANALYZE_DEV_012_does_not_fire_below_the_five_minute_floor(long durationSeconds)
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
SleepEpisode(durationSeconds, kind: "sleep"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_DEV_012_does_not_fire_on_a_clock_change_event()
|
||||
{
|
||||
// The sibling watcher event carries a large timeDeltaMs but is a different event type —
|
||||
// the rule must key on system_sleep_episode only.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "system_clock_changed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 7,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["timeDeltaMs"] = 7_200_000L,
|
||||
["durationSeconds"] = 900L, // adversarial: same field name on the wrong type
|
||||
["reason"] = 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
// ===== Event builder — mirrors the SystemTimelineTracker emit shape =====
|
||||
|
||||
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "system_sleep_episode",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 42,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = kind,
|
||||
["enteredAt"] = DateTime.UtcNow.AddSeconds(-durationSeconds).ToString("o"),
|
||||
["exitedAt"] = DateTime.UtcNow.ToString("o"),
|
||||
["durationSeconds"] = durationSeconds,
|
||||
["backfilled"] = false,
|
||||
}
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> AsDict(object o)
|
||||
{
|
||||
if (o is Dictionary<string, object> d) return d;
|
||||
throw new InvalidOperationException($"Expected Dictionary<string,object>, got {o?.GetType().Name ?? "null"}");
|
||||
}
|
||||
|
||||
private static string AsString(object o) => o?.ToString() ?? string.Empty;
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<SleepEpisodeRuleTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Azure.Data.Tables.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
public class StartupTelemetryServiceTests
|
||||
{
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
public List<ITelemetry> Items { get; } = new();
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string EndpointAddress { get; set; } = "";
|
||||
public void Send(ITelemetry item) => Items.Add(item);
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class Lifetime : IHostApplicationLifetime
|
||||
{
|
||||
private readonly CancellationTokenSource _started = new();
|
||||
public CancellationToken ApplicationStarted => _started.Token;
|
||||
public CancellationToken ApplicationStopping => CancellationToken.None;
|
||||
public CancellationToken ApplicationStopped => CancellationToken.None;
|
||||
public void StopApplication() { }
|
||||
public void FireStarted() => _started.Cancel();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplicationStarted_EmitsStartupAndTableInitMetrics()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = channel, ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000" });
|
||||
|
||||
// Table storage: unreadable sentinel → full pass → FullPassRan = true.
|
||||
var service = new Mock<TableServiceClient>();
|
||||
var admin = new Mock<TableClient>();
|
||||
service.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(admin.Object);
|
||||
admin.Setup(c => c.GetEntityIfExistsAsync<TableEntity>(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "TableNotFound"));
|
||||
admin.Setup(c => c.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
service.Setup(s => s.CreateTableIfNotExistsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(new TableItem("x"), Mock.Of<Response>()));
|
||||
var storage = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
await storage.InitializeTablesAsync();
|
||||
|
||||
var lifetime = new Lifetime();
|
||||
var sut = new StartupTelemetryService(lifetime, telemetry, storage, new BackendBuildInfo(), NullLogger<StartupTelemetryService>.Instance);
|
||||
await sut.StartAsync(CancellationToken.None);
|
||||
Assert.Empty(channel.Items);
|
||||
|
||||
lifetime.FireStarted();
|
||||
|
||||
var metrics = channel.Items.ConvertAll(i => (MetricTelemetry)i);
|
||||
Assert.Equal(2, metrics.Count);
|
||||
var startup = metrics.Find(m => m.Name == StartupTelemetryService.StartupMetricName)!;
|
||||
var tableInit = metrics.Find(m => m.Name == StartupTelemetryService.TableInitMetricName)!;
|
||||
Assert.True(startup.Sum > 0);
|
||||
Assert.True(tableInit.Sum >= 0);
|
||||
Assert.Equal("True", startup.Properties["tableInitFullPass"]);
|
||||
Assert.False(string.IsNullOrEmpty(startup.Properties["version"]));
|
||||
Assert.True(startup.Sum >= tableInit.Sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-2
@@ -48,11 +48,74 @@ public class StorageDependencyFilterProcessorTests
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedStorageDependency_IsKept()
|
||||
[Theory]
|
||||
[InlineData("429")]
|
||||
[InlineData("500")]
|
||||
[InlineData("503")]
|
||||
[InlineData("403")]
|
||||
[InlineData("")] // unknown outcome (timeout/network) — keep
|
||||
public void FailedStorageDependency_IsKept(string resultCode)
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("myacct.queue.core.windows.net", success: false);
|
||||
dep.ResultCode = resultCode;
|
||||
processor.Process(dep);
|
||||
Assert.Same(dep, Assert.Single(next.Received));
|
||||
}
|
||||
|
||||
// Expected outcomes (point-read miss, ETag precondition, insert conflict) are normal control
|
||||
// flow — live-verified as 100% of the remaining "failed" storage rows. Dropped.
|
||||
[Theory]
|
||||
[InlineData("404")]
|
||||
[InlineData("412")]
|
||||
[InlineData("409")]
|
||||
public void ExpectedStorageOutcome_HttpShape_IsDropped(string resultCode)
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("myacct.table.core.windows.net", success: false);
|
||||
dep.ResultCode = resultCode;
|
||||
processor.Process(dep);
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Azure.RequestFailedException: The specified resource does not exist.\nRequestId:x\nStatus: 404 (Not Found)\nErrorCode: ResourceNotFound")]
|
||||
[InlineData("Azure.RequestFailedException: The update condition specified in the request was not satisfied.\nStatus: 412 (Precondition Failed)\nErrorCode: UpdateConditionNotSatisfied")]
|
||||
[InlineData("Azure.RequestFailedException: The specified entity already exists.\nStatus: 409 (Conflict)\nErrorCode: EntityAlreadyExists")]
|
||||
public void ExpectedStorageOutcome_InProcShape_IsDropped(string error)
|
||||
{
|
||||
// InProc spans have no ResultCode — only the exception text in Properties["Error"].
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep(target: "TableClient.GetEntity", success: false, type: "InProc | Microsoft.Tables");
|
||||
dep.Properties["Error"] = error;
|
||||
processor.Process(dep);
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrottledInProcStorageDependency_IsKept()
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep(target: "TableClient.SubmitTransaction", success: false, type: "InProc | Microsoft.Tables");
|
||||
dep.Properties["Error"] = "Azure.RequestFailedException: Too many requests.\nStatus: 429 (Too Many Requests)\nErrorCode: ServerBusy";
|
||||
processor.Process(dep);
|
||||
Assert.Same(dep, Assert.Single(next.Received));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulSignalRRestCall_IsDropped()
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
processor.Process(Dep("autopilotmonitor-eu.service.signalr.net", success: true, data: "POST /api/hubs/autopilotmonitor/groups/t-x/connections/y", type: "HTTP"));
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedSignalRRestCall_IsKept()
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("autopilotmonitor-eu.service.signalr.net", success: false, type: "HTTP");
|
||||
dep.ResultCode = "500";
|
||||
processor.Process(dep);
|
||||
Assert.Same(dep, Assert.Single(next.Received));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Azure.Data.Tables.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Startup table initialization is gated by a schema sentinel (hash over TableNames.All)
|
||||
/// stored in AdminConfiguration: a matching sentinel means zero CreateTableIfNotExists
|
||||
/// calls on a cold start; anything else runs the full idempotent pass and rewrites it.
|
||||
/// </summary>
|
||||
public class TableSchemaSentinelTests
|
||||
{
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableServiceClient> Service { get; } = new();
|
||||
public Mock<TableClient> AdminConfig { get; } = new();
|
||||
public TableEntity? UpsertedSentinel { get; private set; }
|
||||
public int CreateCalls;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Service.Setup(s => s.GetTableClient(Constants.TableNames.AdminConfiguration)).Returns(AdminConfig.Object);
|
||||
Service.Setup(s => s.CreateTableIfNotExistsAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref CreateCalls);
|
||||
return Task.FromResult(Response.FromValue(new TableItem("x"), Mock.Of<Response>()));
|
||||
});
|
||||
AdminConfig.Setup(c => c.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ITableEntity, TableUpdateMode, CancellationToken>((e, _, _) => UpsertedSentinel = (TableEntity)e)
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
}
|
||||
|
||||
public void SentinelReturns(string? hash)
|
||||
{
|
||||
var response = new Mock<NullableResponse<TableEntity>>();
|
||||
response.SetupGet(r => r.HasValue).Returns(hash != null);
|
||||
if (hash != null)
|
||||
{
|
||||
response.SetupGet(r => r.Value).Returns(new TableEntity(
|
||||
TableStorageService.SchemaSentinelPartitionKey, TableStorageService.SchemaSentinelRowKey)
|
||||
{
|
||||
[TableStorageService.SchemaSentinelHashProperty] = hash
|
||||
});
|
||||
}
|
||||
AdminConfig.Setup(c => c.GetEntityIfExistsAsync<TableEntity>(
|
||||
TableStorageService.SchemaSentinelPartitionKey, TableStorageService.SchemaSentinelRowKey,
|
||||
It.IsAny<System.Collections.Generic.IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(response.Object);
|
||||
}
|
||||
|
||||
public void SentinelThrows()
|
||||
{
|
||||
AdminConfig.Setup(c => c.GetEntityIfExistsAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<System.Collections.Generic.IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "TableNotFound"));
|
||||
}
|
||||
|
||||
public TableStorageService Build() => new(Service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
private static string CurrentHash => TableStorageService.ComputeTableSchemaHash(Constants.TableNames.All);
|
||||
|
||||
[Fact]
|
||||
public void Hash_IsOrderIndependent_AndChangesWhenTableAdded()
|
||||
{
|
||||
var a = TableStorageService.ComputeTableSchemaHash(new[] { "B", "A" });
|
||||
var b = TableStorageService.ComputeTableSchemaHash(new[] { "A", "B" });
|
||||
var c = TableStorageService.ComputeTableSchemaHash(new[] { "A", "B", "C" });
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.Equal(64, a.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MatchingSentinel_SkipsTableCreation()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelReturns(CurrentHash);
|
||||
|
||||
var fullPass = await h.Build().InitializeTablesAsync();
|
||||
|
||||
Assert.False(fullPass);
|
||||
Assert.Equal(0, h.CreateCalls);
|
||||
Assert.Null(h.UpsertedSentinel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaleSentinel_RunsFullPass_AndRewritesSentinel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelReturns("STALE");
|
||||
|
||||
var fullPass = await h.Build().InitializeTablesAsync();
|
||||
|
||||
Assert.True(fullPass);
|
||||
Assert.Equal(Constants.TableNames.All.Length, h.CreateCalls);
|
||||
Assert.NotNull(h.UpsertedSentinel);
|
||||
Assert.Equal(CurrentHash, h.UpsertedSentinel!.GetString(TableStorageService.SchemaSentinelHashProperty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSentinel_RunsFullPass()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelReturns(null);
|
||||
|
||||
Assert.True(await h.Build().InitializeTablesAsync());
|
||||
Assert.Equal(Constants.TableNames.All.Length, h.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnreadableSentinel_FreshStorage_RunsFullPass()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelThrows();
|
||||
|
||||
Assert.True(await h.Build().InitializeTablesAsync());
|
||||
Assert.Equal(Constants.TableNames.All.Length, h.CreateCalls);
|
||||
Assert.NotNull(h.UpsertedSentinel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedTableCreate_DoesNotWriteSentinel_AndRetriesNextCall()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelReturns(null);
|
||||
var failing = Constants.TableNames.All.First();
|
||||
h.Service.Setup(s => s.CreateTableIfNotExistsAsync(failing, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(500, "boom"));
|
||||
var svc = h.Build();
|
||||
|
||||
Assert.True(await svc.InitializeTablesAsync());
|
||||
Assert.Null(h.UpsertedSentinel);
|
||||
|
||||
// Not marked initialized → a second call runs the pass again instead of short-circuiting.
|
||||
var before = h.CreateCalls;
|
||||
Assert.True(await svc.InitializeTablesAsync());
|
||||
Assert.True(h.CreateCalls > before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondCallAfterSuccess_IsNoOp()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SentinelReturns(null);
|
||||
var svc = h.Build();
|
||||
|
||||
await svc.InitializeTablesAsync();
|
||||
var calls = h.CreateCalls;
|
||||
Assert.False(await svc.InitializeTablesAsync());
|
||||
Assert.Equal(calls, h.CreateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillClaim_WinnerTrue_LoserFalse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AdminConfig.SetupSequence(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>())
|
||||
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
|
||||
var svc = h.Build();
|
||||
|
||||
Assert.True(await svc.TryClaimSessionIndexBackfillAsync());
|
||||
Assert.False(await svc.TryClaimSessionIndexBackfillAsync());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<ResendEmailService> _resendMock;
|
||||
private readonly Mock<IEmailService> _emailMock;
|
||||
private readonly TenantApprovalService _sut;
|
||||
|
||||
public TenantApprovalServiceTests()
|
||||
@@ -37,9 +37,10 @@ public class TenantApprovalServiceTests
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_resendMock = new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance);
|
||||
_emailMock = new Mock<IEmailService>();
|
||||
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
@@ -47,7 +48,7 @@ public class TenantApprovalServiceTests
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
_resendMock.Object);
|
||||
_emailMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,7 +95,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
Assert.False(sent);
|
||||
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,7 +110,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -121,7 +122,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,7 +135,7 @@ public class TenantApprovalServiceTests
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -151,6 +152,6 @@ public class TenantApprovalServiceTests
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
|
||||
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance).Object)
|
||||
Mock.Of<IEmailService>())
|
||||
{ CallBase = false };
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
|
||||
+31
-1
@@ -20,6 +20,7 @@ public class TenantConfigTableSerializationTests
|
||||
{
|
||||
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
|
||||
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
|
||||
var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
@@ -29,7 +30,8 @@ public class TenantConfigTableSerializationTests
|
||||
TrialExpiresUtc = expires,
|
||||
TrialStartedUtc = started,
|
||||
TrialConsumed = true,
|
||||
TrialGrantedBy = "alice@contoso.com"
|
||||
TrialGrantedBy = "alice@contoso.com",
|
||||
ProDowngradedUtc = downgraded
|
||||
};
|
||||
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
|
||||
@@ -40,6 +42,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Equal(started, mapped.TrialStartedUtc);
|
||||
Assert.True(mapped.TrialConsumed);
|
||||
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
|
||||
Assert.Equal(downgraded, mapped.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,6 +62,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.TrialStartedUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
Assert.Null(mapped.ProDowngradedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,6 +81,7 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.TrialExpiresUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,6 +125,31 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.AbsoluteMaxSessionHours);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_EnableDoGroupIdAutoSet_SurvivesStoreAndMap()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
EnableDoGroupIdAutoSet = true
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.True(mapped.EnableDoGroupIdAutoSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_LegacyRow_WithoutDoGroupIdColumn_ReadsNull()
|
||||
{
|
||||
// null = "not set" → GetAgentConfigFunction serves the agent default (false).
|
||||
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
||||
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).EnableDoGroupIdAutoSet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_NotificationChannelsJson_SurvivesStoreAndMap()
|
||||
{
|
||||
|
||||
@@ -194,8 +194,10 @@ public class TenantEntitlementServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
|
||||
public void GetEffectiveRetentionDays_TrialExpiry_ProCapHoldsThroughGrace_ThenDegradesTo90()
|
||||
{
|
||||
// Trial expiry is a downgrade: the Pro cap keeps applying for the 30-day retention
|
||||
// grace (anchor = TrialExpiresUtc, no write involved), THEN the Community cap bites.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
@@ -204,6 +206,93 @@ public class TenantEntitlementServiceTests
|
||||
TrialExpiresUtc = Now.AddDays(1)
|
||||
};
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
|
||||
// 1 day after expiry: inside the grace — still the Pro cap.
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
|
||||
// 31 days after expiry: grace over — Community cap enforced.
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(32)));
|
||||
}
|
||||
|
||||
// ── Retention downgrade grace (GetRetentionGraceEndUtc + cap interplay) ──────
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_ProTenant_ReturnsNull()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "pro",
|
||||
ProDowngradedUtc = Now.AddDays(-5) // stale leftover must not matter while Pro
|
||||
};
|
||||
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_NeverLostPro_ReturnsNull()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free" };
|
||||
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_ExplicitDowngrade_AnchorPlusGraceDays()
|
||||
{
|
||||
var downgraded = Now.AddDays(-10);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = downgraded
|
||||
};
|
||||
Assert.Equal(
|
||||
downgraded.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
|
||||
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRetentionGraceEndUtc_LatestAnchorWins()
|
||||
{
|
||||
// Downgrade, re-granted trial, trial expired again: the LATER trial expiry anchors.
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
ProDowngradedUtc = Now.AddDays(-20),
|
||||
TrialExpiresUtc = Now.AddDays(-3)
|
||||
};
|
||||
Assert.Equal(
|
||||
Now.AddDays(-3).AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
|
||||
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_ExplicitDowngrade_ProCapDuringGrace_CommunityCapAfter()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "community",
|
||||
DataRetentionDays = 365,
|
||||
ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(29)));
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(31)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_GraceDoesNotRaiseAnythingElse()
|
||||
{
|
||||
// 0 (infinite) passes through untouched, and a stored value below the Community cap
|
||||
// is unaffected — the grace only swaps which CAP clamps.
|
||||
var zero = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 0, ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(zero, Now.AddDays(5)));
|
||||
|
||||
var below = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 60, ProDowngradedUtc = Now
|
||||
};
|
||||
Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +499,136 @@ public class TimeAttributionCalculatorTests
|
||||
Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]);
|
||||
}
|
||||
|
||||
// ── sleep spans: payload instants are authoritative, annotation not partition ─────────
|
||||
|
||||
[Fact]
|
||||
public void SleepSpan_ComesFromPayloadInstants_NotFromEventTimestamp()
|
||||
{
|
||||
var enteredAt = T0.AddMinutes(8);
|
||||
var exitedAt = T0.AddMinutes(14);
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
// Emitted at wake (its Timestamp is the wake moment, possibly clamped) — only the
|
||||
// payload carries the true episode bounds.
|
||||
Evt(T0.AddMinutes(14.2), "system_sleep_episode", data: new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "modern_standby",
|
||||
["enteredAt"] = enteredAt.ToString("o"),
|
||||
["exitedAt"] = exitedAt.ToString("o"),
|
||||
["durationSeconds"] = 360L,
|
||||
}),
|
||||
Evt(T0.AddMinutes(20), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
|
||||
|
||||
AssertExactPartition(b); // sleep is an annotation — the wall-clock partition is untouched
|
||||
var span = Assert.Single(b.SleepSpans);
|
||||
Assert.Equal(enteredAt, span.StartUtc);
|
||||
Assert.Equal(exitedAt, span.EndUtc);
|
||||
Assert.Equal(360, span.Seconds);
|
||||
Assert.Equal(360, b.SleepSeconds);
|
||||
Assert.Equal("modern_standby", span.Kind);
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SleepSpan_DuplicateEpisode_NotDoubleCounted()
|
||||
{
|
||||
var enteredAt = T0.AddMinutes(8);
|
||||
var exitedAt = T0.AddMinutes(10);
|
||||
Dictionary<string, object> Episode() => new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "sleep",
|
||||
["enteredAt"] = enteredAt.ToString("o"),
|
||||
["exitedAt"] = exitedAt.ToString("o"),
|
||||
};
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
// Same episode observed twice (e.g. backfilled again after an agent restart from a
|
||||
// different event-log record).
|
||||
Evt(T0.AddMinutes(10.1), "system_sleep_episode", data: Episode()),
|
||||
Evt(T0.AddMinutes(12), "system_sleep_episode", data: Episode()),
|
||||
Evt(T0.AddMinutes(20), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
|
||||
|
||||
Assert.Single(b.SleepSpans);
|
||||
Assert.Equal(120, b.SleepSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SleepSpan_MissingOrUnparseablePayload_Ignored()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(5), "system_sleep_episode", data: new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "sleep",
|
||||
["enteredAt"] = "not-a-timestamp",
|
||||
["exitedAt"] = T0.AddMinutes(5).ToString("o"),
|
||||
}),
|
||||
Evt(T0.AddMinutes(6), "system_sleep_episode", data: new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "sleep", // bounds missing entirely
|
||||
}),
|
||||
// exitedAt <= enteredAt — pathological, no claim.
|
||||
Evt(T0.AddMinutes(7), "system_sleep_episode", data: new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "sleep",
|
||||
["enteredAt"] = T0.AddMinutes(7).ToString("o"),
|
||||
["exitedAt"] = T0.AddMinutes(6).ToString("o"),
|
||||
}),
|
||||
Evt(T0.AddMinutes(20), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
|
||||
|
||||
Assert.Empty(b.SleepSpans);
|
||||
Assert.Equal(0, b.SleepSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_SleepAcrossPause_CountsOnlyInWindowFlanks()
|
||||
{
|
||||
var part1End = T0.AddMinutes(20);
|
||||
var resumedAt = T0.AddDays(2);
|
||||
var completedAt = resumedAt.AddMinutes(10);
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(part1End, "whiteglove_part1_complete"),
|
||||
// Sealed device slept through the pause: episode starts 1 min before the seal and
|
||||
// ends 30 s into part 2 — only the in-window flanks may count (90 s), never the
|
||||
// ~2-day pause (RebootSpan precedent).
|
||||
Evt(resumedAt.AddSeconds(35), "system_sleep_episode", data: new Dictionary<string, object>
|
||||
{
|
||||
["kind"] = "hibernate",
|
||||
["enteredAt"] = part1End.AddMinutes(-1).ToString("o"),
|
||||
["exitedAt"] = resumedAt.AddSeconds(30).ToString("o"),
|
||||
}),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var span = Assert.Single(b.SleepSpans);
|
||||
Assert.Equal(90, span.Seconds);
|
||||
Assert.Equal(90, b.SleepSeconds);
|
||||
}
|
||||
|
||||
// ── WhiteGlove pause vs. intervals/reboots (Codex review P1): the pause lies INSIDE
|
||||
// the windows' hull, so hull-clamping alone would count it — a reboot bracketing the
|
||||
// pause must contribute only its in-window flanks, never the pause itself ─────────────
|
||||
|
||||
+12
@@ -46,6 +46,11 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
{
|
||||
new() { StartUtc = T0.AddMinutes(10), EndUtc = T0.AddMinutes(14), Seconds = 240, SegmentKey = TimeAttributionSegments.EspApps },
|
||||
},
|
||||
SleepSeconds = 360,
|
||||
SleepSpans = new List<SleepSpan>
|
||||
{
|
||||
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
|
||||
},
|
||||
BlockingApps = new List<BlockingAppInterval>
|
||||
{
|
||||
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
|
||||
@@ -77,6 +82,13 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, reboot.SegmentKey);
|
||||
Assert.Equal(240, reboot.Seconds);
|
||||
|
||||
Assert.Equal(360, mapped.SleepSeconds);
|
||||
var sleep = Assert.Single(mapped.SleepSpans);
|
||||
Assert.Equal("modern_standby", sleep.Kind);
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, sleep.SegmentKey);
|
||||
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
|
||||
Assert.Equal(360, sleep.Seconds);
|
||||
|
||||
var app = Assert.Single(mapped.BlockingApps);
|
||||
Assert.Equal(AppX, app.AppId);
|
||||
Assert.Equal("App X", app.AppName);
|
||||
|
||||
@@ -20,7 +20,8 @@ public class TrialExpirySweepFunctionTests
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Tenant(
|
||||
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
|
||||
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com",
|
||||
int retentionDays = 90, DateTime? proDowngradedUtc = null) => new()
|
||||
{
|
||||
TenantId = id,
|
||||
DomainName = domain,
|
||||
@@ -28,6 +29,8 @@ public class TrialExpirySweepFunctionTests
|
||||
PlanTier = planTier,
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = trialExpiresUtc.HasValue,
|
||||
DataRetentionDays = retentionDays,
|
||||
ProDowngradedUtc = proDowngradedUtc,
|
||||
};
|
||||
|
||||
private static (TrialExpirySweepFunction Sut, List<OpsEventEntry> Events) Build(params TenantConfiguration[] configs)
|
||||
@@ -150,6 +153,88 @@ public class TrialExpirySweepFunctionTests
|
||||
Assert.Equal(2, events.Count);
|
||||
}
|
||||
|
||||
// ── Retention downgrade grace events ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GraceEndingSoon_WithDataAtRisk_EmitsGraceExpiring()
|
||||
{
|
||||
// Downgraded 28 days ago with stored retention 365: grace ends in 2 days.
|
||||
var (sut, events) = Build(Tenant("t1", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-28)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.GraceExpiringEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantRetentionGraceExpiring", e.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, e.Severity);
|
||||
Assert.Contains("2 day", e.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GraceEndedWithinLookBack_EmitsGraceEnded_OlderStaysSilent()
|
||||
{
|
||||
var (sut, events) = Build(
|
||||
// Grace ended 12h ago → emit once.
|
||||
Tenant("just-ended", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-30).AddHours(-12)),
|
||||
// Grace ended 3 days ago → reported by a previous run, silent.
|
||||
Tenant("long-ended", null, planTier: "community",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-33)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.GraceEndedEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantRetentionGraceEnded", e.EventType);
|
||||
Assert.Equal("just-ended", e.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grace_NoDataAtRisk_StaysSilent()
|
||||
{
|
||||
var (sut, events) = Build(
|
||||
// Stored retention at the Community cap — nothing to lose.
|
||||
Tenant("at-cap", null, planTier: "community",
|
||||
retentionDays: 90, proDowngradedUtc: Now.AddDays(-29)),
|
||||
// 0 = infinite escape hatch — the fanout never deletes, no warning needed.
|
||||
Tenant("infinite", null, planTier: "community",
|
||||
retentionDays: 0, proDowngradedUtc: Now.AddDays(-29)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.GraceExpiringEmitted);
|
||||
Assert.Equal(0, result.GraceEndedEmitted);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grace_ReUpgradedTenant_StaysSilent()
|
||||
{
|
||||
// Back on Pro: GetRetentionGraceEndUtc returns null even with a stale anchor.
|
||||
var (sut, events) = Build(Tenant("t1", null, planTier: "pro",
|
||||
retentionDays: 365, proDowngradedUtc: Now.AddDays(-29)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.GraceExpiringEmitted);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredTrial_WithDataAtRisk_GetsBothTrialExpiredAndLaterGraceEvents()
|
||||
{
|
||||
// The trial-expiry anchor drives the grace window too: expired 2h ago fires
|
||||
// TenantTrialExpired now; 28 days later the same tenant enters the grace heads-up.
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddHours(-2), retentionDays: 365));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.ExpiredEmitted);
|
||||
Assert.Equal(0, result.GraceExpiringEmitted); // grace end is ~30d out — beyond heads-up
|
||||
Assert.Single(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict-calibration drift radar (docs/backend/verdict-calibration.md): the three finding
|
||||
/// kinds, the small-n / lift / Wilson gates, re-arm, and the tracker round-trip.
|
||||
/// </summary>
|
||||
public class VerdictCalibrationRadarTests
|
||||
{
|
||||
private static readonly DateTime Target = new(2026, 8, 22, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>35 daily rows ending on Target; per-day buckets chosen by window/baseline membership.</summary>
|
||||
private static List<VerdictCalibrationDailyAggregate> Horizon(
|
||||
Func<bool, (string Path, string Status, int Count)[]> bucketsFor, int sessionsPerDay = 20)
|
||||
{
|
||||
var rows = new List<VerdictCalibrationDailyAggregate>();
|
||||
for (var i = 34; i >= 0; i--)
|
||||
{
|
||||
var inWindow = i < 7;
|
||||
var date = Target.AddDays(-i);
|
||||
rows.Add(new VerdictCalibrationDailyAggregate
|
||||
{
|
||||
TenantId = "t", Date = date.ToString("yyyy-MM-dd"), SessionCount = sessionsPerDay, TerminalSessionCount = sessionsPerDay,
|
||||
Buckets = bucketsFor(inWindow).Select(b => new VerdictCalibrationBucket { VerdictPath = b.Path, Status = b.Status, Count = b.Count }).ToList(),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Share_regression_fires_on_a_doubled_separated_share()
|
||||
{
|
||||
// baseline: 1/20 per day (5%), window: 4/20 per day (20%) → lift 4, clearly separated
|
||||
var rows = Horizon(w => new[] { ("sweep:r5_incomplete", "Incomplete", w ? 4 : 1), ("agent:complete", "Succeeded", w ? 16 : 19) });
|
||||
var findings = VerdictCalibrationRadar.Evaluate(rows, Target);
|
||||
|
||||
var f = Assert.Single(findings, x => x.Kind == VerdictCalibrationAlertKinds.ShareRegression);
|
||||
Assert.Equal("sweep:r5_incomplete", f.VerdictPath);
|
||||
Assert.Equal("Incomplete", f.Status);
|
||||
Assert.Equal(28, f.WindowHitCount);
|
||||
Assert.Equal(140, f.WindowSessionCount);
|
||||
Assert.Equal(28, f.BaselineHitCount);
|
||||
Assert.Equal(560, f.BaselineSessionCount);
|
||||
Assert.Equal(20.0, f.WindowRatePct);
|
||||
Assert.Equal(5.0, f.BaselineRatePct);
|
||||
Assert.Equal(4.0, f.Lift);
|
||||
Assert.Equal("2026-08-16", f.WindowStartDate);
|
||||
Assert.Equal("2026-08-22", f.WindowEndDate);
|
||||
// sweep:* is also the silence group → silence share doubled as well
|
||||
Assert.Contains(findings, x => x.Kind == VerdictCalibrationAlertKinds.SilenceShareRegression && x.VerdictPath == VerdictCalibrationRadar.SilenceGroupPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Share_regression_is_suppressed_below_lift_two_or_without_separation()
|
||||
{
|
||||
// 5% → 8%: lift 1.6 — under the gate
|
||||
var mild = Horizon(w => new[] { ("agent:failed", "Failed", w ? 2 : 1), ("agent:complete", "Succeeded", w ? 18 : 19) }, sessionsPerDay: 20);
|
||||
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(mild, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
|
||||
|
||||
// tiny n: 1/2 per day in window vs 0/2 baseline — lift infinite but Wilson intervals overlap / hits < 5
|
||||
var tiny = Horizon(w => new[] { ("agent:failed", "Failed", w ? 1 : 0), ("agent:complete", "Succeeded", w ? 1 : 2) }, sessionsPerDay: 2);
|
||||
Assert.Empty(VerdictCalibrationRadar.Evaluate(tiny, Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Agent_and_registration_paths_never_share_alert_but_still_feed_the_silence_group()
|
||||
{
|
||||
// Workflow mix, not classifier drift: a WhiteGlove rollout week doubles the pending share.
|
||||
var wg = Horizon(w => new[] { ("agent:whiteglove_pending", "Pending", w ? 6 : 1), ("agent:complete", "Succeeded", w ? 14 : 19) });
|
||||
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(wg, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
|
||||
var reg = Horizon(w => new[] { ("register:superseded", "Incomplete", w ? 6 : 1), ("agent:complete", "Succeeded", w ? 14 : 19) });
|
||||
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(reg, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
|
||||
// Backend-decided paths stay eligible.
|
||||
Assert.True(VerdictCalibrationRadar.IsShareRegressionEligible("sweep:r6"));
|
||||
Assert.True(VerdictCalibrationRadar.IsShareRegressionEligible("rule:ANALYZE-ESP-001"));
|
||||
Assert.True(VerdictCalibrationRadar.IsShareRegressionEligible("manual:failed"));
|
||||
Assert.False(VerdictCalibrationRadar.IsShareRegressionEligible("agent:complete_soft"));
|
||||
|
||||
// An episode opened by the earlier, broader gate re-arms on the next pass regardless of numbers.
|
||||
var stale = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "agent:whiteglove_pending", Status = "Pending" };
|
||||
Assert.True(VerdictCalibrationRadar.ShouldReArm(stale, wg, Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derived_legacy_paths_never_alert()
|
||||
{
|
||||
var rows = Horizon(w => new[] { ("legacy:r6", "Incomplete", w ? 10 : 1), ("agent:complete", "Succeeded", w ? 10 : 19) });
|
||||
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target), f => f.Kind == VerdictCalibrationAlertKinds.ShareRegression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_gap_fires_on_r6_share_and_ignores_non_rule_sweep_paths()
|
||||
{
|
||||
// classifier verdicts in window: r6 5/day + r5 15/day = 140; r6 share 25% → fires. sweep:stalled is not a rule verdict.
|
||||
var rows = Horizon(w => new[]
|
||||
{
|
||||
("sweep:r6", "Incomplete", 5), ("sweep:r5_incomplete", "Incomplete", 15), ("sweep:stalled", "Stalled", 30),
|
||||
}, sessionsPerDay: 50);
|
||||
var f = Assert.Single(VerdictCalibrationRadar.Evaluate(rows, Target), x => x.Kind == VerdictCalibrationAlertKinds.EvidenceGap);
|
||||
Assert.Equal(VerdictCalibrationRadar.EvidenceGapPath, f.VerdictPath);
|
||||
Assert.Equal(35, f.WindowHitCount);
|
||||
Assert.Equal(140, f.WindowSessionCount);
|
||||
Assert.Equal(25.0, f.WindowRatePct);
|
||||
Assert.Null(f.Lift);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_gap_needs_twenty_classifier_verdicts()
|
||||
{
|
||||
var rows = Horizon(w => new[] { ("sweep:r6", "Incomplete", w ? 2 : 0), ("agent:complete", "Succeeded", 18) });
|
||||
Assert.DoesNotContain(VerdictCalibrationRadar.Evaluate(rows, Target), f => f.Kind == VerdictCalibrationAlertKinds.EvidenceGap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReArm_when_share_falls_under_one_point_five_or_path_stops()
|
||||
{
|
||||
var alert = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "sweep:r6", Status = "Incomplete" };
|
||||
|
||||
var stillHigh = Horizon(w => new[] { ("sweep:r6", "Incomplete", w ? 4 : 1), ("agent:complete", "Succeeded", w ? 16 : 19) });
|
||||
Assert.False(VerdictCalibrationRadar.ShouldReArm(alert, stillHigh, Target));
|
||||
|
||||
var elevatedButUnderGate = Horizon(w => new[] { ("sweep:r6", "Incomplete", w ? 2 : 1), ("agent:complete", "Succeeded", w ? 18 : 19) }, sessionsPerDay: 20);
|
||||
// 10% vs 5% = 2.0× — still at/above 1.5× → keep
|
||||
Assert.False(VerdictCalibrationRadar.ShouldReArm(alert, elevatedButUnderGate, Target));
|
||||
|
||||
var back = Horizon(w => new[] { ("sweep:r6", "Incomplete", 1), ("agent:complete", "Succeeded", 19) });
|
||||
Assert.True(VerdictCalibrationRadar.ShouldReArm(alert, back, Target));
|
||||
|
||||
var gone = Horizon(w => new[] { ("sweep:r6", "Incomplete", w ? 0 : 1), ("agent:complete", "Succeeded", w ? 20 : 19) });
|
||||
Assert.True(VerdictCalibrationRadar.ShouldReArm(alert, gone, Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evidence_gap_rearms_under_fifteen_percent()
|
||||
{
|
||||
var alert = new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.EvidenceGap, VerdictPath = "r6", Status = "*" };
|
||||
var still = Horizon(w => new[] { ("sweep:r6", "Incomplete", 4), ("sweep:r5_incomplete", "Incomplete", 16) });
|
||||
Assert.False(VerdictCalibrationRadar.ShouldReArm(alert, still, Target)); // 20%
|
||||
var between = Horizon(w => new[] { ("sweep:r6", "Incomplete", 17), ("sweep:r5_incomplete", "Incomplete", 83) }, sessionsPerDay: 100);
|
||||
Assert.False(VerdictCalibrationRadar.ShouldReArm(alert, between, Target)); // 17%
|
||||
var low = Horizon(w => new[] { ("sweep:r6", "Incomplete", 1), ("sweep:r5_incomplete", "Incomplete", 19) });
|
||||
Assert.True(VerdictCalibrationRadar.ShouldReArm(alert, low, Target)); // 5%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentSums_follow_the_alert_kind()
|
||||
{
|
||||
var rows = Horizon(w => new[] { ("sweep:r6", "Incomplete", 2), ("maxlife:r3", "Incomplete", 1), ("agent:complete", "Succeeded", 17) });
|
||||
var share = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.ShareRegression, VerdictPath = "sweep:r6", Status = "Incomplete" }, rows, Target);
|
||||
Assert.Equal((14, 140, 56, 560), share);
|
||||
var silence = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.SilenceShareRegression }, rows, Target);
|
||||
Assert.Equal((21, 140, 84, 560), silence);
|
||||
var gap = VerdictCalibrationRadar.CurrentSums(new VerdictCalibrationAlert { Kind = VerdictCalibrationAlertKinds.EvidenceGap }, rows, Target);
|
||||
Assert.Equal((14, 21, 56, 84), gap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_the_horizon_are_ignored()
|
||||
{
|
||||
var rows = Horizon(w => new[] { ("agent:complete", "Succeeded", 20) });
|
||||
rows.Add(new VerdictCalibrationDailyAggregate { TenantId = "t", Date = "2026-08-23", SessionCount = 1000, Buckets = { new VerdictCalibrationBucket { VerdictPath = "sweep:r6", Status = "Incomplete", Count = 1000 } } });
|
||||
rows.Add(new VerdictCalibrationDailyAggregate { TenantId = "t", Date = "2026-07-01", SessionCount = 1000, Buckets = { new VerdictCalibrationBucket { VerdictPath = "sweep:r6", Status = "Incomplete", Count = 1000 } } });
|
||||
Assert.Empty(VerdictCalibrationRadar.Evaluate(rows, Target));
|
||||
}
|
||||
|
||||
// ---- tracker round-trip ----
|
||||
|
||||
[Fact]
|
||||
public void Alert_round_trips_through_the_tracker_entity()
|
||||
{
|
||||
var alert = new VerdictCalibrationAlert
|
||||
{
|
||||
TenantId = "11111111-1111-1111-1111-111111111111", Kind = VerdictCalibrationAlertKinds.ShareRegression,
|
||||
VerdictPath = "rule:ANALYZE-ESP-001", Status = "Failed",
|
||||
WindowHitCount = 7, WindowSessionCount = 40, BaselineHitCount = 3, BaselineSessionCount = 160,
|
||||
WindowRatePct = 17.5, BaselineRatePct = 1.9, Lift = 9.3, WindowStartDate = "2026-08-16", WindowEndDate = "2026-08-22",
|
||||
Dimension = new RuleRegressionDimension { Dimension = "osBuild", Value = "26200.1234", HitCount = 6, HitSharePct = 85.7, AllSharePct = 20.0, Lift = 4.3 },
|
||||
FirstNotifiedAt = new DateTime(2026, 8, 20, 6, 0, 0, DateTimeKind.Utc), LastEvaluatedAt = new DateTime(2026, 8, 23, 6, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildVerdictCalibrationEntity(alert.TenantId, alert);
|
||||
Assert.Equal("verdictcalibration|share_regression|rule:analyze-esp-001|failed", entity.RowKey);
|
||||
Assert.Equal(alert.TenantId, entity.PartitionKey);
|
||||
|
||||
var back = TableHardwareRejectionNotificationTracker.MapToVerdictCalibrationAlert(entity);
|
||||
Assert.Equal(JsonSerializer.Serialize(alert), JsonSerializer.Serialize(back));
|
||||
|
||||
// Tri-states: no lift / no dimension → absent columns → null on read, never 0 / a guessed dimension.
|
||||
var bare = new VerdictCalibrationAlert
|
||||
{
|
||||
TenantId = alert.TenantId, Kind = VerdictCalibrationAlertKinds.EvidenceGap, VerdictPath = "r6", Status = "*",
|
||||
FirstNotifiedAt = alert.FirstNotifiedAt, LastEvaluatedAt = alert.LastEvaluatedAt,
|
||||
};
|
||||
var bareBack = TableHardwareRejectionNotificationTracker.MapToVerdictCalibrationAlert(
|
||||
TableHardwareRejectionNotificationTracker.BuildVerdictCalibrationEntity(bare.TenantId, bare));
|
||||
Assert.Null(bareBack.Lift);
|
||||
Assert.Null(bareBack.Dimension);
|
||||
Assert.Equal("verdictcalibration|evidence_gap|r6|*", TableHardwareRejectionNotificationTracker.BuildVerdictCalibrationRowKey(bare.Kind, bare.VerdictPath, bare.Status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict calibration aggregate (docs/backend/verdict-calibration.md): the pure bucketing core,
|
||||
/// the persistence round-trip and the matrix arithmetic of the read endpoint.
|
||||
/// </summary>
|
||||
public class VerdictCalibrationTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private static readonly DateTime Now = new(2026, 8, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static SessionSummary Session(string tenant, string id, SessionStatus status, DateTime started,
|
||||
string? path = null, string? priorPath = null, string? priorStatus = null, string? adminMarked = null,
|
||||
DateTime? completed = null, string serial = "SN-1", string? failureReason = null, string? reconcileReason = null) => new()
|
||||
{
|
||||
TenantId = tenant, SessionId = id, SerialNumber = serial, Status = status, StartedAt = started,
|
||||
CompletedAt = completed, VerdictPath = path, PriorVerdictPath = priorPath, PriorStatus = priorStatus,
|
||||
AdminMarkedAction = adminMarked, FailureReason = failureReason ?? string.Empty,
|
||||
ReconcileReason = reconcileReason ?? string.Empty, FailureSource = string.Empty,
|
||||
};
|
||||
|
||||
private static DeviceHistory History(string tenant, string serial, params (string Id, DateTime Started, DateTime? Completed, string Status)[] refs) => new()
|
||||
{
|
||||
TenantId = tenant, SerialKey = serial.ToLowerInvariant(), SerialNumber = serial,
|
||||
Chain = refs.Select(r => new DeviceSessionRef { SessionId = r.Id, StartedAt = r.Started, CompletedAt = r.Completed, Status = r.Status }).ToList(),
|
||||
};
|
||||
|
||||
private static VerdictCalibrationBucket Bucket(VerdictCalibrationDailyAggregate row, string path, string status)
|
||||
=> Assert.Single(row.Buckets, b => b.VerdictPath == path && b.Status == status);
|
||||
|
||||
// ---- BuildVerdictCalibrationAggregates ----
|
||||
|
||||
[Fact]
|
||||
public void Buckets_by_tenant_date_path_and_status_with_global_mirror()
|
||||
{
|
||||
var d1 = new DateTime(2026, 8, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
Session(TenantA, "a1", SessionStatus.Succeeded, d1, path: "agent:complete", completed: d1.AddHours(1)),
|
||||
Session(TenantA, "a2", SessionStatus.Succeeded, d1.AddHours(2), path: "agent:complete", completed: d1.AddHours(3)),
|
||||
Session(TenantA, "a3", SessionStatus.Incomplete, d1, path: "sweep:r6", completed: d1.AddHours(6)),
|
||||
Session(TenantA, "a4", SessionStatus.Stalled, d1.AddDays(1), path: "sweep:stalled"),
|
||||
Session(TenantB, "b1", SessionStatus.Failed, d1, path: "agent:failed", completed: d1.AddHours(1)),
|
||||
};
|
||||
var rows = MaintenanceService.BuildVerdictCalibrationAggregates(sessions, new Dictionary<string, List<DeviceHistory>>(), Now);
|
||||
|
||||
var a10 = Assert.Single(rows, r => r.TenantId == TenantA && r.Date == "2026-08-10");
|
||||
Assert.Equal(3, a10.SessionCount);
|
||||
Assert.Equal(3, a10.TerminalSessionCount);
|
||||
Assert.Equal(2, Bucket(a10, "agent:complete", "Succeeded").Count);
|
||||
Assert.Equal(1, Bucket(a10, "sweep:r6", "Incomplete").Count);
|
||||
|
||||
var a11 = Assert.Single(rows, r => r.TenantId == TenantA && r.Date == "2026-08-11");
|
||||
Assert.Equal(1, a11.SessionCount);
|
||||
Assert.Equal(0, a11.TerminalSessionCount); // Stalled is not terminal
|
||||
Assert.Equal(1, Bucket(a11, "sweep:stalled", "Stalled").Count);
|
||||
|
||||
var g10 = Assert.Single(rows, r => r.TenantId == "global" && r.Date == "2026-08-10");
|
||||
Assert.Equal(4, g10.SessionCount);
|
||||
Assert.Equal(1, Bucket(g10, "agent:failed", "Failed").Count);
|
||||
Assert.Equal(VerdictCalibrationDailyAggregate.CurrentVersion, g10.Version);
|
||||
Assert.Equal(Now, g10.ComputedAt);
|
||||
|
||||
// Deterministic ordering: rows by tenant then date, buckets by path then status.
|
||||
Assert.Equal(rows.Select(r => (r.TenantId, r.Date)).OrderBy(k => k.TenantId, StringComparer.Ordinal).ThenBy(k => k.Date, StringComparer.Ordinal), rows.Select(r => (r.TenantId, r.Date)));
|
||||
Assert.Equal(a10.Buckets.Select(b => b.VerdictPath).OrderBy(p => p, StringComparer.Ordinal), a10.Buckets.Select(b => b.VerdictPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unstamped_rows_are_derived_and_counted_as_derived()
|
||||
{
|
||||
var d = new DateTime(2026, 8, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
Session(TenantA, "a1", SessionStatus.Succeeded, d, path: null, completed: d.AddHours(1)), // legacy agent completion
|
||||
Session(TenantA, "a2", SessionStatus.Incomplete, d, path: null, completed: d.AddHours(5),
|
||||
failureReason: "No Device Setup completion or explicit failure signal observed before timeout"),
|
||||
Session(TenantA, "a3", SessionStatus.Succeeded, d, path: "agent:complete", completed: d.AddHours(1)),
|
||||
};
|
||||
var row = MaintenanceService.BuildVerdictCalibrationAggregates(sessions, new Dictionary<string, List<DeviceHistory>>(), Now)
|
||||
.Single(r => r.TenantId == TenantA);
|
||||
|
||||
var complete = Bucket(row, "agent:complete", "Succeeded");
|
||||
Assert.Equal(2, complete.Count);
|
||||
Assert.Equal(1, complete.DerivedCount);
|
||||
var r6 = Bucket(row, "legacy:r6", "Incomplete");
|
||||
Assert.Equal(1, r6.Count);
|
||||
Assert.Equal(1, r6.DerivedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_are_attributed_to_the_prior_path()
|
||||
{
|
||||
var d = new DateTime(2026, 8, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
// sweep said Incomplete, agent later completed → late completion against sweep:r5_incomplete
|
||||
Session(TenantA, "a1", SessionStatus.Succeeded, d, path: "agent:complete", priorPath: "sweep:r5_incomplete", priorStatus: "Incomplete", completed: d.AddHours(1)),
|
||||
// admin flipped an agent success to Failed
|
||||
Session(TenantA, "a2", SessionStatus.Failed, d, path: "manual:failed", priorPath: "agent:complete", priorStatus: "Succeeded", adminMarked: "Failed", completed: d.AddHours(1)),
|
||||
// retro reclassification Failed → Incomplete
|
||||
Session(TenantA, "a3", SessionStatus.Incomplete, d, path: "retro:r6", priorPath: "agent:failed", priorStatus: "Failed", completed: d.AddHours(1)),
|
||||
// admin mark to Succeeded counts as admin, not late completion
|
||||
Session(TenantA, "a4", SessionStatus.Succeeded, d, path: "manual:succeeded", priorPath: "sweep:r6", priorStatus: "Incomplete", adminMarked: "Succeeded", completed: d.AddHours(1)),
|
||||
};
|
||||
var row = MaintenanceService.BuildVerdictCalibrationAggregates(sessions, new Dictionary<string, List<DeviceHistory>>(), Now)
|
||||
.Single(r => r.TenantId == TenantA);
|
||||
|
||||
var priorSweep = Bucket(row, "sweep:r5_incomplete", "Incomplete");
|
||||
Assert.Equal(0, priorSweep.Count); // nobody is on it any more
|
||||
Assert.Equal(1, priorSweep.OverriddenByLateCompletion);
|
||||
|
||||
var priorAgent = Bucket(row, "agent:complete", "Succeeded");
|
||||
Assert.Equal(1, priorAgent.Count); // a1 is on it now
|
||||
Assert.Equal(1, priorAgent.OverriddenByAdmin); // a2 used to be
|
||||
|
||||
Assert.Equal(1, Bucket(row, "agent:failed", "Failed").OverriddenOther);
|
||||
Assert.Equal(1, Bucket(row, "sweep:r6", "Incomplete").OverriddenByAdmin);
|
||||
Assert.Equal(0, Bucket(row, "manual:succeeded", "Succeeded").OverriddenByAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReEnrollment_proxy_counts_only_eligible_terminal_sessions()
|
||||
{
|
||||
var old = Now.AddDays(-20);
|
||||
var young = Now.AddDays(-2);
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
// old, re-enrolled 3 days later → eligible + re-enrolled
|
||||
Session(TenantA, "a1", SessionStatus.Incomplete, old, path: "sweep:r6", completed: old.AddHours(5), serial: "SN-A"),
|
||||
// old, next attempt 10 days later → eligible, not re-enrolled
|
||||
Session(TenantA, "a2", SessionStatus.Failed, old, path: "agent:failed", completed: old.AddHours(1), serial: "SN-B"),
|
||||
// old, no next attempt → eligible, not re-enrolled
|
||||
Session(TenantA, "a3", SessionStatus.Succeeded, old, path: "agent:complete", completed: old.AddHours(1), serial: "SN-C"),
|
||||
// too young to judge → not eligible even though the chain shows a follow-up
|
||||
Session(TenantA, "a4", SessionStatus.Failed, young, path: "agent:failed", completed: young.AddHours(1), serial: "SN-D"),
|
||||
// non-terminal → never eligible
|
||||
Session(TenantA, "a5", SessionStatus.AwaitingUser, old, path: "sweep:r5_awaiting", serial: "SN-E"),
|
||||
};
|
||||
var histories = new Dictionary<string, List<DeviceHistory>>
|
||||
{
|
||||
[TenantA] = new()
|
||||
{
|
||||
History(TenantA, "SN-A", ("a1", old, old.AddHours(5), "Incomplete"), ("x1", old.AddDays(3), old.AddDays(3).AddHours(1), "Succeeded")),
|
||||
History(TenantA, "SN-B", ("a2", old, old.AddHours(1), "Failed"), ("x2", old.AddDays(10), null, "Succeeded")),
|
||||
History(TenantA, "SN-C", ("a3", old, old.AddHours(1), "Succeeded")),
|
||||
History(TenantA, "SN-D", ("a4", young, young.AddHours(1), "Failed"), ("x4", young.AddHours(2), null, "Succeeded")),
|
||||
},
|
||||
};
|
||||
var rows = MaintenanceService.BuildVerdictCalibrationAggregates(sessions, histories, Now);
|
||||
|
||||
var oldRow = rows.Single(r => r.TenantId == TenantA && r.Date == old.ToString("yyyy-MM-dd"));
|
||||
var r6 = Bucket(oldRow, "sweep:r6", "Incomplete");
|
||||
Assert.Equal((1, 1), (r6.Eligible7d, r6.ReEnrolled7d));
|
||||
var failed = Bucket(oldRow, "agent:failed", "Failed");
|
||||
Assert.Equal((1, 0), (failed.Eligible7d, failed.ReEnrolled7d));
|
||||
var complete = Bucket(oldRow, "agent:complete", "Succeeded");
|
||||
Assert.Equal((1, 0), (complete.Eligible7d, complete.ReEnrolled7d));
|
||||
var awaiting = Bucket(oldRow, "sweep:r5_awaiting", "AwaitingUser");
|
||||
Assert.Equal((0, 0), (awaiting.Eligible7d, awaiting.ReEnrolled7d));
|
||||
|
||||
var youngRow = rows.Single(r => r.TenantId == TenantA && r.Date == young.ToString("yyyy-MM-dd"));
|
||||
var youngFailed = Bucket(youngRow, "agent:failed", "Failed");
|
||||
Assert.Equal((0, 0), (youngFailed.Eligible7d, youngFailed.ReEnrolled7d));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sessions_inside_a_deletion_cascade_are_skipped()
|
||||
{
|
||||
var d = new DateTime(2026, 8, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
var deleting = Session(TenantA, "a1", SessionStatus.Succeeded, d, path: "agent:complete");
|
||||
deleting.DeletionState = "Pending";
|
||||
var rows = MaintenanceService.BuildVerdictCalibrationAggregates(new[] { deleting }, new Dictionary<string, List<DeviceHistory>>(), Now);
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
// ---- Persistence round-trip ----
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_round_trips_through_the_table_entity()
|
||||
{
|
||||
var aggregate = new VerdictCalibrationDailyAggregate
|
||||
{
|
||||
TenantId = TenantA, Date = "2026-08-10", Version = 1, SessionCount = 5, TerminalSessionCount = 4, ComputedAt = Now,
|
||||
Buckets =
|
||||
{
|
||||
new VerdictCalibrationBucket { VerdictPath = "agent:complete", Status = "Succeeded", Count = 3, DerivedCount = 1, Eligible7d = 3, ReEnrolled7d = 1, OverriddenByAdmin = 1, OverriddenByLateCompletion = 0, OverriddenOther = 2 },
|
||||
new VerdictCalibrationBucket { VerdictPath = "sweep:r6", Status = "Incomplete", Count = 1 },
|
||||
},
|
||||
};
|
||||
var entity = TableStorageService.BuildVerdictCalibrationAggregateEntity(aggregate);
|
||||
Assert.Equal(TenantA, entity.PartitionKey);
|
||||
Assert.Equal("2026-08-10", entity.RowKey);
|
||||
|
||||
var back = TableStorageService.MapToVerdictCalibrationAggregate(entity);
|
||||
Assert.Equal(JsonSerializer.Serialize(aggregate), JsonSerializer.Serialize(back));
|
||||
}
|
||||
|
||||
// ---- Response builder ----
|
||||
|
||||
private static VerdictCalibrationDailyAggregate Day(string tenant, DateTime date, int sessions, params (string Path, string Status, int Count)[] buckets) => new()
|
||||
{
|
||||
TenantId = tenant, Date = date.ToString("yyyy-MM-dd"), SessionCount = sessions, TerminalSessionCount = sessions, Version = 1, ComputedAt = Now,
|
||||
Buckets = buckets.Select(b => new VerdictCalibrationBucket { VerdictPath = b.Path, Status = b.Status, Count = b.Count, Eligible7d = b.Count, ReEnrolled7d = b.Count / 5 }).ToList(),
|
||||
};
|
||||
|
||||
// Same naming policy as the worker host (Program.cs: camelCase) so the assertions pin the wire shape.
|
||||
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
|
||||
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire);
|
||||
|
||||
[Fact]
|
||||
public void Response_sums_the_window_and_computes_share_rate_and_trend()
|
||||
{
|
||||
var today = new DateTime(2026, 8, 23, 0, 0, 0, DateTimeKind.Utc);
|
||||
var daily = new List<VerdictCalibrationDailyAggregate>();
|
||||
// 35 days: baseline days carry 10 sessions (1 sweep:r6), the last 7 days carry 10 sessions (3 sweep:r6) → share 10% → 30%, lift 3.0
|
||||
for (var i = 34; i >= 0; i--)
|
||||
{
|
||||
var date = today.AddDays(-i);
|
||||
var r6 = i < 7 ? 3 : 1;
|
||||
daily.Add(Day("global", date, 10, ("agent:complete", "Succeeded", 10 - r6), ("sweep:r6", "Incomplete", r6)));
|
||||
}
|
||||
var json = BuildJson(daily, today, days: 30);
|
||||
|
||||
Assert.True(json.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(30, json.GetProperty("windowDays").GetInt32());
|
||||
Assert.Equal(300, json.GetProperty("totals").GetProperty("sessions").GetInt32());
|
||||
Assert.Equal(30, json.GetProperty("totals").GetProperty("days").GetInt32());
|
||||
|
||||
var paths = json.GetProperty("paths").EnumerateArray().ToList();
|
||||
var r6Row = paths.Single(p => p.GetProperty("verdictPath").GetString() == "sweep:r6");
|
||||
Assert.Equal(7 * 3 + 23 * 1, r6Row.GetProperty("count").GetInt32());
|
||||
Assert.Equal(Math.Round(100.0 * 44 / 300, 1), r6Row.GetProperty("sharePct").GetDouble());
|
||||
Assert.Equal(30.0, r6Row.GetProperty("window7").GetProperty("sharePct").GetDouble());
|
||||
Assert.Equal(10.0, r6Row.GetProperty("baseline28").GetProperty("sharePct").GetDouble());
|
||||
Assert.Equal(3.0, r6Row.GetProperty("lift").GetDouble());
|
||||
// Eligible 44 ≥ 20 → rate stated; ReEnrolled7d = count/5 per day → 7*0 + 23*0 = 0 → 0%
|
||||
Assert.Equal(0.0, r6Row.GetProperty("reEnrollRatePct").GetDouble());
|
||||
|
||||
// Sorted by count desc → agent:complete first.
|
||||
Assert.Equal("agent:complete", paths[0].GetProperty("verdictPath").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_withholds_rate_below_twenty_eligible_and_lift_without_baseline()
|
||||
{
|
||||
var today = new DateTime(2026, 8, 23, 0, 0, 0, DateTimeKind.Utc);
|
||||
var daily = new List<VerdictCalibrationDailyAggregate>
|
||||
{
|
||||
Day("global", today, 5, ("maxlife:r3", "Incomplete", 5)),
|
||||
};
|
||||
var json = BuildJson(daily, today, days: 7);
|
||||
var row = json.GetProperty("paths").EnumerateArray().Single();
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("reEnrollRatePct").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("lift").ValueKind); // no baseline rows → never invented
|
||||
Assert.Equal(100.0, row.GetProperty("sharePct").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_keeps_a_zero_count_bucket_that_carries_overrides()
|
||||
{
|
||||
var today = new DateTime(2026, 8, 23, 0, 0, 0, DateTimeKind.Utc);
|
||||
var day = Day("global", today, 1, ("agent:complete", "Succeeded", 1));
|
||||
day.Buckets.Add(new VerdictCalibrationBucket { VerdictPath = "sweep:r5_incomplete", Status = "Incomplete", Count = 0, OverriddenByLateCompletion = 1 });
|
||||
var json = BuildJson(new[] { day }, today, days: 7);
|
||||
var rows = json.GetProperty("paths").EnumerateArray().ToList();
|
||||
var prior = rows.Single(p => p.GetProperty("verdictPath").GetString() == "sweep:r5_incomplete");
|
||||
Assert.Equal(0, prior.GetProperty("count").GetInt32());
|
||||
Assert.Equal(1, prior.GetProperty("overriddenByLateCompletion").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_is_empty_but_well_formed_before_the_first_sweep()
|
||||
{
|
||||
var json = BuildJson(Array.Empty<VerdictCalibrationDailyAggregate>(), new DateTime(2026, 8, 23), 30);
|
||||
Assert.True(json.GetProperty("success").GetBoolean());
|
||||
Assert.Empty(json.GetProperty("paths").EnumerateArray());
|
||||
Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)]
|
||||
[InlineData("abc", 30)]
|
||||
[InlineData("0", 1)]
|
||||
[InlineData("500", 180)]
|
||||
[InlineData("14", 14)]
|
||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verdict calibration instrumentation (docs/backend/verdict-calibration.md): every status write
|
||||
/// declares its origin, overriding a prior verdict preserves it, the classifier names the rule
|
||||
/// behind each verdict, and pre-instrumentation rows derive an honest path from their literals.
|
||||
/// </summary>
|
||||
public class VerdictPathTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 8, 20, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---- ComputePriorVerdict: the correction stream ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("Succeeded", "agent:complete", "Failed", true)] // admin mark over an agent success
|
||||
[InlineData("Incomplete", "sweep:r5_incomplete", "Succeeded", true)] // late agent completion upgrades a sweep verdict
|
||||
[InlineData("AwaitingUser", "sweep:r5_awaiting", "Incomplete", true)] // grace expired
|
||||
[InlineData("Failed", "agent:failed", "Incomplete", true)] // retro reclassification
|
||||
[InlineData("Stalled", "sweep:stalled", "InProgress", false)] // stall heal is not a correction
|
||||
[InlineData("Stalled", "sweep:stalled", "Succeeded", false)]
|
||||
[InlineData("InProgress", "register:new", "Succeeded", false)]
|
||||
[InlineData("Pending", "agent:whiteglove_pending", "InProgress", false)]
|
||||
[InlineData("AwaitingUser", "sweep:wg_awaiting", "AwaitingUser", false)] // same-status refresh keeps the original prior
|
||||
public void Prior_is_preserved_only_when_a_verdict_is_overridden(string existing, string existingPath, string incoming, bool expectPrior)
|
||||
{
|
||||
var prior = TableStorageService.ComputePriorVerdict(existing, existingPath, Enum.Parse<SessionStatus>(incoming));
|
||||
if (expectPrior)
|
||||
{
|
||||
Assert.NotNull(prior);
|
||||
Assert.Equal(existing, prior!.Value.PriorStatus);
|
||||
Assert.Equal(existingPath, prior.Value.PriorVerdictPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(prior);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prior_needs_a_stamped_path_on_the_existing_row()
|
||||
{
|
||||
// Pre-instrumentation rows carry no path — there is nothing trustworthy to preserve.
|
||||
Assert.Null(TableStorageService.ComputePriorVerdict("Incomplete", null, SessionStatus.Succeeded));
|
||||
Assert.Null(TableStorageService.ComputePriorVerdict("Incomplete", "", SessionStatus.Succeeded));
|
||||
Assert.Null(TableStorageService.ComputePriorVerdict(null, "sweep:r6", SessionStatus.Succeeded));
|
||||
}
|
||||
|
||||
// ---- VerdictPaths vocabulary ----
|
||||
|
||||
[Fact]
|
||||
public void Compose_and_Origin_round_trip()
|
||||
{
|
||||
var path = VerdictPaths.Compose(VerdictPaths.OriginSweep, ClassifierRules.R5DeviceSetupIncomplete);
|
||||
Assert.Equal("sweep:r5_incomplete", path);
|
||||
Assert.Equal("sweep", VerdictPaths.Origin(path));
|
||||
Assert.True(VerdictPaths.IsClassifierPath(path));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("maxlife:r1"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("late:r4"));
|
||||
Assert.True(VerdictPaths.IsClassifierPath("retro:r6"));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.AgentComplete));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.SweepStalled));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.RetroSuperseded));
|
||||
Assert.False(VerdictPaths.IsClassifierPath("legacy:r6"));
|
||||
Assert.False(VerdictPaths.IsClassifierPath(VerdictPaths.ManualFailed));
|
||||
Assert.Throws<ArgumentException>(() => VerdictPaths.Compose("", "r1"));
|
||||
Assert.Throws<ArgumentException>(() => VerdictPaths.Compose("sweep", " "));
|
||||
}
|
||||
|
||||
// ---- Classifier: every return names its rule ----
|
||||
|
||||
private static EnrollmentEvent Evt(string type, string? message = null) => new()
|
||||
{
|
||||
EventType = type, Timestamp = Start, Source = "test", Message = message ?? type,
|
||||
};
|
||||
|
||||
private static EnrollmentEvent Esp(string message) => Evt("esp_provisioning_status", message);
|
||||
private const string DeviceSetup44 = "ESP provisioning status: DeviceSetup — 4 of 4 subcategories completed";
|
||||
private const string AccountSetup55 = "ESP provisioning status: AccountSetup — 5 of 5 subcategories completed";
|
||||
private const string AccountSetup15 = "ESP provisioning status: AccountSetup — 1 of 5 subcategories completed";
|
||||
|
||||
private static string RuleOf(IReadOnlyList<EnrollmentEvent> events, double hours = 6, int grace = 72,
|
||||
bool preProvisioned = false, DateTime? resumedAt = null, bool selfDeploying = false)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var (_, _, rule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(hours), grace, isPreProvisioned: preProvisioned,
|
||||
resumedAt: resumedAt, isSelfDeployingProfile: selfDeploying);
|
||||
return rule;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classifier_returns_the_rule_id_behind_each_verdict()
|
||||
{
|
||||
Assert.Equal(ClassifierRules.R1ExplicitFailure, RuleOf(new[] { Evt("enrollment_failed") }));
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, RuleOf(
|
||||
new[] { Esp(DeviceSetup44), Evt("whiteglove_complete") }, hours: 6, grace: 51, preProvisioned: true, resumedAt: Start.AddHours(1)));
|
||||
Assert.Equal(ClassifierRules.R1bWhiteGloveSucceeded, RuleOf(
|
||||
new[] { Esp(DeviceSetup44), Evt("whiteglove_complete") }, hours: 60, grace: 51, preProvisioned: true, resumedAt: Start.AddHours(1)));
|
||||
Assert.Equal(ClassifierRules.R1cSelfDeploying, RuleOf(new[] { Esp(DeviceSetup44) }, selfDeploying: true));
|
||||
Assert.Equal(ClassifierRules.R2AccountSetupComplete, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) }));
|
||||
Assert.Equal(ClassifierRules.R3EmergencyBreak, RuleOf(new[] { Esp(DeviceSetup44), Evt("agent_emergency_break") }));
|
||||
Assert.Equal(ClassifierRules.R4DesktopHello, RuleOf(new[] { Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed") }));
|
||||
Assert.Equal(ClassifierRules.R5DeviceSetupAwaiting, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 6, grace: 72));
|
||||
Assert.Equal(ClassifierRules.R5DeviceSetupIncomplete, RuleOf(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 80, grace: 72));
|
||||
Assert.Equal(ClassifierRules.R6Fallthrough, RuleOf(Array.Empty<EnrollmentEvent>()));
|
||||
}
|
||||
|
||||
// ---- Derivation for pre-instrumentation rows ----
|
||||
|
||||
private static SessionSummary Row(SessionStatus status, string? failureReason = null, string? reconcileReason = null,
|
||||
string? failureSource = null, string? adminMarked = null, bool soft = false, string? verdictPath = null) => new()
|
||||
{
|
||||
SessionId = "s", TenantId = "t", Status = status,
|
||||
FailureReason = failureReason ?? string.Empty, ReconcileReason = reconcileReason ?? string.Empty,
|
||||
FailureSource = failureSource ?? string.Empty, AdminMarkedAction = adminMarked, EspSoftFailure = soft,
|
||||
VerdictPath = verdictPath,
|
||||
};
|
||||
|
||||
private static string Classified(IReadOnlyList<EnrollmentEvent> events, double hours = 80, int grace = 72, bool selfDeploying = false)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var (_, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(hours), grace, isSelfDeployingProfile: selfDeploying);
|
||||
return reason;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamped_rows_are_returned_verbatim()
|
||||
{
|
||||
var (path, derived) = VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete, failureReason: "anything", verdictPath: "sweep:r6"));
|
||||
Assert.Equal("sweep:r6", path);
|
||||
Assert.False(derived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derivation_uses_the_real_classifier_literals()
|
||||
{
|
||||
// Reasons come straight from the classifier so a wording change there surfaces here.
|
||||
Assert.Equal(("legacy:r6", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete,
|
||||
failureReason: Classified(Array.Empty<EnrollmentEvent>()))));
|
||||
Assert.Equal(("legacy:r5_incomplete", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete,
|
||||
failureReason: Classified(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }))));
|
||||
Assert.Equal(("legacy:r5_awaiting", true), VerdictPathDerivation.Derive(Row(SessionStatus.AwaitingUser,
|
||||
failureReason: Classified(new[] { Esp(DeviceSetup44), Esp(AccountSetup15) }, hours: 6))));
|
||||
Assert.Equal(("legacy:r3", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete,
|
||||
failureReason: Classified(new[] { Esp(DeviceSetup44), Evt("agent_emergency_break") }))));
|
||||
Assert.Equal(("legacy:r2", true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded,
|
||||
reconcileReason: Classified(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) }))));
|
||||
Assert.Equal(("legacy:r4", true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded,
|
||||
reconcileReason: Classified(new[] { Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed") }))));
|
||||
Assert.Equal(("legacy:r1c", true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded,
|
||||
reconcileReason: Classified(new[] { Esp(DeviceSetup44) }, selfDeploying: true))));
|
||||
Assert.Equal(("legacy:r1", true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed,
|
||||
failureReason: Classified(new[] { Evt("enrollment_failed") }))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derivation_recovers_the_origin_from_writer_suffixes()
|
||||
{
|
||||
var r6 = Classified(Array.Empty<EnrollmentEvent>());
|
||||
Assert.Equal(("maxlife:r6", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete,
|
||||
failureReason: r6 + " Verdict triggered by the agent's max-lifetime watchdog shutdown.")));
|
||||
Assert.Equal(("retro:r6", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete,
|
||||
failureReason: r6 + " Retro-reclassified from the legacy blanket timeout verdict.")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derivation_attributes_unambiguous_non_classifier_paths()
|
||||
{
|
||||
Assert.Equal((VerdictPaths.ManualFailed, true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed,
|
||||
failureReason: "Manually marked as failed by administrator", failureSource: "manual", adminMarked: "Failed")));
|
||||
Assert.Equal((VerdictPaths.ManualSucceeded, true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded, adminMarked: "Succeeded")));
|
||||
Assert.Equal(("rule:ANALYZE-ESP-001", true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed,
|
||||
failureReason: "Rule: ESP timeout", failureSource: "rule:ANALYZE-ESP-001")));
|
||||
Assert.Equal((VerdictPaths.AgentComplete, true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded)));
|
||||
Assert.Equal((VerdictPaths.AgentComplete, true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded,
|
||||
reconcileReason: "Late completion report received — upgraded prior 'Incomplete' verdict")));
|
||||
Assert.Equal((VerdictPaths.AgentCompleteSoft, true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded, soft: true)));
|
||||
Assert.Equal((VerdictPaths.AgentFailed, true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed, failureReason: "ESP timeout (0x800705B4)")));
|
||||
Assert.Equal((VerdictPaths.AgentEspFailureFallback, true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed, failureReason: "ESP failure (backend fallback)")));
|
||||
Assert.Equal((VerdictPaths.SweepStalled, true), VerdictPathDerivation.Derive(Row(SessionStatus.Stalled, failureReason: "Agent silent for 130min (detected by maintenance sweep)")));
|
||||
Assert.Equal((VerdictPaths.AgentStallProbe, true), VerdictPathDerivation.Derive(Row(SessionStatus.Stalled, failureReason: "Agent reported stall after 60min without progress (stall_probe)")));
|
||||
Assert.Equal(("legacy:superseded", true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete, failureReason: "Superseded by session abc: re-registered")));
|
||||
Assert.Equal((VerdictPaths.AgentWhiteGlovePending, true), VerdictPathDerivation.Derive(Row(SessionStatus.Pending)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derivation_never_invents_a_path()
|
||||
{
|
||||
Assert.Equal((VerdictPaths.LegacyUnknown, true), VerdictPathDerivation.Derive(Row(SessionStatus.Failed, failureReason: "Session timed out after 5 hours (started at 2026-01-01 UTC)")));
|
||||
Assert.Equal((VerdictPaths.LegacyUnknown, true), VerdictPathDerivation.Derive(Row(SessionStatus.Incomplete)));
|
||||
Assert.Equal((VerdictPaths.LegacyUnknown, true), VerdictPathDerivation.Derive(Row(SessionStatus.InProgress)));
|
||||
Assert.Equal((VerdictPaths.LegacyUnknown, true), VerdictPathDerivation.Derive(Row(SessionStatus.Succeeded, reconcileReason: "Some future wording")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the
|
||||
/// scheme is a published contract receivers implement against), determinism, and
|
||||
/// secret/message sensitivity.
|
||||
/// </summary>
|
||||
public class WebhookSignatureTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeSignature_MatchesKnownVector()
|
||||
{
|
||||
// Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}")
|
||||
var signature = WebhookSignatureCalculator.ComputeSignature(
|
||||
"whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}");
|
||||
|
||||
Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput()
|
||||
{
|
||||
var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}");
|
||||
|
||||
Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}"));
|
||||
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderNames_AreTheDocumentedContract()
|
||||
{
|
||||
Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader);
|
||||
Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+52
@@ -494,6 +494,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
// --- Email Template Overrides ---
|
||||
|
||||
private const string EmailTemplatesPartition = "EmailTemplates";
|
||||
|
||||
public async Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>(EmailTemplatesPartition, kind);
|
||||
var html = entity.Value.GetString("Html");
|
||||
if (string.IsNullOrEmpty(html)) return null;
|
||||
return new EmailTemplateOverride
|
||||
{
|
||||
Kind = kind,
|
||||
Html = html,
|
||||
UpdatedBy = entity.Value.GetString("UpdatedBy") ?? string.Empty,
|
||||
UpdatedUtc = entity.Value.GetDateTime("UpdatedUtc") ?? DateTime.MinValue,
|
||||
};
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry)
|
||||
{
|
||||
var entity = new TableEntity(EmailTemplatesPartition, overrideEntry.Kind)
|
||||
{
|
||||
["Html"] = overrideEntry.Html,
|
||||
["UpdatedBy"] = overrideEntry.UpdatedBy,
|
||||
["UpdatedUtc"] = overrideEntry.UpdatedUtc == default ? DateTime.UtcNow : overrideEntry.UpdatedUtc,
|
||||
};
|
||||
await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
}
|
||||
|
||||
public async Task DeleteEmailTemplateOverrideAsync(string kind)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _previewConfigTableClient.DeleteEntityAsync(EmailTemplatesPartition, kind);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// already gone — reset is idempotent
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preview Notification Email ---
|
||||
|
||||
public async Task<string?> GetNotificationEmailAsync(string tenantId)
|
||||
@@ -620,6 +668,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "RebootDelaySeconds", config.RebootDelaySeconds },
|
||||
{ "EnableGeoLocation", config.EnableGeoLocation },
|
||||
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
|
||||
{ "EnableDoGroupIdAutoSet", config.EnableDoGroupIdAutoSet },
|
||||
{ "NtpServer", config.NtpServer },
|
||||
{ "EnableImeMatchLog", config.EnableImeMatchLog },
|
||||
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
|
||||
@@ -670,6 +719,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "TrialStartedUtc", config.TrialStartedUtc },
|
||||
{ "TrialConsumed", config.TrialConsumed },
|
||||
{ "TrialGrantedBy", config.TrialGrantedBy },
|
||||
{ "ProDowngradedUtc", config.ProDowngradedUtc },
|
||||
// SLA targets
|
||||
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
|
||||
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
|
||||
@@ -727,6 +777,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
RebootDelaySeconds = entity.GetInt32("RebootDelaySeconds"),
|
||||
EnableGeoLocation = entity.GetBoolean("EnableGeoLocation"),
|
||||
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
|
||||
EnableDoGroupIdAutoSet = entity.GetBoolean("EnableDoGroupIdAutoSet"),
|
||||
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
|
||||
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
|
||||
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
|
||||
@@ -781,6 +832,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
|
||||
TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
|
||||
TrialGrantedBy = entity.GetString("TrialGrantedBy"),
|
||||
ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"),
|
||||
// SLA targets
|
||||
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
|
||||
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user