mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3392a23da6 | |||
| 5312216f6b | |||
| 72392f6f4c | |||
| d65157a092 | |||
| cf45911b69 | |||
| 1593232898 | |||
| e4c06a9564 | |||
| 9c50d98792 | |||
| 20b43e5349 | |||
| d0d1d28371 | |||
| 5191751ed2 | |||
| 907118649b | |||
| 26ecbf33d6 | |||
| adb103fc9e | |||
| 26c8b80efa | |||
| 89c5c7da03 | |||
| 48308df4ff | |||
| cefa57fde5 | |||
| 946d503be0 | |||
| f744ad8406 | |||
| 5e9876bc5f | |||
| ffe88a47c6 | |||
| 1f22b65d57 | |||
| 03afbf6ac6 | |||
| 36172b8cc5 | |||
| 8849a68b1c | |||
| cc195990ff | |||
| 18b28260a7 | |||
| 6cbd507b4f | |||
| 26bcab9054 | |||
| 10308664dd | |||
| 21b8e054df | |||
| 249c20e15a | |||
| 4ebd60fb1e | |||
| 22093d0824 | |||
| dff1532be5 | |||
| 8608ee6272 | |||
| f762b932a2 | |||
| 173bfc40a9 | |||
| 609d9c47af | |||
| 5496d575c1 | |||
| 1c3d29f30b | |||
| bb4accfe96 | |||
| 0ec481b479 | |||
| 3ec716a3ec | |||
| b0bb7233e4 | |||
| 6758dc59e4 | |||
| ffd70e0383 | |||
| c18154d76d | |||
| f524473305 | |||
| 8fe420b902 | |||
| 2e8c2cdb2f | |||
| e04b75dd53 | |||
| d55b0ff864 | |||
| 123ba0e55e | |||
| c8175ff7b1 | |||
| 0efeb0f734 | |||
| 7b975d46b6 | |||
| 7680bdff7b | |||
| 93094653a2 | |||
| ba7d14b070 | |||
| 7f7330e9e5 | |||
| 82934a3719 | |||
| 372f0c7c98 | |||
| 8057b44e48 | |||
| 7842d121a0 | |||
| 4bc2435ec5 | |||
| 1bf37f6761 | |||
| 88da317bf4 | |||
| 1e6de47433 | |||
| 6de21e821a | |||
| 55c898bb9c | |||
| e637317bdb | |||
| 4eac7d042c |
+1
-1
Submodule .claude/commands updated: af1b8468c7...53953bd30b
@@ -194,8 +194,8 @@ jobs:
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
|
||||
@@ -69,9 +69,21 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 141
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -52,7 +52,12 @@ jobs:
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
output_location: "" # Built app content directory - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
|
||||
@@ -53,6 +53,9 @@ __azurite_db*__.json
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
|
||||
# inside flips between .next/types and .next/dev/types) — must not be tracked.
|
||||
next-env.d.ts
|
||||
build/
|
||||
dist/
|
||||
!rules/dist/
|
||||
@@ -148,3 +151,6 @@ plans/REFACTOR_AGENT_V2.md
|
||||
scripts/Backup/setup-backup-container.ps1
|
||||
docs/Introduction-AutopilotMonitor.pptx
|
||||
docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"autopilot-monitor": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.autopilotmonitor.com/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npx", "@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,19 @@ normal IME deployment flow. Spam containment, in order:
|
||||
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
|
||||
(which the backend aligns to the earliest event) is not dragged backwards.
|
||||
|
||||
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
|
||||
events read like a live failure):
|
||||
|
||||
- The scan buffers the (capped) record set, first emits an **Info summary event**
|
||||
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
|
||||
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
|
||||
records **oldest-first** so the timeline sequence follows event-log chronology.
|
||||
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
|
||||
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
|
||||
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
|
||||
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
|
||||
Error filter.
|
||||
|
||||
# Schema — Error codes (backend known-issue map)
|
||||
|
||||
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
|
||||
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
|
||||
tags:
|
||||
- agent
|
||||
- gather-rules
|
||||
- diagnostics
|
||||
- tenant-configuration
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Gather Rule Debug Log
|
||||
|
||||
## Schema
|
||||
|
||||
### The problem
|
||||
|
||||
A gather rule that produces no timeline events is indistinguishable from a rule that
|
||||
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
|
||||
them were visible to the customer:
|
||||
|
||||
| # | Decision point | Where |
|
||||
|---|---|---|
|
||||
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
|
||||
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
|
||||
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
|
||||
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
|
||||
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
|
||||
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
|
||||
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
|
||||
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
|
||||
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
|
||||
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
|
||||
|
||||
### The setting
|
||||
|
||||
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
|
||||
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
|
||||
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
|
||||
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
|
||||
tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
### The trace file
|
||||
|
||||
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
|
||||
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
|
||||
when disabled — zero cost on the normal path). Line format:
|
||||
|
||||
```
|
||||
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
|
||||
```
|
||||
|
||||
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
|
||||
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
|
||||
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
|
||||
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
|
||||
(execution start), `collector` (null/empty results with likely cause), `guard`
|
||||
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
|
||||
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
|
||||
position range, match count, parse failures, regex timeouts, mode; plus the
|
||||
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
|
||||
match** — line number, matched text, capture groups — capped at 10 per file per run),
|
||||
`error` (full exception **with stack trace** — the agent log keeps only the message;
|
||||
an invalid logparser regex is reported here including the offending pattern string).
|
||||
|
||||
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
|
||||
Every write failure is swallowed — tracing must never break gathering. The trace never
|
||||
leaves the device on its own — but because it lives in the agent log folder
|
||||
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
|
||||
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
|
||||
diagnostics upload hands the trace to support without manual copying.
|
||||
|
||||
### Semantics
|
||||
|
||||
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
|
||||
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
|
||||
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
|
||||
trace.
|
||||
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
|
||||
a one-shot standalone `config` line, so the file is never just absent.
|
||||
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
|
||||
default path) and mirrors every line to the console under `--console`.
|
||||
|
||||
## Examples
|
||||
|
||||
A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
|
||||
```
|
||||
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
|
||||
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
|
||||
... | - | phase | phase change: DeviceSetup -> AccountSetup
|
||||
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
|
||||
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
|
||||
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
|
||||
```
|
||||
|
||||
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
|
||||
|
||||
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
|
||||
|
||||
```
|
||||
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
|
||||
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
|
||||
```
|
||||
|
||||
and a broken regex names itself:
|
||||
|
||||
```
|
||||
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
|
||||
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
|
||||
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
|
||||
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
|
||||
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Device Journeys & First-Time-Right (F2)
|
||||
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs
|
||||
tags:
|
||||
- backend
|
||||
- metrics
|
||||
- device-history
|
||||
- first-time-right
|
||||
- insights
|
||||
timestamp: 2026-07-27T12:00:00+02:00
|
||||
---
|
||||
|
||||
# Device Journeys & First-Time-Right (F2)
|
||||
|
||||
Answers "how many devices enrolled right on the first try?" and "show me every attempt
|
||||
this device made". Session success rate hides wipe-and-retry loops — every retry that
|
||||
eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain.
|
||||
All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1);
|
||||
no consumer re-derives them.
|
||||
|
||||
# Schema
|
||||
|
||||
## Device key
|
||||
|
||||
`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder
|
||||
serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day
|
||||
exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`,
|
||||
`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter
|
||||
than 4 characters. A motherboard swap changes the serial and therefore the device key;
|
||||
re-enrollment under a different tenant is a separate key by design.
|
||||
|
||||
## Journey
|
||||
|
||||
Terminal sessions of a device key ordered by StartedAt. The terminal set is
|
||||
**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1),
|
||||
`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts.
|
||||
|
||||
* A journey **ends** with the first terminal success; the next session starts a new
|
||||
journey (redeployment).
|
||||
* A gap of **more than 30 days** since the previous attempt's terminal moment
|
||||
(`CompletedAt`, fallback StartedAt) starts a new journey even without a success
|
||||
(device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h,
|
||||
92 % < 7 d.
|
||||
* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE
|
||||
attempt; a WG device still waiting for its user session leaves the journey open —
|
||||
never a failed attempt.
|
||||
* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are
|
||||
non-successful attempts).
|
||||
* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys.
|
||||
Open and gap-abandoned journeys never enter numerator or denominator.
|
||||
|
||||
Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim
|
||||
(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never
|
||||
`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions.
|
||||
|
||||
## Persistence & compute
|
||||
|
||||
* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of
|
||||
the device's terminal session refs `{sessionId, startedAt, completedAt, status,
|
||||
enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most
|
||||
recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the
|
||||
banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written
|
||||
inline at the session-terminal single-writer seam
|
||||
(`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses,
|
||||
DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep.
|
||||
Deliberately NOT part of the per-session deletion manifest — the row aggregates many
|
||||
sessions. Wiped on tenant offboarding.
|
||||
* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain
|
||||
refs of deleted sessions **tombstone-driven** — every session deletion (cascade and
|
||||
retention both run through the cascade worker) leaves a `SessionTombstones` marker for
|
||||
~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is
|
||||
valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's
|
||||
terminal sessions into their chains (backfill, inline-miss healing, re-terminal
|
||||
reclassifications). (3) Recomputes the daily FTR aggregates idempotently.
|
||||
* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day —
|
||||
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
|
||||
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=…)").
|
||||
|
||||
# Surfaces
|
||||
|
||||
* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query
|
||||
param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`)
|
||||
— the chain plus, with `sessionId`, that session's server-computed attempt number
|
||||
(live sessions get their would-be position via a virtual non-successful attempt, so the
|
||||
redeploy and gap rules apply). `history: null` = no recorded history.
|
||||
* `GET api/metrics/device-journeys?days=` (MemberRead) /
|
||||
`GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) —
|
||||
`{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because
|
||||
counts are additive. Without `tenantId` the global variant serves the cross-tenant
|
||||
aggregate rows and `repeatDevices: null` — a per-device list would require scanning
|
||||
every tenant's partition.
|
||||
* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts
|
||||
and whose newest terminal session falls inside the window, ordered by attempts then
|
||||
recency; the failure reason comes from the newest failed attempt (bounded point-reads).
|
||||
* Web: "Attempt N for this device · View history" banner on session detail (expandable
|
||||
chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt
|
||||
histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode).
|
||||
|
||||
# Examples
|
||||
|
||||
A device fails ESP twice and succeeds on the third try within one week: one journey,
|
||||
three attempts, completed — counts once in the FTR denominator, not in the numerator,
|
||||
and the histogram's bucket `3` gains one. The same device redeployed a month later and
|
||||
succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A
|
||||
device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal
|
||||
sessions surface only as that day's disclosed exclusion count.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests.
|
||||
* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5).
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
type: Concept
|
||||
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
|
||||
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- bootstrap
|
||||
- security
|
||||
- oobe
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# OOBE Bootstrap go-Code Flow
|
||||
|
||||
Bootstrap Sessions let a technician install the agent **before** Intune
|
||||
enrollment: an admin creates a short-lived session in the portal (Settings →
|
||||
Bootstrap Sessions), hands the technician a short URL, and the technician types
|
||||
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
|
||||
during OOBE. The URL is deliberately short because it is typed by hand on a
|
||||
device that has no clipboard.
|
||||
|
||||
# Lifecycle
|
||||
|
||||
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
|
||||
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
|
||||
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
|
||||
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
|
||||
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
|
||||
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
|
||||
`PublicAnonymous`). Customers reach it through the Front Door custom domain
|
||||
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
|
||||
Validation runs in-process (`ValidateCodeAsync` + the tenant's
|
||||
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
|
||||
3. **Install** — the generated script downloads the agent ZIP from
|
||||
`Constants.AgentDownloadBaseUrl`, extracts, and runs
|
||||
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
|
||||
from OOBE start, switches to certificate auth after Intune enrollment, and
|
||||
self-destructs on completion.
|
||||
|
||||
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
|
||||
URLs issued before the migration and is removed with the static-export PR —
|
||||
see the transition rule in [URL Registry](../url-registry.md).
|
||||
|
||||
# Always-200 contract
|
||||
|
||||
Every response — success, invalid format, unknown/expired code, disabled
|
||||
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
|
||||
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
|
||||
exception on the OOBE console, while a 200 error script surfaces the actual
|
||||
message via `Write-Host 'ERROR: …'`. Consequences:
|
||||
|
||||
- Failures are invisible in status-code metrics; the compensating signal is the
|
||||
`LogWarning` per rejection (code + client IP).
|
||||
- Error messages are capped at 200 chars **before** quote-escaping and
|
||||
single-quote-doubled — a hostile upstream message cannot break out of the
|
||||
`Write-Host` literal.
|
||||
|
||||
A disabled tenant returns the **same generic message** as an unknown code — no
|
||||
enumeration oracle for whether a code exists.
|
||||
|
||||
# Injection defense (two lines)
|
||||
|
||||
The script runs as SYSTEM during OOBE, so interpolation is treated as a
|
||||
security boundary:
|
||||
|
||||
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
|
||||
GUIDs, the download URL must be https on an allow-listed host
|
||||
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
|
||||
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
|
||||
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
|
||||
though values are produced in-process — defense-in-depth against tampered
|
||||
table data.
|
||||
2. **Single-quoted PS literals** — every interpolated value sits inside
|
||||
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
|
||||
backticks.
|
||||
|
||||
The template is a C# raw string with `__TOKEN__` placeholders substituted via
|
||||
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
|
||||
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
|
||||
TypeScript template through a golden file, so the legacy route and the backend
|
||||
serve identical scripts during the transition.
|
||||
|
||||
# Rate limiting behind Front Door
|
||||
|
||||
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
|
||||
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
|
||||
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
|
||||
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
|
||||
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
|
||||
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
|
||||
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
|
||||
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
|
||||
IPs are keyed, not AFD egress.
|
||||
|
||||
Front Door route caching is DISABLED for this route: the body inlines the
|
||||
bearer token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
|
||||
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
|
||||
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Rule Regression Radar (F3)
|
||||
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs
|
||||
tags:
|
||||
- backend
|
||||
- rules
|
||||
- regression-radar
|
||||
- insights
|
||||
timestamp: 2026-07-27T16:00:00+02:00
|
||||
---
|
||||
|
||||
# Rule Regression Radar (F3)
|
||||
|
||||
Detects "this analyze rule suddenly fires much more often" before helpdesk volume does, and
|
||||
says what the spike correlates with — with honest statistics and honest wording. Analyze
|
||||
rules only in v1: their daily stats are audit-verified session-deduplicated
|
||||
(`FireCount` ≈ distinct sessions with a hit, `SessionsEvaluated` the same-source
|
||||
denominator). Gather rules are excluded until their per-batch stats dedup is fixed —
|
||||
never silently included with bad math.
|
||||
|
||||
# Schema
|
||||
|
||||
## Signal & detection
|
||||
|
||||
Per (tenant, ruleId), daily hit rate = `FireCount ÷ SessionsEvaluated` from the
|
||||
`RuleStats` rows — RATES, not counts (absolute counts spike with every rollout wave).
|
||||
A regression fires only when ALL hold for the trailing 7-day window vs. the prior
|
||||
28-day baseline:
|
||||
|
||||
1. window has ≥ 5 hit sessions AND ≥ 20 evaluated sessions;
|
||||
2. window rate ≥ 2× baseline rate;
|
||||
3. the Wilson 95 % intervals are disjoint in the increase direction
|
||||
(`MetricsMath.WilsonInterval` / `RateIncreaseSeparated` — deterministic, unit-pinned),
|
||||
so small-n noise never alerts.
|
||||
|
||||
## Suppression (false positives are trust damage)
|
||||
|
||||
* Rule entity missing (deleted) — never alerts.
|
||||
* Rule younger than baseline + window (`CreatedAt` within 35 days — grace period for new
|
||||
rules; the entity timestamp is the honest gate, a stats-row count would fail on
|
||||
low-volume tenants whose quiet days simply have no rows).
|
||||
* Rule edited inside the window (`UpdatedAt` ≥ window start) — edits legitimately change
|
||||
hit rates.
|
||||
* Empty baseline denominator (rule disabled throughout the baseline).
|
||||
* Platform kill switch: app setting `RuleRegressionRadarDisabled=true` skips the radar
|
||||
(fail-open — it only notifies, never mutates data).
|
||||
|
||||
## Alert episodes (tracker keyspace)
|
||||
|
||||
An episode lives as `ruleregression|{ruleId}` in the notification-tracker table
|
||||
(the `tpmpss|` pattern): the row IS the dedup (bell + ops event fire exactly once per
|
||||
episode), the badge state and the `regressions[]` payload — window/baseline counts,
|
||||
rates, lift (null for a zero-baseline "new signal" — no finite lift is invented), window
|
||||
dates, and the dimension concentration captured at first fire. Numbers refresh on every
|
||||
radar pass while the episode stays active; `FirstNotifiedAt` never moves. The episode
|
||||
closes (badge clears, dedup re-arms) when the window rate falls back under 1.5× baseline
|
||||
or fires stop entirely; the tracker's 30-day retention sweep also re-arms, so a
|
||||
month-old still-burning regression rings once more by design.
|
||||
|
||||
## Dimension correlation (on fire only)
|
||||
|
||||
Hit sessions (RuleResults scan: tenant partition range + RowKey = ruleId + DetectedAt in
|
||||
window, intersected with the window's sessions) are compared against all window sessions
|
||||
across `osBuild`, `model` (manufacturer + model), `agentVersion` and `imeVersion`. The
|
||||
top value by lift is reported with BOTH shares — wording contract: "X % of affected
|
||||
sessions are on … vs Y % of all sessions — correlated, not necessarily causal". No value
|
||||
with ≥ 5 hit sessions at ≥ 2× lift → "no clear dimension concentration"; a correlation
|
||||
failure also yields no claim. App-version correlation for `ANALYZE-APP-*` rules is a
|
||||
follow-up (needs rule→app matching semantics), tracked with the gather-rule fix.
|
||||
|
||||
## Compute & alert path
|
||||
|
||||
Runs in the daily maintenance pass right after the rule-stats aggregation
|
||||
(`RunRuleRegressionRadarAsync`), anchored on YESTERDAY — whole days only, a partial
|
||||
today would understate the window rate. One 35-day stats read covers all tenants;
|
||||
evaluation is the pure `RuleRegressionRadar` core. On a NEW episode:
|
||||
|
||||
1. `RuleFrequencyRegression` ops event (category Tenant, Warning) with the full numbers
|
||||
— dual-registered in the web `OPS_EVENT_TYPES` catalog for alert-rule routing.
|
||||
2. Tenant bell notification (type `rule_frequency_regression`, Admin audience) with the
|
||||
same numbers and a deep link to the rule's card (`/analyze-rules#rule-card-{ruleId}`).
|
||||
A sessions-list pre-filtered to rule hits needs a rule filter the session search does
|
||||
not have yet — follow-up alongside the gather-rule work.
|
||||
3. v1 channel: bell + ops event only. Provider fan-out (webhook/Telegram) follows once
|
||||
alert quality is proven — a noisy first version burns trust permanently.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* `GET api/metrics/rule-stats` (and the global variant with `tenantId`) carries
|
||||
`regressions[]` — the active episodes. MCP `get_rule_stats` inherits the block.
|
||||
* Analyze-rules page: per-rule 30-day fire-count sparkline (densified from the `trend`
|
||||
rows the endpoint already served) and a red "↑ Regression" badge while an episode is
|
||||
active, with the full numbers in the tooltip.
|
||||
|
||||
# Examples
|
||||
|
||||
A rule firing on 2 % of sessions for a month jumps to 15 % over a week (15/100 vs
|
||||
10/500): lift 7.5×, Wilson-separated → one bell, one ops event, badge until the rate
|
||||
falls back under 3 %. The same jump on 5/20 sessions with a 12 % baseline stays silent —
|
||||
the intervals overlap, so the spike is not statistically real yet. A rule that never
|
||||
fired in the baseline and now hits 15 % alerts as a "new signal" with no lift figure.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs` — gates, suppression, re-arm, dimension concentration.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs` — Wilson interval + separation primitive.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.RuleRegression.cs` — sweep, episode reconcile, bell/ops wiring.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs` — episode keyspace.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleRegressionRadarTests.cs` — hand-computed Wilson vectors + every suppression branch.
|
||||
* `tasks/insights-expansion-spec.md` — F3 specification and source-data audit (§0.5).
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Enrollment Time Attribution (F1)
|
||||
description: How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs
|
||||
tags:
|
||||
- backend
|
||||
- metrics
|
||||
- time-attribution
|
||||
- insights
|
||||
timestamp: 2026-07-26T20:00:00+02:00
|
||||
---
|
||||
|
||||
# Enrollment Time Attribution (F1)
|
||||
|
||||
Answers "where did the 47 minutes go?" per session and per fleet. Every number is
|
||||
traceable to observed events; a missing signal produces "unknown" or an explicit
|
||||
unattributed remainder — never a guess, and the partition is never normalized to 100 %.
|
||||
|
||||
# Schema
|
||||
|
||||
## Invariant
|
||||
|
||||
For every computed breakdown:
|
||||
|
||||
```
|
||||
sum(span seconds) + UnattributedSeconds == WallClockSeconds == session DurationSeconds
|
||||
```
|
||||
|
||||
`DurationSeconds` is the authoritative wall clock — for WhiteGlove it is part 1 + part 2
|
||||
WITHOUT the pause, and re-terminal stamps move `CompletedAt`, so `CompletedAt − StartedAt`
|
||||
is never used (it diverges in ~25 % of terminal sessions). Span seconds are floored;
|
||||
sub-second dust lands in the unattributed remainder.
|
||||
|
||||
## Observation windows
|
||||
|
||||
Windows are end-anchored on authoritative timestamps and constructed so their lengths sum
|
||||
exactly to `DurationSeconds`:
|
||||
|
||||
* Non-WhiteGlove: one window `[CompletedAt − DurationSeconds, CompletedAt]`.
|
||||
* WhiteGlove (ONE session row, two windows): part 2 = `[ResumedAt, CompletedAt]`; part 1 =
|
||||
`[part1End − (DurationSeconds − part2), part1End]` where `part1End` is anchored on the
|
||||
`whiteglove_part1_complete` event. The pause between the windows is never attributed.
|
||||
Fallback anchors set the `WhiteGloveAnchorsIncomplete` quality flag.
|
||||
|
||||
## Segments
|
||||
|
||||
Spans are sliced at phase-declaration events (only declaration events carry
|
||||
`Phase != Unknown`) plus `desktop_arrived`, in canonical Sequence order; anchors whose
|
||||
timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
|
||||
| EnrollmentPhase | Segment key |
|
||||
|---|---|
|
||||
| Start / DevicePreparation / DeviceSetup | `device_prep` |
|
||||
| AppsDevice | `esp_apps` |
|
||||
| AccountSetup / FinalizingSetup | `identity_hello` |
|
||||
| AppsUser | `user_esp` |
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
event, covering IME retries) — never the agent payload timing, which freezes at the first
|
||||
terminal transition. Blocking membership joins the app id against the session's latest
|
||||
list-carrying `esp_config_detected` emission: listed ⇒ blocking; absent ⇒ unknown, never
|
||||
false (`BlockingSetUnknown` / `BlockingSetTruncated` flags). Critical-path occupancy =
|
||||
overlap-merged union of blocking intervals clipped to the `esp_apps` spans;
|
||||
`EspAppsOccupancySeconds` is null (unknown) when no lists were observed.
|
||||
|
||||
The fleet what-if bound per app X is `max(0, cpEnd − cpEndWithoutX)` (latest interval end
|
||||
with vs. without X; the idle gap before X counts — the ESP also waits for X to start).
|
||||
It is an upper bound by construction; every consumer says "up to", never "you will save".
|
||||
|
||||
## Reboots
|
||||
|
||||
`system_reboot_detected` is detection-time-stamped by the NEXT agent run; the real boot
|
||||
moment is its `lastBootUtc` payload. Reboot spans are therefore the event-stream gap
|
||||
bracketing `lastBootUtc`, deduped per boot, clipped to the observation windows, and carried
|
||||
as a cross-cutting annotation (they overlap the segment they started in — never a slice of
|
||||
the partition).
|
||||
|
||||
## Persistence & compute
|
||||
|
||||
* `SessionTimeBreakdowns` (PK = TenantId, RK = SessionId): spans, reboot spans, blocking
|
||||
intervals (top 20 + uncapped count), quality flags, `AttributionVersion`. Written once at
|
||||
the session-terminal single-writer seam (`TableSessionRepository.UpdateSessionStatusAsync`,
|
||||
Succeeded/Failed only — Incomplete deliberately has no duration). Deleted with its session
|
||||
(deletion-manifest step) and on tenant offboarding.
|
||||
* 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.
|
||||
* `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
|
||||
stack (incl. `unattributed`; a session without a segment contributes 0), plus top blocking
|
||||
apps (≥5 sessions per app row). Clean/flagged/missing counts are part of every row; rows
|
||||
exist below the ≥20 UI gate on purpose (the UI needs the n for "insufficient data (n=…)").
|
||||
Daily rows retain 180 days; `rolling30` rows are refreshed whole (a median of per-day
|
||||
medians is not the range median — the rolling rows are the honest range statistics).
|
||||
|
||||
## Surfaces
|
||||
|
||||
* `GET api/sessions/{sessionId}/time-attribution` (MemberRead + tenant query param) —
|
||||
breakdown row or `breakdown: null` (pre-feature / non-terminal / Incomplete = no lane).
|
||||
* `GET api/metrics/time-attribution` (MemberRead) / `GET api/global/metrics/time-attribution`
|
||||
(GlobalReadOrAdmin, optional `tenantId`) — `{ windowDays, classes (rolling), daily }`.
|
||||
* Web: attribution lane under the session-detail phase timeline; "Time attribution" section
|
||||
on Fleet Health. MCP: `get_time_attribution` (sessionId mode / fleet mode).
|
||||
|
||||
# Examples
|
||||
|
||||
A 30-minute user-driven session: `device_prep` 5m → `esp_apps` 10m (occupancy 4m by one
|
||||
blocking app) → `identity_hello` 8m → `user_esp` 5m → `desktop_handoff` 2m, unattributed 0.
|
||||
A WhiteGlove session with a 2-day pause reports wall clock = part1 + part2 only; the resume
|
||||
gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
* `tasks/insights-expansion-spec.md` — F1 specification and source-data audit (§0.5).
|
||||
@@ -20,6 +20,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [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).
|
||||
* [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.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [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.
|
||||
@@ -32,12 +33,20 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# Backend
|
||||
|
||||
* [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.
|
||||
* [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.
|
||||
* [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.
|
||||
* [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.
|
||||
* [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.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
|
||||
# Web Portal
|
||||
|
||||
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
|
||||
|
||||
# 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.
|
||||
|
||||
+26
@@ -1,5 +1,27 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
|
||||
|
||||
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
|
||||
|
||||
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
|
||||
|
||||
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
|
||||
|
||||
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
|
||||
|
||||
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
|
||||
|
||||
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
|
||||
|
||||
## 2026-07-25
|
||||
|
||||
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
|
||||
@@ -52,6 +74,10 @@
|
||||
|
||||
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
|
||||
|
||||
## 2026-07-26
|
||||
|
||||
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
|
||||
|
||||
## 2026-07-17
|
||||
|
||||
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
|
||||
|
||||
+37
-1
@@ -21,7 +21,7 @@ literal appears nowhere else.
|
||||
| Component | Registry | Guard test |
|
||||
|---|---|---|
|
||||
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
|
||||
|
||||
Derived registries build on these instead of repeating hosts:
|
||||
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
|
||||
`build-agent.yml` — they exist precisely to keep already-deployed customer
|
||||
bootstrap scripts working until the customer migration completes.
|
||||
|
||||
# Bootstrap go-URL (2026-07-28)
|
||||
|
||||
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
|
||||
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
|
||||
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
|
||||
Function App (`GetBootstrapScriptFunction`). Registry entries:
|
||||
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
|
||||
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
|
||||
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
|
||||
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
|
||||
independent oracle — before this, no test guarded the bootstrap URL shape at
|
||||
all.
|
||||
|
||||
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
|
||||
the `/agent/*` route above, sharpened: the script body inlines the bearer
|
||||
token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
|
||||
`/api/bootstrap/go/` prefix.
|
||||
|
||||
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
|
||||
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
|
||||
`middleware.ts` are deleted — verified safe because the BootstrapSessions
|
||||
table held zero active sessions predating the URL cutover (all expired
|
||||
2026-03). The backend is now the only script producer;
|
||||
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
|
||||
shipped template.
|
||||
|
||||
Portal detail routes are query-string based since the same PR
|
||||
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
|
||||
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
|
||||
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
|
||||
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
|
||||
carries the production redirects/headers and is guarded by
|
||||
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
|
||||
unsafe-eval, route order).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
|
||||
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
|
||||
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
|
||||
tags:
|
||||
- web
|
||||
- performance
|
||||
- nextjs
|
||||
- static-web-apps
|
||||
- observability
|
||||
timestamp: 2026-07-27T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
|
||||
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
|
||||
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
|
||||
navigation therefore carries no page data — only the shell.
|
||||
|
||||
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
|
||||
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
|
||||
it enters the viewport.
|
||||
|
||||
# Mechanism
|
||||
|
||||
Two properties combine into a portal-wide freeze:
|
||||
|
||||
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
|
||||
RSC request per visible link — measured at ~10,400 requests over 14 days.
|
||||
2. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
|
||||
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
|
||||
|
||||
The navigation the user wants is then stuck behind that queue and the tab shows a loading spinner or a
|
||||
blank page until it drains. Nothing fails — the requests eventually return `200`, which is why the
|
||||
failure leaves no error trace anywhere.
|
||||
|
||||
> **Correction (2026-07-28).** The original version of this document named a third property: that
|
||||
> `portal.autopilotmonitor.com` negotiates HTTP/1.1 and the prefetches exhaust the six-connection
|
||||
> browser cap. **That is wrong.** It came from `curl -w '%{http_version}'` on a Windows build of curl
|
||||
> whose Schannel backend has no HTTP/2 support, so it can only ever report `1.1`. ALPN says otherwise:
|
||||
> `openssl s_client -alpn h2,http/1.1` negotiates **h2** on the portal host, confirmed by a .NET
|
||||
> `HttpClient` request returning `HTTP/2.0`. There is no six-connection cap and connection starvation
|
||||
> was never the mechanism — the queue is server-side. Verify HTTP versions with `openssl s_client
|
||||
> -alpn` or `HttpClient`, never with this curl build.
|
||||
|
||||
# Evidence
|
||||
|
||||
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
|
||||
|
||||
| Request | Duration | Status |
|
||||
| --- | --- | --- |
|
||||
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
|
||||
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
|
||||
| `/progress?_rsc=…` | 37,878 ms | 200 |
|
||||
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
|
||||
| `/audit?_rsc=…` | 37,539 ms | 200 |
|
||||
| Backend API, same window | 1,600–2,200 ms | 200 |
|
||||
|
||||
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
|
||||
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
|
||||
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
|
||||
document immediately and the browser then waited nearly four minutes for the `load` event.
|
||||
|
||||
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
|
||||
38 over 10 s, 21 over 30 s.
|
||||
|
||||
# Outcome, measured 2026-07-28
|
||||
|
||||
Two changes landed within half an hour of each other on 2026-07-27: the SWA plan went Free → Standard
|
||||
at 16:02:44 UTC, and `prefetch={false}` went live at 16:34:13 UTC. Twenty hours of production
|
||||
telemetry separate their effects.
|
||||
|
||||
**The Standard plan produced no measurable latency gain.** Latency is governed almost entirely by the
|
||||
gap to the previous request — the SSR runtime cools down — so raw percentiles only measure the traffic
|
||||
mix. Stratified by that gap, the two eras are indistinguishable (p50, Free → Standard): `<10s`
|
||||
85 → 95 ms · `10-60s` 100 → 117 ms · `1-5min` 295 → 310 ms · `5-30min` 429 → 441 ms. Tail rates match
|
||||
too: Free ran 1.64 % over 2 s and 0.374 % over 10 s (n = 10,163); Standard produced 6 and 1 events at
|
||||
n = 258 against 4.2 and 1.0 expected. `browserTimings.totalDuration` by Mann-Whitney: n = 436 vs 48,
|
||||
z = −0.24, **p = 0.81**.
|
||||
|
||||
**The prefetch fix is what removed the freezes** — worst `browserTimings.totalDuration` fell from
|
||||
225,277 ms to 4,856 ms, and the 30-second events stopped.
|
||||
|
||||
**It also removed an accidental keep-alive.** The prefetch storm kept the SSR runtime warm: 85 % of
|
||||
requests used to arrive less than 10 s apart, now 46 % do. On an identical never-cacheable route
|
||||
(`/sessions/{id}`) the median rose from 87 ms to 211 ms and p90 from 465 ms to 1,480 ms. After 517
|
||||
minutes idle, one request took 11,441 ms and was abandoned (`resultCode 0`). The trade — minute-long
|
||||
freezes gone, ~150 ms added to a cold navigation — is worth it, but it is a trade.
|
||||
|
||||
Do not benchmark this with a synthetic burst. Firing requests in quick succession warms the runtime
|
||||
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
|
||||
and rounds 4–25 at p50 113 ms.
|
||||
|
||||
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
|
||||
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
|
||||
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
|
||||
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
|
||||
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
|
||||
|
||||
# Diagnosing this class of problem
|
||||
|
||||
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
|
||||
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
|
||||
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
|
||||
`query_backend_logs` tool does not reach. Query it through
|
||||
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
|
||||
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
|
||||
a document load).
|
||||
|
||||
# Contract
|
||||
|
||||
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
|
||||
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
|
||||
chunk, which the browser caches after first use.
|
||||
|
||||
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
|
||||
Application Insights resource — the prefetch is cheap only while the runtime is warm.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
|
||||
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
|
||||
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
|
||||
@@ -1,369 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -5,18 +5,28 @@
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest
|
||||
(legacy fallback: Content-MD5 header from the blob response)
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
@@ -27,16 +37,35 @@
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -55,17 +84,20 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0"
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -85,26 +117,19 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,30 +144,65 @@ try {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard).
|
||||
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
$profileNames = @(
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
@@ -166,7 +226,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
@@ -181,38 +241,47 @@ try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
@@ -227,29 +296,17 @@ try {
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
if ($expectedSha256) {
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
else {
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test (DEV)
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[string]$Status,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
$prefix = switch ($Status) {
|
||||
'PASS' { '[PASS]' }
|
||||
'SKIP' { '[SKIP]' }
|
||||
'WARN' { '[WARN]' }
|
||||
'INFO' { '[INFO]' }
|
||||
default { '[....]' }
|
||||
}
|
||||
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
$wouldInstall = $true
|
||||
$reasons = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Previously deployed marker exists: $deployed")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
try {
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Result ==='
|
||||
Write-Host ''
|
||||
|
||||
if ($wouldInstall) {
|
||||
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
|
||||
} else {
|
||||
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Summary ==='
|
||||
|
||||
if ($reasons.Count -eq 0) {
|
||||
Write-Host 'No blocking reasons found.'
|
||||
} else {
|
||||
foreach ($reason in $reasons) {
|
||||
Write-Host " - $reason"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
Write-Host "UptimeHours : $([string]$uptimeHours)"
|
||||
Write-Host "AgentExePath : $AgentExePath"
|
||||
Write-Host ''
|
||||
@@ -1,8 +1,11 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
@@ -38,6 +41,19 @@ function Get-RegistryValueSafe {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
@@ -58,63 +74,79 @@ if ($deployed) {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard)
|
||||
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
$profileNames = @(
|
||||
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object {
|
||||
-not $_.Special -and
|
||||
$_.LocalPath -like 'C:\Users\*'
|
||||
} |
|
||||
ForEach-Object {
|
||||
Split-Path $_.LocalPath -Leaf
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
# Filesystem view -- catches profiles WMI might miss
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) |
|
||||
Where-Object {
|
||||
$_ -and $_ -notmatch $excludePattern
|
||||
} |
|
||||
Select-Object -Unique
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
if ($profileNames) {
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check
|
||||
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
|
||||
# This is "how old can the OOBE state be before I no longer trust it for initial install".
|
||||
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
|
||||
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
@@ -134,7 +166,7 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 4: Agent binary already present
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
@@ -168,6 +200,11 @@ Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
+48
@@ -285,6 +285,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_true_to_default_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = null;
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = true;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(agent.GatherRuleDebugLogPath));
|
||||
Assert.Equal(
|
||||
Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath),
|
||||
agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_maps_enable_gather_rule_debug_log_false_clears_path()
|
||||
{
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Null(agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_preserves_custom_gather_rule_debug_log_path_when_remote_disables()
|
||||
{
|
||||
// A dev-time --gather-debug-log <custom> override seeds a non-default path on the
|
||||
// AgentConfiguration. When the tenant remote flips EnableGatherRuleDebugLog off we
|
||||
// keep the custom path rather than clobbering a deliberate dev override.
|
||||
var agent = NewAgentConfig();
|
||||
agent.GatherRuleDebugLogPath = @"C:\Temp\custom-gather.log";
|
||||
|
||||
var remote = FullRemote();
|
||||
remote.EnableGatherRuleDebugLog = false;
|
||||
|
||||
RemoteConfigMerger.Merge(agent, remote);
|
||||
|
||||
Assert.Equal(@"C:\Temp\custom-gather.log", agent.GatherRuleDebugLogPath);
|
||||
}
|
||||
|
||||
// ============================================================= Null / partial tolerance
|
||||
|
||||
[Fact]
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using DeviceInfoCollector = AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q2 — <see cref="DeviceInfoCollector.RefreshEspConfiguration"/>
|
||||
/// re-reads the ESP blocking lists and re-emits <c>esp_config_detected</c> exactly when the
|
||||
/// payload changed: the registry lists grow progressively (one timestamped subkey per CSP
|
||||
/// status write) and the user-scope lists appear only after sign-in, so the early emissions
|
||||
/// are structurally partial. The StartupEventGate keeps an unchanged re-read silent.
|
||||
/// Probe overrides are static slots → serialized with the other probe-driven suites.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class DeviceInfoCollectorEspRefreshTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 7, 27, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string DeviceApp1 = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceApp2 = "22222222-2222-2222-2222-222222222222";
|
||||
private const string UserApp1 = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
private static EspTrackingInfoSnapshot Snapshot(string[] deviceApps, string[] userApps)
|
||||
{
|
||||
var all = deviceApps.Concat(userApps).Distinct().ToArray();
|
||||
return new EspTrackingInfoSnapshot(
|
||||
msiProductCodes: Array.Empty<string>(),
|
||||
modernAppPfns: Array.Empty<string>(),
|
||||
win32AppIds: all,
|
||||
userWin32AppIds: userApps,
|
||||
msiCount: 0,
|
||||
modernCount: 0,
|
||||
win32Count: all.Length);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> EspConfigEvents(FakeSignalIngressSink ingress) =>
|
||||
ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.EspConfigDetected)
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<string> TypedList(FakeSignalIngressSink.PostedSignal signal, string key)
|
||||
{
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-1", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: false, skipDevice: false,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
|
||||
// Early read (agent start / DeviceSetup detection): partial device list, empty user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
var afterEarly = EspConfigEvents(ingress);
|
||||
Assert.Single(afterEarly);
|
||||
Assert.Empty(TypedList(afterEarly[0], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// Apps sub-phase opened: the device list grew and sign-in populated the user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsUser);
|
||||
}
|
||||
var afterGrowth = EspConfigEvents(ingress);
|
||||
Assert.Equal(2, afterGrowth.Count);
|
||||
Assert.Contains(DeviceApp2, TypedList(afterGrowth[1], "espTrackedWin32AppIds"));
|
||||
Assert.Equal(new[] { UserApp1 }, TypedList(afterGrowth[1], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// A later trigger with an UNCHANGED registry state re-reads but emits nothing —
|
||||
// the StartupEventGate suppresses the identical payload.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAccountSetup);
|
||||
}
|
||||
Assert.Equal(2, EspConfigEvents(ingress).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_refreshes_never_emit_an_older_snapshot_after_a_newer_one()
|
||||
{
|
||||
// Codex review: the refresh triggers each run on their own Task.Run — without the
|
||||
// collector-side lock, two collects can interleave read-before-emit and the LATER
|
||||
// emission carries the OLDER (smaller) list. The backend keeps the latest
|
||||
// list-bearing emission as positive evidence, so that would silently shrink it.
|
||||
// The probe returns a strictly growing list per read; with the read→emit critical
|
||||
// section, emission order must equal read order — sizes strictly ascending.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-1", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: false, skipDevice: false,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
|
||||
var readCounter = 0;
|
||||
using var tracking = new EspTrackingInfoProbe.ScopedOverride(_ =>
|
||||
{
|
||||
var n = Interlocked.Increment(ref readCounter);
|
||||
var ids = Enumerable.Range(1, n)
|
||||
.Select(i => $"{i:d8}-1111-2222-3333-444444444444")
|
||||
.ToArray();
|
||||
return Snapshot(ids, Array.Empty<string>());
|
||||
});
|
||||
|
||||
var tasks = Enumerable.Range(0, 12)
|
||||
.Select(i => Task.Run(() => collector.RefreshEspConfiguration($"stress-{i}")))
|
||||
.ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var sizes = EspConfigEvents(ingress)
|
||||
.Select(e => TypedList(e, "espTrackedWin32AppIds").Count)
|
||||
.ToList();
|
||||
// Every refresh saw a strictly larger list (gate never suppresses) and each
|
||||
// read→emit is atomic, so the emission sequence is exactly 1..12.
|
||||
Assert.Equal(Enumerable.Range(1, 12), sizes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Writer unit tests for <see cref="GatherRuleDebugLog"/>: line format, lazy directory
|
||||
/// creation, size-cap rotation, and the null-writer no-op path on the context.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public GatherRuleDebugLogTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
|
||||
|
||||
[Fact]
|
||||
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
Assert.False(File.Exists(LogPath)); // lazy — nothing until the first write
|
||||
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageScope, "skipped: out of scope");
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Equal(2, lines.Length); // header + entry
|
||||
Assert.Contains("| - | config | -- gather rule debug log started --", lines[0]);
|
||||
var parts = lines[1].Split(new[] { " | " }, StringSplitOptions.None);
|
||||
Assert.Equal(4, parts.Length);
|
||||
Assert.Equal("RULE-1", parts[1]);
|
||||
Assert.Equal("scope", parts[2]);
|
||||
Assert.Equal("skipped: out of scope", parts[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_flattens_newlines_except_for_error_stage()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageCollector, "line1\r\nline2");
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageError, "boom\r\n at Frame()");
|
||||
|
||||
var content = File.ReadAllText(LogPath);
|
||||
Assert.Contains("line1 line2", content);
|
||||
Assert.Contains(" at Frame()", content); // error stage keeps the stack multi-line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_null_ruleId_renders_dash()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger);
|
||||
log.Write(null, GatherRuleDebugLog.StageConfig, "no rules delivered");
|
||||
|
||||
Assert.Contains(" | - | config | no rules delivered", File.ReadAllText(LogPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_rotates_once_when_file_exceeds_cap()
|
||||
{
|
||||
var log = new GatherRuleDebugLog(LogPath, _logger, maxBytes: 512);
|
||||
for (int i = 0; i < 20; i++)
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
|
||||
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
|
||||
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
|
||||
Assert.True(File.Exists(LogPath));
|
||||
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
|
||||
// Never more than one .old generation
|
||||
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(LogPath), "*.old.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteStandalone_writes_single_config_line()
|
||||
{
|
||||
GatherRuleDebugLog.WriteStandalone(LogPath, "no gather rules delivered by backend — nothing to execute", _logger);
|
||||
|
||||
var lines = File.ReadAllLines(LogPath);
|
||||
Assert.Contains(lines, l => l.Contains("no gather rules delivered by backend"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_DebugLog_is_noop_without_writer()
|
||||
{
|
||||
var context = new GatherRuleContext(
|
||||
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
|
||||
|
||||
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
|
||||
|
||||
Assert.False(File.Exists(LogPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="GatherRuleExecutor"/> (EnableGatherRuleDebugLog):
|
||||
/// every "silently produced nothing" outcome must leave an explanatory line in the trace
|
||||
/// file — registration summary, missing interval, scope skips, on_change suppression,
|
||||
/// empty collector results.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorDebugLogTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant", _ => { }, logger,
|
||||
debugLogPath: _debugLogPath)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string id, Action<GatherRule>? mutate = null)
|
||||
{
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
mutate?.Invoke(rule);
|
||||
return rule;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_writes_registration_summary_per_rule()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-REG-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; }),
|
||||
Rule("GATHER-REG-002", r => r.Enabled = false),
|
||||
});
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-REG-001 | config | registered: trigger=interval, collector=registry", trace);
|
||||
Assert.Contains("emitMode=on_change", trace);
|
||||
Assert.Contains("interval=3600s", trace);
|
||||
Assert.Contains("GATHER-REG-002 | config | rule disabled — will never run", trace);
|
||||
Assert.Contains("interval timer scheduled every 3600s (first run after one full interval", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateRules_flags_interval_rule_without_intervalSeconds()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-NOINT-001", r => { r.Trigger = "interval"; r.IntervalSeconds = null; }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-NOINT-001 | error | trigger=interval but intervalSeconds is missing — rule will NEVER run",
|
||||
TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedRule_skip_traces_unknown_phase_reason()
|
||||
{
|
||||
var rule = Rule("GATHER-SCOPE-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.ActivePhases = new List<string> { "DeviceSetup" }; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
// Synchronous internal check mirrors the timer-tick gate; the reason must name Unknown.
|
||||
string reason;
|
||||
Assert.False(_executor.IsRuleInScope(rule, out reason));
|
||||
Assert.Contains("currentPhase=Unknown", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedStartupRule_deferral_is_traced()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-DEFER-001", r => r.ActivePhases = new List<string> { "DeviceSetup" }),
|
||||
});
|
||||
|
||||
Assert.Contains("GATHER-DEFER-001 | scope | startup rule deferred — currentPhase=Unknown", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnChange_suppression_and_reemission_are_traced()
|
||||
{
|
||||
var rule = Rule("GATHER-OC-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
string hashPrefix;
|
||||
int streak;
|
||||
var first = new Dictionary<string, object> { ["exists"] = false };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, first, out hashPrefix, out streak));
|
||||
Assert.Equal(0, streak);
|
||||
Assert.False(string.IsNullOrEmpty(hashPrefix));
|
||||
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(1, streak);
|
||||
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak);
|
||||
|
||||
var changed = new Dictionary<string, object> { ["exists"] = true };
|
||||
Assert.True(_executor.ShouldEmitOnChange(rule, changed, out hashPrefix, out streak));
|
||||
Assert.Equal(2, streak); // the streak that just ended
|
||||
Assert.Equal(2, changed["suppressedPolls"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChange_writes_transition_line()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
|
||||
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
|
||||
// A repeated signal for the same phase must not produce a second transition line.
|
||||
var marker = "phase change: DeviceSetup -> AccountSetup";
|
||||
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCollectorResult_is_traced_via_startup_execution()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
Rule("GATHER-EMPTY-001", r => r.Parameters = new Dictionary<string, string> { ["emitOnlyIfExists"] = "true" }),
|
||||
});
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("GATHER-EMPTY-001 | exec | executing: collector=registry", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | registry key/value not found", trace);
|
||||
Assert.Contains("GATHER-EMPTY-001 | collector | collector returned EMPTY result — nothing emitted", trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-trace coverage of <see cref="LogParserCollector"/>: the classic
|
||||
/// "rule delivers nothing" cases must be explained in the trace — zero matches,
|
||||
/// position-tracker skips, and plain-text logs parsed in the default cmtrace mode.
|
||||
/// </summary>
|
||||
public sealed class LogParserCollectorDebugLogTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly string _debugLogPath;
|
||||
|
||||
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
|
||||
// test assembly instead, admitted via UnrestrictedMode.
|
||||
private readonly string _outsideUsers = Path.Combine(
|
||||
AppContext.BaseDirectory, "gather-debug-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public LogParserCollectorDebugLogTests()
|
||||
{
|
||||
Directory.CreateDirectory(_outsideUsers);
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_context = new GatherRuleContext(
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
null,
|
||||
new LogFilePositionTracker(),
|
||||
new GatherRuleDebugLog(_debugLogPath, logger))
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
|
||||
|
||||
private static GatherRule Rule(string target, string pattern, string? format = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-LP-DEBUG",
|
||||
Title = "logparser debug test",
|
||||
CollectorType = "logparser",
|
||||
Target = target,
|
||||
Parameters = format == null
|
||||
? new Dictionary<string, string> { ["pattern"] = pattern }
|
||||
: new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = format },
|
||||
Trigger = "startup",
|
||||
OutputEventType = "logparser_debug_test",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ZeroMatches_writes_per_file_outcome_line()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "app.log");
|
||||
File.WriteAllText(path, "nothing interesting\nstill nothing\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "WILL_NEVER_MATCH_[0-9]+", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("app.log: read 2 lines", trace);
|
||||
Assert.Contains("matched 0", trace);
|
||||
Assert.Contains("mode=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondRun_without_new_content_traces_position_skip()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "twice.log");
|
||||
File.WriteAllText(path, "one line\n");
|
||||
var rule = Rule(path, "one", format: "text");
|
||||
|
||||
var collector = new LogParserCollector();
|
||||
collector.Execute(rule, _context); // reads the file, advances the position
|
||||
collector.Execute(rule, _context); // nothing new
|
||||
|
||||
Assert.Contains("twice.log: no new content (position", TraceContent());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainTextFile_in_default_cmtrace_mode_gets_format_hint()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "plain.log");
|
||||
File.WriteAllText(path, "plain line one\nplain line two\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "plain"), _context); // default = cmtrace mode
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("parseFailures=2", trace);
|
||||
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_traces_line_number_matched_text_and_groups()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "match.log");
|
||||
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
|
||||
Assert.Contains("groups: code=\"0x80070005\"", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchTraces_are_capped_per_file()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "many.log");
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (var i = 1; i <= 15; i++)
|
||||
sb.Append("hit number ").Append(i).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
|
||||
|
||||
Assert.Equal(15, _events.Count); // all matches still emit events
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("many.log: line 10 matched", trace);
|
||||
Assert.Contains("further matches not traced individually", trace);
|
||||
Assert.DoesNotContain("many.log: line 11 matched", trace);
|
||||
Assert.Contains("matched 15", trace); // summary still carries the full count
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRegex_traces_the_pattern_itself()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "irrelevant.log");
|
||||
File.WriteAllText(path, "content\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("invalid regex pattern '([unclosed'", trace);
|
||||
Assert.Contains("rule can never match", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingFile_traces_no_files_matched()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "does-not-exist.log");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "x", format: "text"), _context);
|
||||
|
||||
Assert.Contains("no files matched target", TraceContent());
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
|
||||
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
|
||||
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
|
||||
/// absent, never throw) on unrecognized ones.
|
||||
/// </summary>
|
||||
public sealed class NetshWifiFallbackTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_english_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
There is 1 interface on the system:
|
||||
|
||||
Name : Wi-Fi
|
||||
Description : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
|
||||
State : connected
|
||||
SSID : contoso-corp
|
||||
AP BSSID : 3c:37:12:53:c8:44
|
||||
Band : 5 GHz
|
||||
Channel : 116
|
||||
Radio type : 802.11ax
|
||||
Signal : 94%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
Assert.Equal(94, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(116, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_german_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Es ist 1 Schnittstelle auf dem System vorhanden:
|
||||
|
||||
Name : WLAN
|
||||
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
Status : Verbunden
|
||||
SSID : fabrikam-wlan
|
||||
Kanal : 44
|
||||
Funktyp : 802.11ac
|
||||
Signal : 87%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("fabrikam-wlan", r!.Ssid);
|
||||
Assert.Equal(87, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(44, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_spanish_output_extracts_signal_and_channel()
|
||||
{
|
||||
const string output = @"
|
||||
Nombre : Wi-Fi
|
||||
SSID : contoso-es
|
||||
Canal : 6
|
||||
Tipo de radio : 802.11n
|
||||
Señal : 72%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-es", r!.Ssid);
|
||||
Assert.Equal(72, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(6, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_chinese_simplified_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
名称 : WLAN
|
||||
SSID : contoso-cn
|
||||
信道 : 149
|
||||
无线电类型 : 802.11ac
|
||||
信号 : 68%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-cn", r!.Ssid);
|
||||
Assert.Equal(68, r.SignalPercent);
|
||||
Assert.Equal("802.11ac", r.RadioType);
|
||||
Assert.Equal(149, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_korean_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
이름 : Wi-Fi
|
||||
SSID : contoso-kr
|
||||
채널 : 36
|
||||
라디오 종류 : 802.11ax
|
||||
신호 : 91%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-kr", r!.Ssid);
|
||||
Assert.Equal(91, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(36, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_signal_with_space_before_percent_is_parsed()
|
||||
{
|
||||
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(81, r!.SignalPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ap_bssid_does_not_overwrite_ssid()
|
||||
{
|
||||
const string output = @"
|
||||
SSID : contoso-corp
|
||||
AP BSSID : aa:bb:cc:dd:ee:ff
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.Equal("contoso-corp", r!.Ssid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_finnish_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nimi : Wi-Fi
|
||||
SSID : contoso-fi
|
||||
Kanava : 100
|
||||
Radiotyyppi : 802.11ax
|
||||
Signaali : 79%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-fi", r!.Ssid);
|
||||
Assert.Equal(79, r.SignalPercent);
|
||||
Assert.Equal("802.11ax", r.RadioType);
|
||||
Assert.Equal(100, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_indonesian_output_extracts_all_fields()
|
||||
{
|
||||
const string output = @"
|
||||
Nama : Wi-Fi
|
||||
SSID : contoso-id
|
||||
Saluran : 11
|
||||
Jenis radio : 802.11n
|
||||
Sinyal : 64%
|
||||
";
|
||||
var r = NetshWifiFallback.Parse(output);
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-id", r!.Ssid);
|
||||
Assert.Equal(64, r.SignalPercent);
|
||||
Assert.Equal("802.11n", r.RadioType);
|
||||
Assert.Equal(11, r.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_without_ssid_returns_null()
|
||||
{
|
||||
// Greek is not in the enrollment-geography label set -> nothing to report.
|
||||
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_unknown_language_still_yields_ssid()
|
||||
{
|
||||
// SSID is untranslated in all locales — partial result beats none.
|
||||
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("contoso-gr", r!.Ssid);
|
||||
Assert.Null(r.SignalPercent);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
|
||||
public void Parse_empty_or_error_output_returns_null(string? output)
|
||||
{
|
||||
Assert.Null(NetshWifiFallback.Parse(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
|
||||
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
|
||||
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
|
||||
/// exactly — their output feeds wifi_signal_info event payloads and must stay
|
||||
/// consistent with the strings netsh historically reported.
|
||||
/// </summary>
|
||||
public sealed class WifiInfoReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
|
||||
{
|
||||
var result = WifiInfoReader.TryGetCurrentConnection();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
|
||||
Assert.InRange(result.SignalPercent.Value, 0, 100);
|
||||
if (result.Channel.HasValue)
|
||||
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
|
||||
}
|
||||
}
|
||||
|
||||
// ----- DecodeSsid ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_decodes_utf8_bytes()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
|
||||
ssid.CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_returns_null_for_empty_or_missing()
|
||||
{
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
|
||||
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_clamps_length_to_buffer_size()
|
||||
{
|
||||
var raw = Encoding.UTF8.GetBytes("abc");
|
||||
|
||||
// A corrupt native length beyond the buffer must not throw.
|
||||
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeSsid_uses_only_declared_length()
|
||||
{
|
||||
var raw = new byte[32];
|
||||
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
|
||||
|
||||
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
|
||||
}
|
||||
|
||||
// ----- PhyTypeToRadioType -------------------------------------------
|
||||
|
||||
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
|
||||
// public test method signature (CS0051).
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
|
||||
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
|
||||
{
|
||||
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
|
||||
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
|
||||
[InlineData(9999)]
|
||||
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
|
||||
{
|
||||
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -226,6 +226,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
return archive.Entries.Select(e => e.FullName).ToArray();
|
||||
}
|
||||
|
||||
private static string ZipEntryText(byte[] zipBytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry!.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// ── Nullable outcome (on-demand mid-session) — gate + sessioninfo semantics ──
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel subclass: throws before any HTTP so gate-pass-through is observable as a
|
||||
/// non-null error result (a skip returns null BEFORE the archive is ever built).
|
||||
/// </summary>
|
||||
private sealed class ThrowOnBuildService : DiagnosticsPackageService
|
||||
{
|
||||
public ThrowOnBuildService(AgentConfiguration cfg, AgentLogger logger, BackendApiClient api)
|
||||
: base(cfg, logger, api) { }
|
||||
internal override byte[] BuildArchiveBytes(bool? enrollmentSucceeded) =>
|
||||
throw new System.InvalidOperationException("gate-passed-sentinel");
|
||||
}
|
||||
|
||||
private static DiagnosticsPackageService BuildGateProbe(AgentLogger logger, string mode)
|
||||
{
|
||||
var cfg = Cfg();
|
||||
cfg.DiagnosticsUploadEnabled = true;
|
||||
cfg.DiagnosticsUploadMode = mode;
|
||||
var apiClient = new BackendApiClient(
|
||||
httpClient: new System.Net.Http.HttpClient(),
|
||||
baseUrl: "http://localhost",
|
||||
manufacturer: string.Empty,
|
||||
model: string.Empty,
|
||||
serialNumber: string.Empty,
|
||||
useBootstrapTokenAuth: false,
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: logger);
|
||||
return new ThrowOnBuildService(cfg, logger, apiClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_passes_OnFailure_gate()
|
||||
{
|
||||
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
|
||||
// there is no known success to skip on. Reaching the archive build (sentinel
|
||||
// throw → error result) proves the gate was passed; a skip would return null.
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("gate-passed-sentinel", result!.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_known_success_still_skipped_by_OnFailure_gate()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: true);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_still_blocked_by_mode_Off()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "Off")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "Succeeded")]
|
||||
[InlineData(false, "Failed")]
|
||||
[InlineData(null, "In Progress")]
|
||||
public void BuildArchiveBytes_sessioninfo_reflects_nullable_outcome(bool? outcome, string expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var bytes = rig.Build().BuildArchiveBytes(outcome);
|
||||
|
||||
var sessionInfo = ZipEntryText(bytes, "sessioninfo.txt");
|
||||
Assert.Contains($"Enrollment Result: {expected}", sessionInfo);
|
||||
}
|
||||
|
||||
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
|
||||
|
||||
[Theory]
|
||||
|
||||
+30
@@ -112,6 +112,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal(false, data["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_EmitsInfoLogEvent_AnnouncingTheReplayBlock()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 6, droppedOverCap: 0);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
Assert.Equal("modern_deployment_log", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(posted.TypedPayload);
|
||||
Assert.Equal(true, data["backfillSummary"]);
|
||||
Assert.Equal(6, data["replayedCount"]);
|
||||
Assert.Equal(0, data["droppedOverCap"]);
|
||||
Assert.Equal(240, data["lookbackMinutes"]);
|
||||
|
||||
var message = (string)posted.Payload[SignalPayloadKeys.Message];
|
||||
Assert.Contains("Replaying 6 error event(s)", message);
|
||||
Assert.Contains("before the agent started", message);
|
||||
Assert.DoesNotContain("cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorBackfillSummary_MentionsDroppedRecords_WhenCapWasHit()
|
||||
{
|
||||
_tracker.EmitErrorBackfillSummary(replayedCount: 25, droppedOverCap: 7);
|
||||
|
||||
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
|
||||
var message = (string)posted.Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("7 older record(s) beyond the 25-record cap", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledHarmlessId_StillRunsThroughRollupSuppression()
|
||||
{
|
||||
|
||||
+36
@@ -37,5 +37,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.InformationalEvent, Phase("FinalizingSetup")));
|
||||
}
|
||||
|
||||
// Audit Q2 — the payload shape the adapter's apps sub-phase declaration produces on the
|
||||
// ingress rail (InformationalEventPost: eventType + declared phase enum name).
|
||||
private static IReadOnlyDictionary<string, string> SubPhaseDeclaration(string declaredPhase) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition },
|
||||
{ AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter.PhaseParamKey, declaredPhase },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Apps_subphase_declarations_classify_as_esp_config_refresh_triggers()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsDevice,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsDevice")));
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsUser,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsUser")));
|
||||
// Non-apps phase declarations (e.g. the reducer's Finalizing entry) never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("FinalizingSetup")));
|
||||
// A phase_transition without a declared phase never classifies.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountSetup_phase_classifies_as_the_user_setup_refresh_trigger()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAccountSetup,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("AccountSetup")));
|
||||
// DeviceSetup is the enrollment-start trigger, not an ESP-config refresh.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("DeviceSetup")));
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
// Unrelated informational events never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, "esp_config_detected" } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q5 — WMI hardware values are trimmed at the source. BIOS serials become device
|
||||
/// identity keys downstream (F2 device history joins on TenantId + serial), so padding
|
||||
/// must never leave the agent; whitespace-only values collapse to the "Unknown" sentinel
|
||||
/// the retry logic already treats as unresolved.
|
||||
/// </summary>
|
||||
public sealed class HardwareInfoNormalizeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" PF60H941 ", "PF60H941")]
|
||||
[InlineData("PF60H941", "PF60H941")]
|
||||
[InlineData("\tLENOVO \r\n", "LENOVO")]
|
||||
[InlineData("Unknown", "Unknown")]
|
||||
public void Trims_whitespace_at_the_source(string raw, string expected)
|
||||
=> Assert.Equal(expected, HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t\r\n")]
|
||||
public void Empty_and_whitespace_only_collapse_to_the_Unknown_sentinel(string raw)
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Fact]
|
||||
public void Null_collapses_to_the_Unknown_sentinel()
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(null));
|
||||
}
|
||||
}
|
||||
+82
@@ -243,5 +243,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
Assert.Equal(firstStamp, adapter.AppTimings["app-P"].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_to_active_retry_rearms_CompletedAt_and_final_terminal_covers_total_occupancy()
|
||||
{
|
||||
// Audit Q1: the IME retry path (Error -> Downloading -> Installed is permitted by
|
||||
// AppPackageState.UpdateState). Pre-fix, CompletedAt froze at the first Error, so
|
||||
// the final Installed payload carried the first failure's completedAt/duration.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-R", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installing);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(5));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Error);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(10));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Error, AppInstallationState.Downloading);
|
||||
|
||||
// Re-armed while active again: the retry's download event carries no completedAt,
|
||||
// and the FIRST start stamp is kept.
|
||||
Assert.Null(adapter.AppTimings["app-R"].CompletedAtUtc);
|
||||
var retryDownload = f.InfoEvent(SharedEventTypes.AppDownloadStarted);
|
||||
Assert.False(retryDownload.Payload!.ContainsKey("completedAt"));
|
||||
Assert.False(retryDownload.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal(T0.ToString("o"), retryDownload.Payload["startedAt"]);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-R"];
|
||||
Assert.Equal(T0, timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(35), timing.CompletedAtUtc);
|
||||
Assert.Equal(35.0, timing.DurationSeconds);
|
||||
|
||||
var completed = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal("35.00", completed.Payload!["durationSeconds"]);
|
||||
Assert.Equal(T0.AddSeconds(35).ToString("o"), completed.Payload["completedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skipped_then_retried_app_emits_positive_duration_from_retry_start()
|
||||
{
|
||||
// Production regression (session 66f1c5e1, audit Q1): a user-scope app evaluated
|
||||
// Skipped during DeviceSetup (terminal, no start observed), retried after sign-in
|
||||
// (Skipped -> Downloading is permitted), Installed later. Pre-fix payload:
|
||||
// completedAt frozen at the early Skip + startedAt from the retry ->
|
||||
// durationSeconds=-2342. Post-fix: retry re-arms, duration covers retry-start ->
|
||||
// final terminal.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-S", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Skipped);
|
||||
Assert.Null(adapter.AppTimings["app-S"].StartedAtUtc);
|
||||
Assert.Equal(T0, adapter.AppTimings["app-S"].CompletedAtUtc);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(60));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Skipped, AppInstallationState.Downloading);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(30));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-S"];
|
||||
Assert.Equal(T0.AddSeconds(60), timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(90), timing.CompletedAtUtc);
|
||||
Assert.Equal(30.0, timing.DurationSeconds);
|
||||
|
||||
var completedEvents = f.InfoEvents(SharedEventTypes.AppInstallComplete);
|
||||
Assert.Equal(2, completedEvents.Count);
|
||||
Assert.Equal("30.00", completedEvents[1].Payload!["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inverted_endpoints_yield_null_DurationSeconds()
|
||||
{
|
||||
// Negative guard (audit Q1): endpoints stamped from out-of-order source lines must
|
||||
// never surface as a negative durationSeconds (observed -2342s in production). The
|
||||
// payload emitter omits the field when DurationSeconds is null.
|
||||
var inverted = new AppInstallTiming(startedAtUtc: T0.AddSeconds(100), completedAtUtc: T0);
|
||||
Assert.Null(inverted.DurationSeconds);
|
||||
|
||||
var wellOrdered = new AppInstallTiming(startedAtUtc: T0, completedAtUtc: T0.AddSeconds(100));
|
||||
Assert.Equal(100.0, wellOrdered.DurationSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -511,6 +511,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
$"network_bandwidth_estimate (@{bandwidthIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the straggler stays in Part 1.");
|
||||
}
|
||||
|
||||
// ===================================================== WG Part-1 preprov diagnostics
|
||||
// V1 parity (restored 2026-07-28): the seal exit uploads the diagnostics package as a
|
||||
// pre-provisioning intermediate under the SAME mode gating as the terminal path — the
|
||||
// seal counts as a success, so Always uploads, OnFailure skips (a Part-1 failure
|
||||
// terminates via the regular Failed path, which uploads), Off does nothing.
|
||||
|
||||
[Theory]
|
||||
[InlineData("Always", true, 1)]
|
||||
[InlineData("OnFailure", true, 0)]
|
||||
[InlineData("Off", true, 0)]
|
||||
[InlineData("Always", false, 0)]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_respects_mode(
|
||||
string mode, bool enabled, int expected)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(expected, rig.DiagnosticsUploads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_upload_uses_preprov_suffix_and_succeeded_flag()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(1, rig.DiagnosticsUploads);
|
||||
Assert.Equal("preprov", rig.LastDiagnosticsSuffix);
|
||||
Assert.Equal(true, rig.LastDiagnosticsSucceededFlag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_diagnostics_events_precede_part1_complete_marker()
|
||||
{
|
||||
// whiteglove_part1_complete is the authoritative Part-1/Part-2 boundary for the
|
||||
// web split (computeWhiteGloveSplitSequence) — diagnostics_collecting/uploaded
|
||||
// must sequence BEFORE it or they get mis-filed into the resumed Part-2 block.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
var emitted = rig.EmittedEventTypes.ToList();
|
||||
var collectingIdx = emitted.IndexOf("diagnostics_collecting");
|
||||
var uploadedIdx = emitted.IndexOf("diagnostics_uploaded");
|
||||
var part1CompleteIdx = emitted.IndexOf(Constants.EventTypes.WhiteGlovePart1Complete);
|
||||
|
||||
Assert.True(collectingIdx >= 0, "diagnostics_collecting must have been emitted.");
|
||||
Assert.True(uploadedIdx >= 0, "diagnostics_uploaded must have been emitted.");
|
||||
Assert.True(part1CompleteIdx >= 0, "whiteglove_part1_complete must have been emitted.");
|
||||
Assert.True(uploadedIdx < part1CompleteIdx,
|
||||
$"diagnostics_uploaded (@{uploadedIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the upload stays in Part 1.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_whiteglove_part1_still_skips_cleanup_and_marker_when_diagnostics_enabled()
|
||||
{
|
||||
// The preprov upload must not change the Part-1 exit contract: no self-destruct,
|
||||
// no enrollment-complete.marker, whiteglove.complete still written.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
|
||||
|
||||
Assert.Equal(0, rig.CleanupService.Invocations);
|
||||
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
|
||||
Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
|
||||
{
|
||||
|
||||
+79
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
@@ -70,5 +72,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(0, bootToStart, 0);
|
||||
Assert.Equal(10, uptime, 0);
|
||||
}
|
||||
|
||||
// --- DescribeLateStart: outcome-calibrated severity + phrasing (fleet 2026-07-28) ---
|
||||
|
||||
private static void Describe(
|
||||
EnrollmentTerminationOutcome outcome, string oobeState,
|
||||
out EventSeverity severity, out string message, out string note)
|
||||
=> ObservationCoverage.DescribeLateStart(
|
||||
outcome, oobeState, bootToStartSeconds: 34 * 60, uptimeSeconds: 30,
|
||||
out severity, out message, out note);
|
||||
|
||||
[Fact]
|
||||
public void Failed_outcome_is_a_warning_with_the_hung_script_post_mortem_note()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("low coverage of the enrollment window", message);
|
||||
Assert.Contains("post-mortem", note);
|
||||
Assert.Contains("hung ahead of the bootstrap", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedOut_outcome_is_a_warning_like_a_failure()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.TimedOut, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("post-mortem", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
|
||||
{
|
||||
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("succeeded", note);
|
||||
Assert.Contains("IME log replay", note);
|
||||
Assert.DoesNotContain("post-mortem", note);
|
||||
Assert.DoesNotContain("hung", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_completed_at_start_phrases_a_pure_post_mortem()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "completed",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was already completed when the agent started", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_in_progress_at_start_phrases_the_live_tail()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was still in progress at agent start", message);
|
||||
Assert.Contains("final 30s live", message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not_started")] // flips in after a mid-OOBE reboot — not interpretable
|
||||
[InlineData("unavailable")] // WinRT contract absent / read failed
|
||||
[InlineData("unknown_3")] // unexpected enum value
|
||||
public void Uninterpretable_oobe_states_add_no_claim_to_the_message(string oobeState)
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, oobeState,
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.DoesNotContain("OOBE", message);
|
||||
Assert.EndsWith("low coverage of the enrollment window.", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public string ImeMatchLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to write a gather-rule evaluation trace (for debugging).
|
||||
/// If empty, no trace is written. Set from remote config (EnableGatherRuleDebugLog)
|
||||
/// or the --gather-debug-log CLI override.
|
||||
/// </summary>
|
||||
public string GatherRuleDebugLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory with real IME log files for log replay.
|
||||
/// When set, the agent replays these logs with time compression instead of reading live logs.
|
||||
|
||||
@@ -135,6 +135,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- GatherRuleDebugLog: same bool→path projection as ImeMatchLog
|
||||
// above. `true` expands Constants.GatherRuleDebugLogPath, `false`
|
||||
// nulls the runtime path unless a --gather-debug-log CLI override
|
||||
// supplied a non-default path.
|
||||
if (remoteConfig.EnableGatherRuleDebugLog)
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultGatherDebugPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
if (string.IsNullOrEmpty(agentConfig.GatherRuleDebugLogPath)
|
||||
|| string.Equals(agentConfig.GatherRuleDebugLogPath, defaultGatherDebugPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
agentConfig.GatherRuleDebugLogPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
|
||||
// misconfigured tenant can in theory break uploads, but this
|
||||
// surfaces the config bug instead of silently masking it).
|
||||
|
||||
@@ -327,6 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
KeepLogFile = false,
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
EnableGatherRuleDebugLog = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
|
||||
+10
-5
@@ -10,9 +10,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// the <c>app_tracking_summary</c> terminal event.
|
||||
/// <para>
|
||||
/// <b>StartedAtUtc</b> is recorded on the first lifecycle transition into Downloading /
|
||||
/// Installing / InProgress. <b>CompletedAtUtc</b> is recorded on the first transition to a
|
||||
/// terminal state (Installed / Skipped / Postponed / Error). Both are "set-once" — subsequent
|
||||
/// identical-state events do not overwrite an earlier stamp.
|
||||
/// Installing / InProgress and never overwritten. <b>CompletedAtUtc</b> is recorded on a
|
||||
/// transition to a terminal state (Installed / Skipped / Postponed / Error) and re-armed
|
||||
/// to <c>null</c> when the app goes terminal → active again (IME retry / re-evaluation,
|
||||
/// audit Q1) — so the final terminal event carries first-start → last-terminal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AppInstallTiming
|
||||
@@ -30,10 +31,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Elapsed time from first Downloading/Installing transition to terminal state, in
|
||||
/// seconds. <c>null</c> when either endpoint is missing (e.g. app still installing,
|
||||
/// or the adapter missed the start event because it came online mid-install).
|
||||
/// or the adapter missed the start event because it came online mid-install) — and
|
||||
/// <c>null</c> when the endpoints are inverted (CompletedAt before StartedAt: log lines
|
||||
/// from different source files can be processed out of chronological order; observed in
|
||||
/// production as durationSeconds=-2342 on a Skipped-then-retried user-scope app,
|
||||
/// audit Q1). A negative duration is never a real measurement, so it is not emitted.
|
||||
/// </summary>
|
||||
public double? DurationSeconds =>
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue && CompletedAtUtc.Value >= StartedAtUtc.Value
|
||||
? (CompletedAtUtc.Value - StartedAtUtc.Value).TotalSeconds
|
||||
: (double?)null;
|
||||
}
|
||||
|
||||
+81
-22
@@ -631,10 +631,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// the regular <see cref="ProcessRecord"/> pipeline with isBackfill=true, so they arrive
|
||||
/// as modern_deployment_error events carrying backfilled=true plus the original
|
||||
/// timeCreated; the event's own timestamp is emission time, so session StartedAt is not
|
||||
/// dragged backwards. Newest-first, capped, watermark-deduped across restarts.
|
||||
/// dragged backwards. The scan reads newest-first (the cap keeps the newest records),
|
||||
/// but replay is buffered so an Info summary event announces the block BEFORE the errors
|
||||
/// hit the timeline (customer feedback 2026-07-28: an unannounced wall of pre-agent
|
||||
/// Error events reads like a live failure) and the records emit oldest-first, giving the
|
||||
/// timeline sequence the original event-log chronology. Watermark-deduped across restarts.
|
||||
/// </summary>
|
||||
private void BackfillAutopilotErrors()
|
||||
{
|
||||
var buffered = new List<EventRecord>();
|
||||
try
|
||||
{
|
||||
long watermark;
|
||||
@@ -649,7 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ReverseDirection = true
|
||||
};
|
||||
|
||||
int processed = 0, skippedWatermark = 0, droppedOverCap = 0;
|
||||
int skippedWatermark = 0, droppedOverCap = 0;
|
||||
long maxSeen = watermark;
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
@@ -657,37 +662,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
var recordId = record.RecordId ?? 0;
|
||||
if (recordId > maxSeen) maxSeen = recordId;
|
||||
|
||||
if (recordId != 0 && recordId <= watermark)
|
||||
{
|
||||
skippedWatermark++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (processed >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
continue;
|
||||
}
|
||||
|
||||
processed++;
|
||||
ProcessRecord(record, "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
skippedWatermark++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buffered.Count >= ErrorBackfillMaxRecords)
|
||||
{
|
||||
droppedOverCap++;
|
||||
record.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
buffered.Add(record);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxSeen > watermark)
|
||||
AdvanceAutopilotErrorWatermark(maxSeen);
|
||||
|
||||
if (processed == 0 && droppedOverCap == 0)
|
||||
if (buffered.Count == 0 && droppedOverCap == 0)
|
||||
{
|
||||
_logger.Debug($"ModernDeployment error backfill: nothing new (skippedByWatermark={skippedWatermark})");
|
||||
}
|
||||
else
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {processed} record(s) " +
|
||||
{
|
||||
EmitErrorBackfillSummary(buffered.Count, droppedOverCap);
|
||||
|
||||
// Scan buffered newest-first — replay in reverse so the emitted sequence
|
||||
// follows the original event-log chronology.
|
||||
for (var i = buffered.Count - 1; i >= 0; i--)
|
||||
{
|
||||
ProcessRecord(buffered[i], "Autopilot", AutopilotChannel, isBackfill: true);
|
||||
}
|
||||
|
||||
_logger.Info($"ModernDeployment error backfill: forwarded {buffered.Count} record(s) " +
|
||||
$"(skippedByWatermark={skippedWatermark}, droppedOverCap={droppedOverCap})");
|
||||
}
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
@@ -701,6 +717,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_logger.Warning($"ModernDeployment error backfill failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var record in buffered)
|
||||
{
|
||||
try { record.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces the error-backfill block on the session timeline BEFORE the replayed
|
||||
/// errors, so pre-agent Error events are readable as history rather than a live
|
||||
/// failure. The web timeline additionally badges each replayed event via its
|
||||
/// backfilled=true payload marker; this summary carries backfillSummary=true instead —
|
||||
/// it is a live emission, not a replayed record.
|
||||
/// </summary>
|
||||
internal void EmitErrorBackfillSummary(int replayedCount, int droppedOverCap)
|
||||
{
|
||||
var message = $"[Autopilot] Replaying {replayedCount} error event(s) recorded in the Autopilot event log " +
|
||||
$"before the agent started (lookback {ErrorBackfillLookbackMinutes / 60}h). " +
|
||||
"These predate monitoring — if enrollment progressed afterwards, they were already resolved by a retry.";
|
||||
if (droppedOverCap > 0)
|
||||
{
|
||||
message += $" {droppedOverCap} older record(s) beyond the {ErrorBackfillMaxRecords}-record cap were not replayed.";
|
||||
}
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ModernDeploymentLog,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ModernDeploymentWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "backfillSummary", true },
|
||||
{ "replayedCount", replayedCount },
|
||||
{ "droppedOverCap", droppedOverCap },
|
||||
{ "lookbackMinutes", ErrorBackfillLookbackMinutes }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
|
||||
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
|
||||
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
|
||||
/// context on builds where inbox netsh still reports the connection — until that is fully
|
||||
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
|
||||
/// <para>
|
||||
/// netsh labels follow the OS UI language, so the parser matches the label variants of
|
||||
/// every language implied by the enrollment geography seen to date (see the comment on
|
||||
/// the key arrays for the country→language derivation). Unknown-language labels fall
|
||||
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
|
||||
/// itself is untranslated in all known locales.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class NetshWifiFallback
|
||||
{
|
||||
private const int NetshTimeoutMs = 5000;
|
||||
|
||||
// Label variants per field, by OS UI language. Matched case-insensitively against the
|
||||
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
|
||||
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
|
||||
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
|
||||
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
|
||||
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
|
||||
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
|
||||
// MN/GE/AZ usually en/ru images → ru.
|
||||
private static readonly string[] SignalKeys =
|
||||
{
|
||||
"Signal", // en, de, fr, sv, da, nb, hr
|
||||
"Señal", // es
|
||||
"Sinal", // pt
|
||||
"Segnale", // it
|
||||
"Signaal", // nl
|
||||
"Sygnał", // pl
|
||||
"Signál", // cs, sk
|
||||
"Sinyal", // tr, id
|
||||
"Isyarat", // ms
|
||||
"Signaali", // fi
|
||||
"Signalas", // lt
|
||||
"Signāls", // lv
|
||||
"Jel", // hu
|
||||
"Semnal", // ro
|
||||
"Tín hiệu", // vi
|
||||
"สัญญาณ", // th
|
||||
"الإشارة", // ar
|
||||
"إشارة", // ar (variant without article)
|
||||
"Сигнал", // ru, bg
|
||||
"シグナル", // ja
|
||||
"信号", // zh-CN
|
||||
"訊號", // zh-TW
|
||||
"신호", // ko
|
||||
};
|
||||
|
||||
private static readonly string[] RadioTypeKeys =
|
||||
{
|
||||
"Radio type", // en
|
||||
"Funktyp", // de
|
||||
"Type de radio", // fr
|
||||
"Tipo de radio", // es
|
||||
"Tipo de rádio", // pt
|
||||
"Tipo di radio", // it
|
||||
"Radiotype", // nl, da
|
||||
"Radiotyp", // sv
|
||||
"Typ radia", // pl
|
||||
"Typ rádia", // cs, sk
|
||||
"Radyo türü", // tr
|
||||
"Jenis radio", // id, ms
|
||||
"Radiotyyppi", // fi
|
||||
"Radijo tipas", // lt
|
||||
"Radio tips", // lv
|
||||
"Rádió típusa", // hu
|
||||
"Tip radio", // ro
|
||||
"Vrsta radija", // hr, bs
|
||||
"Loại radio", // vi
|
||||
"ชนิดวิทยุ", // th
|
||||
"نوع الراديو", // ar
|
||||
"Тип радио", // ru
|
||||
"Тип на радиото", // bg
|
||||
"無線の種類", // ja
|
||||
"无线电类型", // zh-CN
|
||||
"無線電類型", // zh-TW
|
||||
"라디오 종류", // ko
|
||||
"무선 종류", // ko (variant)
|
||||
};
|
||||
|
||||
private static readonly string[] ChannelKeys =
|
||||
{
|
||||
"Channel", // en
|
||||
"Kanal", // de, sv, da, nb, tr, hr
|
||||
"Canal", // fr, es, pt, ro
|
||||
"Canale", // it
|
||||
"Kanaal", // nl
|
||||
"Kanał", // pl
|
||||
"Kanál", // cs, sk
|
||||
"Saluran", // id, ms
|
||||
"Kanava", // fi
|
||||
"Kanalas", // lt
|
||||
"Kanāls", // lv
|
||||
"Csatorna", // hu
|
||||
"Kênh", // vi
|
||||
"ช่องสัญญาณ", // th
|
||||
"ช่อง", // th (variant)
|
||||
"القناة", // ar
|
||||
"قناة", // ar (variant without article)
|
||||
"Канал", // ru, bg
|
||||
"チャネル", // ja
|
||||
"信道", // zh-CN
|
||||
"通道", // zh-TW / zh-CN (variant)
|
||||
"채널", // ko
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
|
||||
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
|
||||
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
|
||||
/// labels). Never throws.
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo TryRead()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(NetshTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Parse(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser over the netsh output text. Internal for direct unit-testing via
|
||||
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
|
||||
/// </summary>
|
||||
internal static WifiConnectionInfo Parse(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return null;
|
||||
|
||||
string ssid = null, radioType = null;
|
||||
int? signal = null, channel = null;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (MatchesAny(key, SignalKeys))
|
||||
{
|
||||
// Value formats seen: "94%", "94% " (trailing space), "94 %".
|
||||
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (MatchesAny(key, RadioTypeKeys))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
else if (MatchesAny(key, ChannelKeys))
|
||||
{
|
||||
if (int.TryParse(value, out var ch))
|
||||
channel = ch;
|
||||
}
|
||||
}
|
||||
|
||||
if (ssid == null && signal == null && radioType == null && channel == null)
|
||||
return null;
|
||||
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = ssid,
|
||||
SignalPercent = signal,
|
||||
RadioType = radioType,
|
||||
Channel = channel,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesAny(string key, string[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Current WiFi connection details as read from the native WLAN API.
|
||||
/// </summary>
|
||||
public sealed class WifiConnectionInfo
|
||||
{
|
||||
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
|
||||
public string Ssid { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
|
||||
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
|
||||
/// </summary>
|
||||
public int? SignalPercent { get; internal set; }
|
||||
|
||||
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
|
||||
public string RadioType { get; internal set; }
|
||||
|
||||
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
|
||||
public int? Channel { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
|
||||
/// connection or null — never throws, never blocks on a child process. Machines without
|
||||
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
|
||||
/// </summary>
|
||||
public static class WifiInfoReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
|
||||
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
|
||||
/// first; otherwise — or when it is not connected — the first connected WLAN interface
|
||||
/// wins. Returns null when no interface has an active connection.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
|
||||
=> TryGetCurrentConnection(preferredInterfaceId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
|
||||
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
|
||||
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
|
||||
/// access gating on newer Windows builds) — callers log this at Debug so field issues
|
||||
/// are explainable from the agent log instead of a silent missing event.
|
||||
/// </summary>
|
||||
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
|
||||
{
|
||||
var diag = new StringBuilder();
|
||||
var clientHandle = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanOpenHandle rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var interfaceList = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diagnostics = $"WlanEnumInterfaces rc={rc}";
|
||||
return null;
|
||||
}
|
||||
|
||||
var count = (uint)Marshal.ReadInt32(interfaceList);
|
||||
if (count == 0)
|
||||
{
|
||||
diagnostics = "no WLAN interfaces";
|
||||
return null;
|
||||
}
|
||||
|
||||
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
|
||||
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
|
||||
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
|
||||
|
||||
WifiConnectionInfo fallback = null;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
|
||||
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
|
||||
|
||||
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
|
||||
if (connection == null)
|
||||
continue;
|
||||
|
||||
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
|
||||
{
|
||||
diagnostics = null;
|
||||
return connection;
|
||||
}
|
||||
|
||||
if (fallback == null)
|
||||
fallback = connection;
|
||||
}
|
||||
|
||||
diagnostics = fallback == null ? diag.ToString() : null;
|
||||
return fallback;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (interfaceList != IntPtr.Zero)
|
||||
WlanFreeMemory(interfaceList);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (clientHandle != IntPtr.Zero)
|
||||
WlanCloseHandle(clientHandle, IntPtr.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
|
||||
if (rc != ERROR_SUCCESS)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
|
||||
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
|
||||
|
||||
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
|
||||
{
|
||||
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
|
||||
return null;
|
||||
}
|
||||
|
||||
var assoc = attributes.wlanAssociationAttributes;
|
||||
return new WifiConnectionInfo
|
||||
{
|
||||
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
|
||||
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
|
||||
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
|
||||
Channel = QueryChannel(clientHandle, interfaceGuid),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
|
||||
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
return null;
|
||||
|
||||
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
|
||||
return null;
|
||||
|
||||
return Marshal.ReadInt32(dataPtr);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (dataPtr != IntPtr.Zero)
|
||||
WlanFreeMemory(dataPtr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string DecodeSsid(byte[] raw, uint length)
|
||||
{
|
||||
if (raw == null || length == 0)
|
||||
return null;
|
||||
|
||||
var byteCount = (int)Math.Min(length, (uint)raw.Length);
|
||||
try
|
||||
{
|
||||
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
|
||||
return string.IsNullOrEmpty(ssid) ? null : ssid;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
|
||||
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
|
||||
{
|
||||
switch (phyType)
|
||||
{
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
|
||||
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
|
||||
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
|
||||
/// <para>
|
||||
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
|
||||
/// output, whose labels follow the OS UI language and silently drop fields on
|
||||
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
|
||||
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
|
||||
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class WlanNativeMethods
|
||||
{
|
||||
internal const uint ERROR_SUCCESS = 0;
|
||||
|
||||
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
|
||||
internal const uint WLAN_CLIENT_VERSION = 2;
|
||||
|
||||
internal enum WLAN_INTF_OPCODE : uint
|
||||
{
|
||||
wlan_intf_opcode_current_connection = 7,
|
||||
wlan_intf_opcode_channel_number = 8,
|
||||
}
|
||||
|
||||
internal enum WLAN_INTERFACE_STATE
|
||||
{
|
||||
wlan_interface_state_not_ready = 0,
|
||||
wlan_interface_state_connected = 1,
|
||||
wlan_interface_state_ad_hoc_network_formed = 2,
|
||||
wlan_interface_state_disconnecting = 3,
|
||||
wlan_interface_state_disconnected = 4,
|
||||
wlan_interface_state_associating = 5,
|
||||
wlan_interface_state_discovering = 6,
|
||||
wlan_interface_state_authenticating = 7,
|
||||
}
|
||||
|
||||
internal enum DOT11_PHY_TYPE : uint
|
||||
{
|
||||
dot11_phy_type_unknown = 0,
|
||||
dot11_phy_type_fhss = 1,
|
||||
dot11_phy_type_dsss = 2,
|
||||
dot11_phy_type_irbaseband = 3,
|
||||
dot11_phy_type_ofdm = 4, // 802.11a
|
||||
dot11_phy_type_hrdsss = 5, // 802.11b
|
||||
dot11_phy_type_erp = 6, // 802.11g
|
||||
dot11_phy_type_ht = 7, // 802.11n
|
||||
dot11_phy_type_vht = 8, // 802.11ac
|
||||
dot11_phy_type_dmg = 9, // 802.11ad
|
||||
dot11_phy_type_he = 10, // 802.11ax
|
||||
dot11_phy_type_eht = 11, // 802.11be
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_INTERFACE_INFO
|
||||
{
|
||||
public Guid InterfaceGuid;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strInterfaceDescription;
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct DOT11_SSID
|
||||
{
|
||||
public uint uSSIDLength;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] ucSSID;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_ASSOCIATION_ATTRIBUTES
|
||||
{
|
||||
public DOT11_SSID dot11Ssid;
|
||||
public int dot11BssType;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
|
||||
public byte[] dot11Bssid;
|
||||
public DOT11_PHY_TYPE dot11PhyType;
|
||||
public uint uDot11PhyIndex;
|
||||
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
|
||||
public uint ulRxRate;
|
||||
public uint ulTxRate;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WLAN_SECURITY_ATTRIBUTES
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bSecurityEnabled;
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool bOneXEnabled;
|
||||
public int dot11AuthAlgorithm;
|
||||
public int dot11CipherAlgorithm;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WLAN_CONNECTION_ATTRIBUTES
|
||||
{
|
||||
public WLAN_INTERFACE_STATE isState;
|
||||
public int wlanConnectionMode;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strProfileName;
|
||||
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
|
||||
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
|
||||
}
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanOpenHandle(
|
||||
uint dwClientVersion,
|
||||
IntPtr pReserved,
|
||||
out uint pdwNegotiatedVersion,
|
||||
out IntPtr phClientHandle);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanCloseHandle(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanEnumInterfaces(
|
||||
IntPtr hClientHandle,
|
||||
IntPtr pReserved,
|
||||
out IntPtr ppInterfaceList);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern uint WlanQueryInterface(
|
||||
IntPtr hClientHandle,
|
||||
[In] ref Guid pInterfaceGuid,
|
||||
WLAN_INTF_OPCODE OpCode,
|
||||
IntPtr pReserved,
|
||||
out uint pdwDataSize,
|
||||
out IntPtr ppData,
|
||||
IntPtr pWlanOpcodeValueType);
|
||||
|
||||
[DllImport("wlanapi.dll")]
|
||||
internal static extern void WlanFreeMemory(IntPtr pMemory);
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -218,15 +218,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
|
||||
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
|
||||
/// True for a successful enrollment, false for a failed one (drives the OnFailure mode
|
||||
/// check and sessioninfo.txt content). Pass true for WhiteGlove pre-provisioning (it
|
||||
/// succeeded up to this point). Pass <b>null</b> when the session has no outcome yet
|
||||
/// (on-demand server-requested collection mid-enrollment): the OnFailure gate does not
|
||||
/// apply — there is no success to skip on — and sessioninfo.txt records "In Progress".
|
||||
/// </param>
|
||||
/// <param name="fileNameSuffix">
|
||||
/// Optional suffix inserted before the .zip extension.
|
||||
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
|
||||
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
|
||||
/// </param>
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool? enrollmentSucceeded, string fileNameSuffix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -244,13 +247,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
|
||||
// skips a KNOWN success — an in-flight session has nothing to skip on.
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
|
||||
// Build ZIP in memory
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
@@ -343,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded)
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
using (var ms = new MemoryStream())
|
||||
@@ -414,14 +419,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
|
||||
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Session ID: {_configuration.SessionId}");
|
||||
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
|
||||
sb.AppendLine($"Device Name: {Environment.MachineName}");
|
||||
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded == null ? "In Progress" : enrollmentSucceeded.Value ? "Succeeded" : "Failed")}");
|
||||
|
||||
// Hardware info via existing DeviceInfoProvider (WMI)
|
||||
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
|
||||
|
||||
+45
-64
@@ -706,10 +706,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// the desktop via "Continue anyway".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// Serializes the ESP-config read+emit across ALL callers (CollectAll on the startup
|
||||
// ThreadPool task, CollectAtEnrollmentStart and RefreshEspConfiguration on their own
|
||||
// Task.Run) — without it, two concurrent collects can interleave read-before-emit and
|
||||
// the LATER emission can carry the OLDER (smaller) registry snapshot. The backend keeps
|
||||
// the latest list-bearing emission as positive blocking evidence, so an out-of-order
|
||||
// emission would silently shrink it (Codex review). Inside the lock each collect is
|
||||
// read→emit atomic: a later emission always carries an equal-or-newer snapshot.
|
||||
private readonly object _espConfigCollectLock = new object();
|
||||
|
||||
// Internal (not private) as a test seam: unlike CollectAll/CollectAtEnrollmentStart this
|
||||
// method touches no live system state once both probes are overridden, so tests can
|
||||
// drive the esp_config_detected event surface in isolation (InternalsVisibleTo).
|
||||
internal (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
|
||||
{
|
||||
lock (_espConfigCollectLock)
|
||||
{
|
||||
return CollectEspConfigurationLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfigurationLocked()
|
||||
{
|
||||
bool? skipUser = null;
|
||||
bool? skipDevice = null;
|
||||
@@ -798,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// Collects active network interface details (name, type, link speed, MAC) using
|
||||
/// System.Net.NetworkInformation — instant, no process spawn.
|
||||
/// If the active interface is WiFi, fires off a separate async task to collect
|
||||
/// WiFi signal info via netsh (fire-and-forget, never blocks).
|
||||
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
|
||||
/// </summary>
|
||||
private void CollectActiveNetworkInterfaceInfo()
|
||||
{
|
||||
@@ -841,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Fire-and-forget WiFi signal collection — separate event, never blocks
|
||||
if (isWifi)
|
||||
{
|
||||
Task.Run(() => CollectWiFiSignalInfo());
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -882,78 +900,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
|
||||
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
|
||||
/// never blocks other collection. VMs without WiFi service simply get no event.
|
||||
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
|
||||
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
|
||||
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
|
||||
/// VMs without WiFi service simply get no event.
|
||||
/// </summary>
|
||||
private void CollectWiFiSignalInfo()
|
||||
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
||||
wifi = NetshWifiFallback.TryRead();
|
||||
if (wifi == null)
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
if (wifi.Ssid != null)
|
||||
data["wifiSsid"] = wifi.Ssid;
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
|
||||
if (wifi.RadioType != null)
|
||||
data["wifiRadioType"] = wifi.RadioType;
|
||||
if (wifi.Channel.HasValue)
|
||||
data["wifiChannel"] = wifi.Channel.Value;
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
|
||||
if (wifi.SignalPercent.HasValue)
|
||||
message += $", Signal: {wifi.SignalPercent.Value}%";
|
||||
if (wifi.RadioType != null)
|
||||
message += $" ({wifi.RadioType})";
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiSsid"] = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var signal))
|
||||
data["wifiSignalPercent"] = signal;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiRadioType"] = value;
|
||||
}
|
||||
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value, out var channel))
|
||||
data["wifiChannel"] = channel;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
var message = data.ContainsKey("wifiSsid")
|
||||
? $"WiFi: {data["wifiSsid"]}"
|
||||
: "WiFi signal info";
|
||||
if (data.ContainsKey("wifiSignalPercent"))
|
||||
message += $", Signal: {data["wifiSignalPercent"]}%";
|
||||
if (data.ContainsKey("wifiRadioType"))
|
||||
message += $" ({data["wifiRadioType"]})";
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+17
@@ -114,6 +114,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
CollectTpmStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the ESP configuration (skip flags + ESPTrackingInfo blocking lists) and
|
||||
/// re-emits <c>esp_config_detected</c> when the payload changed. Audit Q2: the earlier
|
||||
/// emissions (agent start, DeviceSetup detection) fire before the registry lists are
|
||||
/// complete — the ESP writes one timestamped subkey per CSP status write, so the
|
||||
/// device-scope list grows progressively and the user-scope <c>S-<SID></c> lists
|
||||
/// appear only after sign-in. Called by <see cref="Orchestration.DeviceInfoHost"/> as
|
||||
/// phase-driven one-shots (apps sub-phase opened, AccountSetup detected) — never on a
|
||||
/// timer. The StartupEventGate keeps an unchanged payload silent, so a re-collect that
|
||||
/// found nothing new emits nothing.
|
||||
/// </summary>
|
||||
public void RefreshEspConfiguration(string trigger)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
|
||||
CollectEspConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
|
||||
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
|
||||
|
||||
+84
-1
@@ -25,14 +25,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
var filePath = rule.Target;
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, "empty target — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"target contains %LOGGED_ON_USER_PROFILE% but no user is logged on — skipped this run");
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
}
|
||||
|
||||
// Apply IME log path override if set. The override comes from the local
|
||||
// --ime-log-path flag only (never from remote config), so the operator
|
||||
@@ -66,6 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
string.IsNullOrEmpty(patternStr))
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"missing 'pattern' parameter — rule can never match");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,6 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -101,6 +112,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -120,6 +133,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-match trace lines are capped per file per run — the per-file summary line
|
||||
// still carries the full match count, this only bounds the per-match detail.
|
||||
private const int MaxTracedMatchesPerFile = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Writes one debug-trace line per regex match: the line number, the matched text,
|
||||
/// and every capture group that will land in the emitted event (same "0" exclusion
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
if (matchNumber == MaxTracedMatchesPerFile + 1)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(fileName).Append(": line ").Append(lineNumber)
|
||||
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
|
||||
|
||||
var first = true;
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (!group.Success) continue;
|
||||
sb.Append(first ? " — groups: " : ", ");
|
||||
first = false;
|
||||
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
|
||||
}
|
||||
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
|
||||
}
|
||||
|
||||
private static bool HasWildcard(string fileNamePart)
|
||||
=> !string.IsNullOrEmpty(fileNamePart) &&
|
||||
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
|
||||
@@ -144,7 +195,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
return Directory.GetFiles(directory, fileNamePart)
|
||||
var allMatches = Directory.GetFiles(directory, fileNamePart);
|
||||
if (allMatches.Length > 20)
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
|
||||
return allMatches
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
@@ -152,6 +207,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
context.DebugLog(ruleId, GatherRuleDebugLog.StageError, $"wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
@@ -168,10 +224,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
|
||||
return;
|
||||
}
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
int parseFailCount = 0;
|
||||
int timeoutCount = 0;
|
||||
long endPosition = startPosition;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
@@ -195,6 +257,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -233,13 +296,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
parseFailCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
@@ -248,6 +315,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
timeoutCount++;
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
@@ -288,6 +356,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,10 +370,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
|
||||
// Per-file outcome — always written so a "matched 0" run is visible too.
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
|
||||
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
|
||||
|
||||
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
|
||||
if (linesRead == maxLines)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"failed reading {filePath}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -113,7 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
// Suppress the "not found" event when the rule only wants a positive signal.
|
||||
if (emitOnlyIfExists)
|
||||
{
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
$"registry key/value not found: {path} — emitOnlyIfExists=true, empty result");
|
||||
return new Dictionary<string, object>(); // empty → executor emits nothing
|
||||
}
|
||||
|
||||
data["exists"] = false;
|
||||
data["path"] = path;
|
||||
|
||||
+16
-1
@@ -22,13 +22,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional gather-rule debug trace writer (EnableGatherRuleDebugLog). Null when
|
||||
/// tracing is disabled — use <see cref="DebugLog"/> for null-safe writes.
|
||||
/// </summary>
|
||||
public GatherRuleDebugLog DebugWriter { get; }
|
||||
|
||||
public GatherRuleContext(
|
||||
AgentLogger logger,
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker)
|
||||
LogFilePositionTracker filePositionTracker,
|
||||
GatherRuleDebugLog debugLog = null)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
@@ -36,8 +43,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
DebugWriter = debugLog;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. No-op when tracing is disabled.
|
||||
/// </summary>
|
||||
public void DebugLog(string ruleId, string stage, string message) => DebugWriter?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a security_warning event and returns an empty result dictionary.
|
||||
/// Called when a gather rule targets a path/query not on the allowlist.
|
||||
@@ -45,6 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
|
||||
{
|
||||
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageGuard,
|
||||
$"{collectorType} target blocked by allowlist guard: {target} — security_warning emitted, rule produced no data");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Append-only trace file for gather-rule evaluation, enabled per tenant via
|
||||
/// EnableGatherRuleDebugLog (or --gather-debug-log). Explains every evaluation
|
||||
/// decision — including the outcomes that produce no event — so customers can
|
||||
/// diagnose rules that never show up in the timeline. Every write failure is
|
||||
/// swallowed: tracing must never break gathering.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleDebugLog
|
||||
{
|
||||
public const string StageConfig = "config";
|
||||
public const string StagePhase = "phase";
|
||||
public const string StageTrigger = "trigger";
|
||||
public const string StageScope = "scope";
|
||||
public const string StageExec = "exec";
|
||||
public const string StageCollector = "collector";
|
||||
public const string StageGuard = "guard";
|
||||
public const string StageEmit = "emit";
|
||||
public const string StageSuppress = "suppress";
|
||||
public const string StageLogParser = "logparser";
|
||||
public const string StageError = "error";
|
||||
|
||||
private const long DefaultMaxBytes = 10 * 1024 * 1024;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly long _maxBytes;
|
||||
private readonly Action<string> _echo;
|
||||
private readonly object _lock = new object();
|
||||
private bool _headerWritten;
|
||||
|
||||
public GatherRuleDebugLog(string path, AgentLogger logger, Action<string> echo = null, long maxBytes = DefaultMaxBytes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
|
||||
_path = path;
|
||||
_maxBytes = maxBytes;
|
||||
_echo = echo;
|
||||
logger?.Info($"GatherRuleDebugLog: enabled -> {path}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one trace line: <c>{utc} | {ruleId} | {stage} | {message}</c>.
|
||||
/// Newlines in <paramref name="message"/> are flattened except for the error
|
||||
/// stage, where exception stack traces stay multi-line.
|
||||
/// </summary>
|
||||
public void Write(string ruleId, string stage, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (stage != StageError && message != null && message.IndexOf('\n') >= 0)
|
||||
message = message.Replace("\r", "").Replace('\n', ' ');
|
||||
|
||||
var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {ruleId ?? "-"} | {stage} | {message}";
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureFileReadyLocked();
|
||||
File.AppendAllText(_path, line + Environment.NewLine);
|
||||
}
|
||||
|
||||
_echo?.Invoke(line);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot helper for callers that have no executor (e.g. the flag is on but the
|
||||
/// backend delivered zero gather rules) — the customer's trace file should explain
|
||||
/// that too instead of silently not existing.
|
||||
/// </summary>
|
||||
public static void WriteStandalone(string path, string message, AgentLogger logger)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
try
|
||||
{
|
||||
new GatherRuleDebugLog(path, logger).Write(null, StageConfig, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Lazy on first write: create the directory and emit a header line. Before every
|
||||
// append, rotate when the file exceeds maxBytes — keeping exactly one .old
|
||||
// generation (worst case ~2x maxBytes on disk).
|
||||
private void EnsureFileReadyLocked()
|
||||
{
|
||||
if (!_headerWritten)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var info = new FileInfo(_path);
|
||||
if (info.Exists && info.Length > _maxBytes)
|
||||
{
|
||||
var oldPath = Path.ChangeExtension(_path, null) + ".old.log";
|
||||
if (File.Exists(oldPath))
|
||||
File.Delete(oldPath);
|
||||
File.Move(_path, oldPath);
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- rotated (previous file exceeded {_maxBytes} bytes) --" + Environment.NewLine);
|
||||
}
|
||||
|
||||
if (!_headerWritten)
|
||||
{
|
||||
File.AppendAllText(_path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- gather rule debug log started --" + Environment.NewLine);
|
||||
_headerWritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
-12
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
@@ -61,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null)
|
||||
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
|
||||
Action<string> debugEcho = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -69,9 +71,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_logger = logger;
|
||||
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
@@ -96,6 +99,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_collectors["command"] = _collectors["command_allowlisted"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null-safe write to the gather-rule debug trace. Never call while holding
|
||||
/// <see cref="_scopeLock"/> — capture the message under the lock, write after release.
|
||||
/// </summary>
|
||||
private void DebugLog(string ruleId, string stage, string message) => _debug?.Write(ruleId, stage, message);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active rules and starts/stops execution accordingly
|
||||
/// </summary>
|
||||
@@ -111,11 +120,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
_activeRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
// Registration summary for the debug trace: one line per delivered rule so the
|
||||
// customer can see exactly what the agent received and how it will be triggered.
|
||||
if (_debug != null)
|
||||
{
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!rule.Enabled)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig, "rule disabled — will never run");
|
||||
continue;
|
||||
}
|
||||
|
||||
var scope = rule.ActivePhases != null && rule.ActivePhases.Count > 0
|
||||
? $"activePhases=[{string.Join(",", rule.ActivePhases)}]"
|
||||
: !string.IsNullOrEmpty(rule.ActiveFromPhase)
|
||||
? $"activeFromPhase={rule.ActiveFromPhase}"
|
||||
: "unscoped";
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig,
|
||||
$"registered: trigger={rule.Trigger}" +
|
||||
(string.IsNullOrEmpty(rule.TriggerPhase) ? "" : $" triggerPhase={rule.TriggerPhase}") +
|
||||
(string.IsNullOrEmpty(rule.TriggerEventType) ? "" : $" triggerEventType={rule.TriggerEventType}") +
|
||||
$", collector={rule.CollectorType}, target={rule.Target}, {scope}" +
|
||||
$", emitMode={(string.IsNullOrEmpty(rule.EmitMode) ? "always" : rule.EmitMode)}" +
|
||||
(rule.IntervalSeconds.HasValue ? $", interval={rule.IntervalSeconds}s" : ""));
|
||||
|
||||
if (rule.Trigger == "interval" && !rule.IntervalSeconds.HasValue)
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
"trigger=interval but intervalSeconds is missing — rule will NEVER run");
|
||||
}
|
||||
}
|
||||
|
||||
// Execute startup rules — track completion via CountdownEvent so callers can wait.
|
||||
// Scoped startup rules that are not yet in scope are deferred: they run once from
|
||||
// OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a
|
||||
// rule runs either here or there, never both). Unscoped rules behave as before.
|
||||
List<GatherRule> pendingStartup;
|
||||
var deferredTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
// A config refresh may deliver new from-phase rules after their phase was
|
||||
@@ -124,7 +165,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
pendingStartup = _activeRules
|
||||
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
|
||||
.Where(r => IsRuleInScopeLocked(r))
|
||||
.Where(r =>
|
||||
{
|
||||
string reason;
|
||||
var inScope = IsRuleInScopeLocked(r, out reason);
|
||||
if (!inScope && deferredTraces != null)
|
||||
deferredTraces.Add(new KeyValuePair<string, string>(r.RuleId,
|
||||
$"startup rule deferred — {reason}; runs once when its scope activates"));
|
||||
return inScope;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var rule in pendingStartup)
|
||||
@@ -133,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
if (deferredTraces != null)
|
||||
{
|
||||
foreach (var trace in deferredTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (pendingStartup.Count > 0)
|
||||
{
|
||||
_startupRulesLatch?.Dispose();
|
||||
@@ -158,8 +213,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
var timer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
if (!IsRuleInScope(intervalRule))
|
||||
string skipReason;
|
||||
if (!IsRuleInScope(intervalRule, out skipReason))
|
||||
{
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageScope, $"interval tick skipped: {skipReason}");
|
||||
return;
|
||||
}
|
||||
ExecuteRule(intervalRule);
|
||||
},
|
||||
null,
|
||||
@@ -168,6 +227,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
);
|
||||
_intervalTimers[intervalRule.RuleId] = timer;
|
||||
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
|
||||
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
|
||||
}
|
||||
|
||||
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
|
||||
@@ -187,9 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
EnrollmentPhase previousPhase;
|
||||
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
@@ -204,12 +267,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
string scopeReason;
|
||||
if (!IsRuleInScopeLocked(rule, out scopeReason))
|
||||
{
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit of {previousPhaseName} skipped: {scopeReason}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
if (exitTraces != null)
|
||||
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
|
||||
$"phase_exit already executed for exit of {previousPhaseName} — once per (rule, phase)"));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,6 +292,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
// Transition marker first, so every rule-level line below reads against it.
|
||||
// Written outside _scopeLock like all trace lines.
|
||||
if (previousPhase != newPhase)
|
||||
DebugLog(null, GatherRuleDebugLog.StagePhase,
|
||||
$"phase change: {previousPhase} -> {phaseName}" +
|
||||
(previousPhase == EnrollmentPhase.Unknown
|
||||
? " (first phase signal — scoped rules now evaluate against this phase)"
|
||||
: ""));
|
||||
|
||||
if (exitTraces != null)
|
||||
{
|
||||
foreach (var trace in exitTraces)
|
||||
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
@@ -252,6 +340,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
var deferredRule = rule;
|
||||
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
|
||||
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase {phaseName} activated deferred startup rule");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
|
||||
}
|
||||
|
||||
@@ -262,18 +352,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
// Scope gate BEFORE the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) execution slot.
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"phase_change to {phaseName} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduplicate: only fire once per (ruleId, phase) combination
|
||||
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
|
||||
if (!_phaseRulesExecuted.Add(deduplicationKey))
|
||||
{
|
||||
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
|
||||
$"phase_change already executed for phase {phaseName} — once per (rule, phase)");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -288,10 +386,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!IsRuleInScope(rule))
|
||||
string scopeReason;
|
||||
if (!IsRuleInScope(rule, out scopeReason))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
|
||||
$"on_event {eventType} skipped: {scopeReason}");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
@@ -302,6 +406,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
|
||||
|
||||
var collectorType = rule.CollectorType?.ToLower();
|
||||
|
||||
@@ -309,6 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
|
||||
{
|
||||
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"unknown collector type '{rule.CollectorType}' — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -316,7 +422,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// LogParser (and potentially others) emit events directly and return null
|
||||
if (result == null)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
collectorType == "logparser"
|
||||
? "logparser emits per-match events directly — see logparser lines above for the per-file outcome"
|
||||
: "collector returned null — nothing emitted");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
|
||||
"collector returned EMPTY result — nothing emitted (typical causes: target not found with emitOnlyIfExists, or guard block — see guard line if present)");
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
@@ -325,8 +443,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// empty result never reaches this point, so an emitOnlyIfExists miss neither
|
||||
// emits nor updates the hash — composing to "one event on appearance, then
|
||||
// only on change".
|
||||
if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result))
|
||||
return;
|
||||
if (IsOnChangeMode(rule))
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
|
||||
{
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
|
||||
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
|
||||
return;
|
||||
}
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"result changed (hash {hashPrefix})" + (suppressedStreak > 0 ? $" after {suppressedStreak} suppressed polls" : ""));
|
||||
}
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
@@ -357,11 +486,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
|
||||
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
|
||||
// Full exception incl. stack trace only in the debug trace — the agent log stays terse.
|
||||
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"rule execution failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,15 +508,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Internal so the scope matrix is testable synchronously (no timer/ThreadPool races).
|
||||
internal bool IsRuleInScope(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScope(rule, out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope gate with a human-readable skip reason for the debug trace. The reason is
|
||||
/// captured under the lock but must be WRITTEN outside it (never trace under _scopeLock).
|
||||
/// </summary>
|
||||
internal bool IsRuleInScope(GatherRule rule, out string reason)
|
||||
{
|
||||
lock (_scopeLock)
|
||||
{
|
||||
return IsRuleInScopeLocked(rule);
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule)
|
||||
{
|
||||
string reason;
|
||||
return IsRuleInScopeLocked(rule, out reason);
|
||||
}
|
||||
|
||||
private bool IsRuleInScopeLocked(GatherRule rule, out string reason)
|
||||
{
|
||||
reason = null;
|
||||
|
||||
if (!HasPhaseScope(rule))
|
||||
return true; // unscoped — legacy behavior
|
||||
|
||||
@@ -400,7 +551,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// No phase signal yet (or scope tokens that never match, e.g. "Unknown"):
|
||||
// scoped rules stay inactive.
|
||||
if (_currentPhase == EnrollmentPhase.Unknown)
|
||||
{
|
||||
reason = "currentPhase=Unknown — scoped rules stay inactive until the first phase signal";
|
||||
return false;
|
||||
}
|
||||
|
||||
var phaseName = _currentPhase.ToString();
|
||||
foreach (var phase in rule.ActivePhases)
|
||||
@@ -408,10 +562,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
reason = $"out of scope (currentPhase={phaseName}, activePhases=[{string.Join(",", rule.ActivePhases)}])";
|
||||
return false;
|
||||
}
|
||||
|
||||
return _fromPhaseLatched.Contains(rule.RuleId);
|
||||
if (_fromPhaseLatched.Contains(rule.RuleId))
|
||||
return true;
|
||||
|
||||
reason = $"activeFromPhase={rule.ActiveFromPhase} not latched yet (currentPhase={_currentPhase})";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -457,8 +616,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// Internal so the state machine is testable synchronously (no timer/ThreadPool races).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result)
|
||||
{
|
||||
string hashPrefix;
|
||||
int suppressedStreak;
|
||||
return ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload exposing the hash prefix and suppression streak for the debug trace:
|
||||
/// on suppress, <paramref name="suppressedStreak"/> is the updated streak count;
|
||||
/// on emit, it is the streak that just ended (0 when none).
|
||||
/// </summary>
|
||||
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
|
||||
out string hashPrefix, out int suppressedStreak)
|
||||
{
|
||||
var hash = ComputeCanonicalHash(result);
|
||||
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
|
||||
suppressedStreak = 0;
|
||||
bool emit;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
@@ -469,6 +643,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
_suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak)
|
||||
? (streak.Count + 1, streak.SinceUtc)
|
||||
: (1, DateTime.UtcNow);
|
||||
suppressedStreak = _suppressed[rule.RuleId].Count;
|
||||
emit = false;
|
||||
}
|
||||
else
|
||||
@@ -479,6 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
result["suppressedPolls"] = streak.Count;
|
||||
result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture);
|
||||
suppressedStreak = streak.Count;
|
||||
}
|
||||
_suppressed.Remove(rule.RuleId);
|
||||
emit = true;
|
||||
|
||||
+9
-5
@@ -77,11 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 18 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (8: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests). cap=18 → HashHelpers.GetPrime(18)=23
|
||||
// buckets → no resize on the 18th key (cap=16 would land at 17 buckets and resize).
|
||||
var data = new Dictionary<string, object>(capacity: 18, StringComparer.Ordinal)
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -164,6 +164,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
|
||||
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
|
||||
data["net_total_requests"] = currentNet.RequestCount;
|
||||
// Cumulative latency sum: total/requests = session-wide average HTTP
|
||||
// round-trip. The backend projects that average onto the Session row
|
||||
// (AvgApiLatencyMs) — cumulative counters make the write idempotent.
|
||||
data["net_total_latency_ms"] = currentNet.TotalLatencyMs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+14
-57
@@ -8,8 +8,8 @@ using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private const int DebounceDelayMs = 3000;
|
||||
private const int ConnectivityCheckDelayMs = 5000;
|
||||
private const int ConnectivityCheckTimeoutMs = 8000;
|
||||
private const int WifiCollectTimeoutMs = 3000;
|
||||
private const string Source = "NetworkChangeDetector";
|
||||
|
||||
/// <summary>
|
||||
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Collect WiFi SSID synchronously (needed for before/after comparison)
|
||||
if (isWifi)
|
||||
{
|
||||
var wifiInfo = CollectWiFiInfo();
|
||||
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
|
||||
var wifiInfo = CollectWiFiInfo(nicGuid);
|
||||
snapshot.WifiSsid = wifiInfo.ssid;
|
||||
snapshot.WifiSignalPercent = wifiInfo.signal;
|
||||
snapshot.WifiRadioType = wifiInfo.radioType;
|
||||
@@ -390,73 +390,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
|
||||
/// Synchronous with timeout — needed before emitting the event.
|
||||
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
|
||||
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
|
||||
/// Synchronous — needed before emitting the event.
|
||||
/// </summary>
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
|
||||
{
|
||||
string ssid = null;
|
||||
int? signal = null;
|
||||
string radioType = null;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(WifiCollectTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Debug("NetworkChangeDetector: netsh timed out");
|
||||
return (null, null, null);
|
||||
}
|
||||
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
||||
wifi = NetshWifiFallback.TryRead();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return (null, null, null);
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
}
|
||||
if (wifi != null)
|
||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return (ssid, signal, radioType);
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -483,7 +483,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
rules: _remoteConfig.GatherRules,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode));
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
// Debug trace is on but the backend delivered zero gather rules — the trace
|
||||
// file must say so instead of silently not existing.
|
||||
Monitoring.Telemetry.Gather.GatherRuleDebugLog.WriteStandalone(
|
||||
_agentConfig.GatherRuleDebugLogPath,
|
||||
"no gather rules delivered by backend — nothing to execute", logger);
|
||||
}
|
||||
|
||||
return new CollectorSurfaces(hosts, imeLogHost, espAndHelloHost, aadJoinHost);
|
||||
|
||||
@@ -36,6 +36,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <item><c>CollectAtEnd</c> — once, on <c>FinalizingSetup</c> or desktop arrival
|
||||
/// (whichever comes first; desktop arrival also covers no-ESP / WDP v2). Re-fetches
|
||||
/// BitLocker (commonly enabled via policy DURING enrollment) + the active NIC.</item>
|
||||
/// <item><c>RefreshEspConfiguration</c> — audit Q2, one-shot per trigger: the ESP
|
||||
/// blocking lists in the registry grow progressively (device scope) and the user-scope
|
||||
/// lists appear only after sign-in, so the early emissions are structurally partial.
|
||||
/// Re-collected when the apps sub-phase opens (first app activity in DeviceSetup /
|
||||
/// AccountSetup — the adapter's <c>phase_transition</c> declaration) and when the
|
||||
/// AccountSetup ESP phase itself is detected. Phase-driven one-shots, never a timer
|
||||
/// (no-heartbeat policy).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Duplicate-event cost is zero: every re-collected event runs through the collector's
|
||||
@@ -46,6 +53,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
public string Name => "DeviceInfoCollector";
|
||||
|
||||
// ESP-config refresh trigger labels (audit Q2) — also the values passed to
|
||||
// DeviceInfoCollector.RefreshEspConfiguration for the agent-log line.
|
||||
internal const string EspConfigTriggerAppsDevice = "apps_phase_device";
|
||||
internal const string EspConfigTriggerAppsUser = "apps_phase_user";
|
||||
internal const string EspConfigTriggerAccountSetup = "account_setup_detected";
|
||||
|
||||
private readonly Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector _collector;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
@@ -58,6 +71,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _endCollected;
|
||||
private int _disposed;
|
||||
|
||||
// Audit Q2 — fire-once latches per ESP-config refresh trigger. Guarded by _espConfigLock
|
||||
// instead of three Interlocked ints so the membership test + add stays one atomic step.
|
||||
private readonly object _espConfigLock = new object();
|
||||
private readonly HashSet<string> _espConfigRefreshesFired = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
public DeviceInfoHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -100,6 +118,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
HandleEspConfigRefreshTrigger(kind, payload);
|
||||
|
||||
var startTrigger = IsEnrollmentStartTrigger(kind, payload);
|
||||
var endTrigger = IsEndTrigger(kind, payload);
|
||||
if (!startTrigger && !endTrigger) return;
|
||||
@@ -111,10 +131,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var runEndCollect = endTrigger && Interlocked.Exchange(ref _endCollected, 1) == 0;
|
||||
if (!runStartRefresh && !runEndCollect) return;
|
||||
|
||||
// Both one-shots done → nothing left to observe.
|
||||
if (Volatile.Read(ref _enrollmentStartCollected) == 1 && Volatile.Read(ref _endCollected) == 1)
|
||||
Unsubscribe();
|
||||
|
||||
var trigger = kind == DecisionSignalKind.DesktopArrived ? "desktop_arrived" : "esp_phase_changed";
|
||||
_logger.Info($"DeviceInfoHost: trigger '{trigger}' — scheduling re-collect (enrollmentStart={runStartRefresh}, end={runEndCollect}).");
|
||||
|
||||
@@ -134,6 +150,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — ESP-config refresh one-shots. Each trigger label fires at most once per
|
||||
/// agent run; the collector's StartupEventGate keeps an unchanged payload silent, so
|
||||
/// the worst case is one silent registry re-read per trigger. The subscription stays
|
||||
/// armed for the host's lifetime (unlike the start/end pair there is no "all triggers
|
||||
/// seen" point that is guaranteed to arrive — SkipUser enrollments never enter
|
||||
/// AccountSetup); the per-signal cost is a few dictionary lookups.
|
||||
/// </summary>
|
||||
private void HandleEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
var trigger = ClassifyEspConfigRefreshTrigger(kind, payload);
|
||||
if (trigger == null) return;
|
||||
|
||||
lock (_espConfigLock)
|
||||
{
|
||||
if (!_espConfigRefreshesFired.Add(trigger)) return;
|
||||
}
|
||||
|
||||
_logger.Info($"DeviceInfoHost: ESP-config refresh trigger '{trigger}' — scheduling re-collect.");
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { _collector.RefreshEspConfiguration(trigger); }
|
||||
catch (Exception ex) { _logger.Warning($"DeviceInfoHost: ESP-config refresh threw: {ex.Message}"); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>First DeviceSetup phase signal — MDM enrollment has populated the registry surface.</summary>
|
||||
internal static bool IsEnrollmentStartTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
@@ -156,6 +198,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
&& string.Equals(phase, nameof(EnrollmentPhase.FinalizingSetup), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — maps a posted signal to an ESP-config refresh trigger label, or
|
||||
/// <c>null</c> when the signal is not one. Two trigger families:
|
||||
/// <list type="bullet">
|
||||
/// <item>The adapter's apps sub-phase declaration (<c>phase_transition</c>
|
||||
/// InformationalEvent with phase AppsDevice/AppsUser — fired on the first app
|
||||
/// activity in an ESP phase): by then IME is actively processing the blocking set,
|
||||
/// so the device-scope ESPTrackingInfo lists are as complete as they get.</item>
|
||||
/// <item>The AccountSetup ESP phase (UserSetup detection): sign-in has happened, so
|
||||
/// the user-scope <c>S-<SID></c> lists exist now — production showed 88 of 89
|
||||
/// sessions with an EMPTY user list at the last pre-fix emission.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string? ClassifyEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
if (payload == null) return null;
|
||||
|
||||
if (kind == DecisionSignalKind.InformationalEvent
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& string.Equals(eventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition, StringComparison.Ordinal)
|
||||
&& payload.TryGetValue(Telemetry.Events.EventTimelineEmitter.PhaseParamKey, out var declaredPhase))
|
||||
{
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsDevice), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsDevice;
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsUser), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsUser;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind == DecisionSignalKind.EspPhaseChanged
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var espPhase)
|
||||
&& string.Equals(espPhase, nameof(EnrollmentPhase.AccountSetup), StringComparison.Ordinal))
|
||||
{
|
||||
return EspConfigTriggerAccountSetup;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Stop() => Unsubscribe();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
+5
-3
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
AgentLogger logger,
|
||||
List<GatherRule> rules,
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false)
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -55,9 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// session mode we wrap post.Emit so every session-mode gather event still
|
||||
// flows through the InformationalEvent ingress pipe before hitting the
|
||||
// telemetry spool — Rail-A semantics for ordering / replay determinism.
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride);
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
manufacturer = obj["Manufacturer"]?.ToString() ?? Unknown;
|
||||
manufacturer = NormalizeWmiValue(obj["Manufacturer"]?.ToString());
|
||||
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
|
||||
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (lenovoObj)
|
||||
{
|
||||
model = lenovoObj["Version"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(lenovoObj["Version"]?.ToString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
model = obj["Model"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(obj["Model"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
serialNumber = obj["SerialNumber"]?.ToString() ?? Unknown;
|
||||
serialNumber = NormalizeWmiValue(obj["SerialNumber"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,5 +154,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static bool IsResolved(string value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& !string.Equals(value, Unknown, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Trims a raw WMI string value at the source (audit Q5: BIOS serials are used as device
|
||||
/// identity keys downstream — F2 device history joins on them — so padding must never
|
||||
/// leave the agent). Whitespace-only and empty values collapse to the "Unknown" sentinel,
|
||||
/// matching the null fallback so the retry logic treats them as unresolved.
|
||||
/// </summary>
|
||||
internal static string NormalizeWmiValue(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Unknown;
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
// Plan §5 Fix 4c — per-app install-lifecycle timing captured from state transitions.
|
||||
// StartedAtUtc on first Downloading/Installing/InProgress transition (set-once);
|
||||
// CompletedAtUtc on first terminal transition (Installed/Skipped/Postponed/Error).
|
||||
// CompletedAtUtc on terminal transition (Installed/Skipped/Postponed/Error), re-armed
|
||||
// to null on a terminal→active transition (IME retry, audit Q1) so the final terminal
|
||||
// stamp reflects the last outcome instead of freezing at the first one.
|
||||
// Surfaced via <see cref="AppTimings"/> to peripheral consumers (FinalStatusBuilder,
|
||||
// app_tracking_summary emission in EnrollmentTerminationHandler).
|
||||
private readonly Dictionary<string, (DateTime? Started, DateTime? Completed)> _appTimings =
|
||||
@@ -691,10 +693,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5 Fix 4c — set-once per-app install-lifecycle timing. Called for every
|
||||
/// state transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// Plan §5 Fix 4c — per-app install-lifecycle timing. Called for every state
|
||||
/// transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// <see cref="AppInstallTiming"/> so the emitter can inline the values on the
|
||||
/// current event's payload without a second dictionary lookup.
|
||||
/// <para>
|
||||
/// Audit Q1: a terminal→active transition (the IME retry path — <see
|
||||
/// cref="AppPackageState.UpdateState"/> permits e.g. Error→Downloading and
|
||||
/// Skipped→Downloading) re-arms <c>Completed</c> to <c>null</c>, so the NEXT terminal
|
||||
/// transition stamps a fresh CompletedAt. StartedAt keeps its first value — the final
|
||||
/// terminal payload then covers first-start → last-terminal (total occupancy across
|
||||
/// attempts) instead of freezing at the first failure. Observed pre-fix: a user-scope
|
||||
/// app Skipped early in DeviceSetup, retried after sign-in, emitted its final
|
||||
/// Installed event with the stale early CompletedAt (durationSeconds=-2342).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private AppInstallTiming UpdateAppTiming(string appId, AppInstallationState newState, DateTime now)
|
||||
{
|
||||
@@ -702,13 +714,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
_appTimings.TryGetValue(appId, out var current);
|
||||
|
||||
// Terminal→active (retry): the earlier terminal stamp no longer describes this
|
||||
// app's outcome — re-arm so the next terminal transition stamps fresh.
|
||||
if (current.Completed != null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Completed = null;
|
||||
}
|
||||
|
||||
// First lifecycle-active transition → record StartedAt.
|
||||
if (current.Started == null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Started = now;
|
||||
}
|
||||
|
||||
// First terminal transition → record CompletedAt.
|
||||
// Terminal transition → record CompletedAt (fresh after a re-arm).
|
||||
if (current.Completed == null && IsCompletedState(newState))
|
||||
{
|
||||
current.Completed = now;
|
||||
|
||||
+39
-12
@@ -39,8 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
|
||||
/// <item>WhiteGlove Part-1 path: stop peripheral collectors first (so their stop-time
|
||||
/// stragglers — e.g. <c>network_bandwidth_estimate</c> — sequence BEFORE the marker),
|
||||
/// then emit <c>whiteglove_part1_complete</c>, drain spool, and write
|
||||
/// <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// upload the diagnostics package as a pre-provisioning intermediate (suffix
|
||||
/// <c>preprov</c>, same mode gating as the terminal path — the seal counts as a
|
||||
/// success, so <c>OnFailure</c> skips), then emit <c>whiteglove_part1_complete</c>,
|
||||
/// drain spool, and write <c>whiteglove.complete</c> marker via
|
||||
/// <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
|
||||
/// so Part-2 resume is detected on the next boot.</item>
|
||||
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
|
||||
/// </list>
|
||||
@@ -241,6 +244,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// the full Stop() later is a no-op for the hosts.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
// V1 parity (restored 2026-07-28) — upload the diagnostics package as a
|
||||
// pre-provisioning intermediate. Between reseal and user OOBE the device
|
||||
// may sit boxed for weeks, and if Part 2 never uploads (never runs, or
|
||||
// OnFailure + success) the Part-1 forensics would be lost to self-destruct.
|
||||
// No dedicated flag: the existing mode semantics apply — Always uploads,
|
||||
// OnFailure skips (the seal is a success; a Part-1 failure terminates via
|
||||
// the regular Failed path below, which uploads), Off does nothing. Must run
|
||||
// BEFORE the whiteglove_part1_complete emit so diagnostics_* events keep a
|
||||
// lower sequence than the Part-1/Part-2 boundary marker. Best-effort: a
|
||||
// reseal-reboot racing the upload just loses the intermediate package.
|
||||
RunUploadDiagnosticsWithEvents(args, suffixOverride: "preprov");
|
||||
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -623,12 +638,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a one-shot <c>agent_late_start</c> Warning when the agent had low observation
|
||||
/// Emits a one-shot <c>agent_late_start</c> event when the agent had low observation
|
||||
/// coverage: it started a long time after device boot AND lived only briefly before this
|
||||
/// terminal outcome (gate in <see cref="ObservationCoverage"/>). Such a session looks like a
|
||||
/// normal multi-minute failure in the list (StartedAt is back-dated to the earliest replayed
|
||||
/// IME-log event) but the agent actually observed only the end-state — so the operator should
|
||||
/// read the diagnosis as a post-mortem reconstruction, not live evidence. No state mutation.
|
||||
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
|
||||
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
|
||||
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
|
||||
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
|
||||
/// enrollments whose bootstrap merely ran late). No state mutation.
|
||||
/// </summary>
|
||||
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
@@ -642,21 +660,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
out var bootToStartSeconds, out var uptimeSeconds))
|
||||
return;
|
||||
|
||||
ObservationCoverage.DescribeLateStart(
|
||||
args.Outcome, _oobeStateAtAgentStart, bootToStartSeconds, uptimeSeconds,
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: low observation coverage — agent started " +
|
||||
$"{bootToStartSeconds / 60.0:F1} min after boot and lived {uptimeSeconds:F0}s; " +
|
||||
$"emitting agent_late_start.");
|
||||
$"emitting agent_late_start ({severity}, outcome {args.Outcome}).");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = Constants.EventTypes.AgentLateStart,
|
||||
Severity = EventSeverity.Warning,
|
||||
Severity = severity,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({args.Outcome}) — low coverage of the enrollment window.",
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "bootToAgentStartSeconds", Math.Round(bootToStartSeconds, 1) },
|
||||
@@ -665,7 +686,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{ "agentStartUtc", _agentStartTimeUtc.ToString("o") },
|
||||
{ "oobeStateAtAgentStart", _oobeStateAtAgentStart },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." },
|
||||
{ "note", note },
|
||||
},
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
@@ -771,7 +792,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
|
||||
/// <param name="args">Termination payload — outcome drives the OnFailure gate.</param>
|
||||
/// <param name="suffixOverride">
|
||||
/// Blob-name suffix override. Null (terminal path) derives "success"/"failure" from
|
||||
/// the outcome; the WhiteGlove Part-1 seal passes "preprov" so the intermediate
|
||||
/// package is distinguishable from the later Part-2 terminal upload.
|
||||
/// </param>
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
|
||||
{
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -807,7 +834,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
DiagnosticsUploadResult result = null;
|
||||
try
|
||||
{
|
||||
var suffix = enrollmentSucceeded ? "success" : "failure";
|
||||
var suffix = suffixOverride ?? (enrollmentSucceeded ? "success" : "failure");
|
||||
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
@@ -76,5 +78,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
uptimeSeconds = Math.Max(0, uptime.TotalSeconds);
|
||||
return bootToStart >= LateStartBootThreshold && uptime <= LowCoverageUptime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity + operator-facing phrasing for the <c>agent_late_start</c> event, calibrated
|
||||
/// by fleet data (2026-07-28): the gate alone is outcome-blind and attached a
|
||||
/// "post-mortem / check for a hung script" Warning to sessions that in fact succeeded —
|
||||
/// there the late bootstrap is deployment hygiene, not an alarm. The OOBE state refines
|
||||
/// the phrasing but must never gate: the canonical hung-script failure terminates on the
|
||||
/// still-shown ESP screen, i.e. with OOBE reading <c>in_progress</c>.
|
||||
/// </summary>
|
||||
/// <param name="outcome">Terminal outcome the enrollment reached.</param>
|
||||
/// <param name="oobeStateAtAgentStart"><see cref="Monitoring.Interop.OobeStateReader"/>
|
||||
/// sample taken at THIS process's start. Only <c>completed</c> / <c>in_progress</c> are
|
||||
/// interpreted; <c>not_started</c> flips in after mid-OOBE reboots and <c>completed</c> is
|
||||
/// routine for post-desktop restarts, so no claim is phrased from the other values.</param>
|
||||
/// <param name="bootToStartSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
/// <param name="uptimeSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
internal static void DescribeLateStart(
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string oobeStateAtAgentStart,
|
||||
double bootToStartSeconds,
|
||||
double uptimeSeconds,
|
||||
out EventSeverity severity,
|
||||
out string message,
|
||||
out string note)
|
||||
{
|
||||
var succeeded = outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
severity = succeeded ? EventSeverity.Info : EventSeverity.Warning;
|
||||
|
||||
message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({outcome}) — low coverage of the enrollment window.";
|
||||
switch (oobeStateAtAgentStart)
|
||||
{
|
||||
case "completed":
|
||||
message += " OOBE was already completed when the agent started — the diagnosis is a " +
|
||||
"post-mortem reconstruction, not live observation.";
|
||||
break;
|
||||
case "in_progress":
|
||||
message += $" OOBE was still in progress at agent start — the agent did observe the " +
|
||||
$"final {uptimeSeconds:F0}s live.";
|
||||
break;
|
||||
}
|
||||
|
||||
note = succeeded
|
||||
? "The agent's bootstrap (an Intune platform script) ran late in the enrollment, so only the " +
|
||||
"final stretch was observed live; earlier timeline entries are reconstructed from IME log " +
|
||||
"replay. The enrollment itself succeeded. If this recurs across sessions, review which " +
|
||||
"platform/remediation scripts run ahead of the bootstrap (see script_timeout_suspected)."
|
||||
: "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, " +
|
||||
"so the agent observed the already-decided end-state rather than the failure window. Treat " +
|
||||
"the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of " +
|
||||
"the bootstrap (see script_timeout_suspected).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +221,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4 — run the executor and wait.
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride))
|
||||
// Step 4 — run the executor and wait. Diagnostic mode always writes the
|
||||
// gather-rule debug trace (CLI override or default path) and mirrors it
|
||||
// to the console in --console mode.
|
||||
var gatherDebugLogPath = !string.IsNullOrEmpty(config.GatherRuleDebugLogPath)
|
||||
? config.GatherRuleDebugLogPath
|
||||
: Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
|
||||
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
|
||||
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
|
||||
|
||||
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath,
|
||||
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
|
||||
{
|
||||
// Diagnostic mode has no enrollment phase context — scoped rules
|
||||
// (activePhases / activeFromPhase) execute unconditionally here.
|
||||
|
||||
@@ -303,6 +303,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
|
||||
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
|
||||
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
|
||||
var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log");
|
||||
|
||||
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
|
||||
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
|
||||
@@ -373,6 +374,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
EnableGeoLocation = !disableGeoLocation,
|
||||
ImeLogPathOverride = imeLogPathOverride,
|
||||
ImeMatchLogPath = imeMatchLogPath,
|
||||
GatherRuleDebugLogPath = gatherDebugLogPath,
|
||||
KeepLogFile = keepLogFile,
|
||||
SelfDestructOnComplete = !noCleanup,
|
||||
CommandLineArgs = FormatArgsForLog(args),
|
||||
@@ -485,6 +487,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
|
||||
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
|
||||
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
|
||||
Console.Out.WriteLine(" --gather-debug-log <PATH> Write gather-rule evaluation trace to file (debug)");
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine("Dev / Test:");
|
||||
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
|
||||
|
||||
@@ -46,8 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
|
||||
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
|
||||
},
|
||||
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
|
||||
// sessioninfo.txt honest ("In Progress") and passes the OnFailure gate by
|
||||
// semantics (there is no success to skip on) instead of the former
|
||||
// enrollmentSucceeded:false workaround that stamped in-flight packages "Failed".
|
||||
uploadDiagnosticsAsync: async (suffix) =>
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
|
||||
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: suffix),
|
||||
onTerminateRequested: action => OnTerminateRequested(
|
||||
action, agentConfig, orchestrator, terminationHandler, shutdown, shutdownComplete, logger),
|
||||
post: post);
|
||||
|
||||
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
|
||||
|
||||
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
|
||||
result, "DESKTOP-CONTOSO1", "SN-1234",
|
||||
"https://portal.autopilotmonitor.com/sessions/abc");
|
||||
"https://portal.autopilotmonitor.com/sessions?id=abc");
|
||||
|
||||
Assert.Equal("analyze_rule_fired", alert.EventType);
|
||||
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
|
||||
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
|
||||
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
|
||||
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
|
||||
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
|
||||
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
|
||||
}
|
||||
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F1 PR1 (insights spec §F1, source-data audit Q2/Q3): app identity on the name-keyed
|
||||
/// summary row and the ESP-blocking join.
|
||||
///
|
||||
/// Ingest side: <c>appId</c> is adopted from the app events; a second appId under the same
|
||||
/// name flags the row (<see cref="AppInstallSummary.AppIdCollision"/>) — in-batch and
|
||||
/// cross-batch. Store side: sentinel-gated columns + sticky collision. Join side:
|
||||
/// <see cref="EspBlockingSets"/> is positive evidence only, and
|
||||
/// <see cref="TableStorageService.ShouldStampEspBlocking"/> never stamps collision rows,
|
||||
/// id-less rows, or non-members. Aggregate side: collision rows leave every per-app fleet
|
||||
/// aggregate with a disclosed exclusion count.
|
||||
/// </summary>
|
||||
public class AppInstallIdentityAndEspBlockingTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string IdA = "aaaaaaaa-1111-2222-3333-444444444444";
|
||||
private const string IdB = "bbbbbbbb-1111-2222-3333-444444444444";
|
||||
|
||||
// ── ingest fold: appId adoption + in-batch collision ────────────────────
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent AppEvent(string eventType, string? appId = null, string appName = "App")
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = T0,
|
||||
Data = new Dictionary<string, object> { ["appName"] = appName },
|
||||
};
|
||||
if (appId != null) evt.Data["appId"] = appId;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_AdoptsAppId_FromFirstObservingEvent()
|
||||
{
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_completed", IdA))["App"].Summary;
|
||||
|
||||
Assert.Equal(IdA, s.AppId);
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_SameName_DifferentAppId_FlagsCollision_FirstSeenIdWins()
|
||||
{
|
||||
// Device- + user-scope assignment of the same display name in one batch (audit Q3:
|
||||
// observed in production as two "Company Portal" appIds in one session).
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_started", IdB))["App"].Summary;
|
||||
|
||||
Assert.True(s.AppIdCollision);
|
||||
Assert.Equal(IdA, s.AppId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_AppIdCaseDrift_IsNotACollision()
|
||||
{
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_started", IdA),
|
||||
AppEvent("app_install_completed", IdA.ToUpperInvariant()))["App"].Summary;
|
||||
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_EventsWithoutAppId_LeaveSentinelEmpty()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed"))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.AppId);
|
||||
Assert.False(s.AppIdCollision);
|
||||
}
|
||||
|
||||
// ── reconcile: cross-batch identity ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_AdoptsStoredAppId_WhenIncomingBatchHasNone()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
|
||||
var summary = new AppInstallSummary { AppName = "App", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal(IdA, summary.AppId);
|
||||
Assert.False(summary.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_CrossBatchDifferentAppId_FlagsCollision_StoredIdWins()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["AppId"] = IdA };
|
||||
var summary = new AppInstallSummary { AppName = "App", AppId = IdB, StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.True(summary.AppIdCollision);
|
||||
Assert.Equal(IdA, summary.AppId); // first-seen (stored) identity wins, matching the fold rule
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_CollisionIsSticky_AndEspBlockingVerdictIsAdopted()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["AppId"] = IdA,
|
||||
["AppIdCollision"] = true,
|
||||
["EspBlocking"] = true,
|
||||
};
|
||||
var summary = new AppInstallSummary { AppName = "App", AppId = IdA, StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.True(summary.AppIdCollision); // one-way sticky
|
||||
Assert.True(summary.EspBlocking); // terminal-path verdict survives late batches
|
||||
}
|
||||
|
||||
// ── entity builder: sentinel gating ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EntityBuilder_IdentityColumns_AreSentinelGated()
|
||||
{
|
||||
var empty = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { AppName = "App" }, "rk");
|
||||
// Absent columns → Merge-mode preserves prior values / stays unknown.
|
||||
Assert.False(empty.ContainsKey("AppId"));
|
||||
Assert.False(empty.ContainsKey("EspBlocking"));
|
||||
Assert.False(empty.ContainsKey("AppIdCollision"));
|
||||
|
||||
var full = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { AppName = "App", AppId = IdA, EspBlocking = true, AppIdCollision = true }, "rk");
|
||||
Assert.Equal(IdA, full.GetString("AppId"));
|
||||
Assert.True(full.GetBoolean("EspBlocking"));
|
||||
Assert.True(full.GetBoolean("AppIdCollision"));
|
||||
}
|
||||
|
||||
// ── EspBlockingSets: positive-evidence parsing ──────────────────────────
|
||||
|
||||
private static Dictionary<string, object> Lists(
|
||||
object? win32 = null, object? userWin32 = null, object? msi = null, object? pfn = null,
|
||||
long? win32Count = null)
|
||||
{
|
||||
var d = new Dictionary<string, object> { ["source"] = "registry_firstsync" };
|
||||
if (win32 != null) d["espTrackedWin32AppIds"] = win32;
|
||||
if (userWin32 != null) d["espTrackedUserWin32AppIds"] = userWin32;
|
||||
if (msi != null) d["espTrackedMsiProductCodes"] = msi;
|
||||
if (pfn != null) d["espTrackedModernAppPfns"] = pfn;
|
||||
if (win32Count.HasValue) d["espTrackedWin32Count"] = win32Count.Value;
|
||||
return d;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_PayloadWithoutListKeys_YieldsNull_NoListsIsNotEmptyLists()
|
||||
{
|
||||
Assert.Null(EspBlockingSets.FromEventData(null));
|
||||
Assert.Null(EspBlockingSets.FromEventData(Lists())); // probe found no Diagnostics key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_MembershipIsCaseInsensitive_AcrossAllFourLists()
|
||||
{
|
||||
// Storage rehydration shape: List<object> (EventDataNormalizer).
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA },
|
||||
msi: new List<object> { "{11111111-2222-3333-4444-555555555555}" },
|
||||
pfn: new List<object> { "Contoso.App_abc123" }))!;
|
||||
|
||||
Assert.True(sets.Contains(IdA.ToUpperInvariant()));
|
||||
Assert.True(sets.Contains("{11111111-2222-3333-4444-555555555555}"));
|
||||
Assert.True(sets.Contains("Contoso.App_abc123"));
|
||||
Assert.False(sets.Contains(IdB)); // absent ⇒ unknown at the caller, never false here
|
||||
Assert.False(sets.Contains(null));
|
||||
Assert.Equal(3, sets.ListedCount);
|
||||
Assert.False(sets.IsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_EmittedCountAboveListLength_MeansTruncated()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA }, win32Count: 60))!;
|
||||
|
||||
Assert.True(sets.IsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sets_UserScopeSubset_IsExposedForCoverageLabeling()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(
|
||||
win32: new List<object> { IdA, IdB },
|
||||
userWin32: new List<object> { IdB }))!;
|
||||
|
||||
Assert.Contains(IdB, sets.UserWin32AppIds);
|
||||
Assert.DoesNotContain(IdA, sets.UserWin32AppIds);
|
||||
}
|
||||
|
||||
// ── terminal-path stamping predicate ────────────────────────────────────
|
||||
|
||||
private static TableEntity Row(string? appId = null, bool? collision = null, bool? espBlocking = null)
|
||||
{
|
||||
var row = new TableEntity("tenant", "session_App");
|
||||
if (appId != null) row["AppId"] = appId;
|
||||
if (collision.HasValue) row["AppIdCollision"] = collision.Value;
|
||||
if (espBlocking.HasValue) row["EspBlocking"] = espBlocking.Value;
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamp_OnlyPositiveMembers_NeverIdlessCollidedOrAlreadyStamped()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(Lists(win32: new List<object> { IdA }))!;
|
||||
|
||||
Assert.True(TableStorageService.ShouldStampEspBlocking(Row(IdA), sets));
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(), sets)); // no identity → unknown
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdB), sets)); // absent ⇒ unknown, never false
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, collision: true), sets)); // ambiguous identity
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(Row(IdA, espBlocking: true), sets)); // idempotent
|
||||
}
|
||||
|
||||
// ── per-app fleet aggregates: collision rows leave, disclosed ───────────
|
||||
|
||||
private static AppInstallSummary Summary(
|
||||
string appName, string status = "Succeeded", bool collision = false, int duration = 30)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-000000000fa1",
|
||||
SessionId = "s-1",
|
||||
AppName = appName,
|
||||
Status = status,
|
||||
TerminalState = status == "Succeeded" ? "Installed" : "Error",
|
||||
DurationSeconds = duration,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
AppIdCollision = collision,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AppMetricsPayload_ExcludesCollisionRowsFromGroups_DisclosesCount()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Clean App"),
|
||||
Summary("Clean App"),
|
||||
Summary("Company Portal", collision: true),
|
||||
Summary("Company Portal", status: "Failed", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||
Assert.Equal(2, root.GetProperty("totalCollisionExcluded").GetInt32());
|
||||
Assert.Empty(root.GetProperty("topFailingApps").EnumerateArray()); // the collided Failed row ranks nowhere
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppsList_ExcludesCollisionRows_DisclosesCount()
|
||||
{
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Clean App"),
|
||||
Summary("Company Portal", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
Assert.Single(apps);
|
||||
Assert.Equal("Clean App", apps[0].GetProperty("appName").GetString());
|
||||
Assert.Equal(2, root.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppAnalytics_ExcludesCollisionRows_DisclosesCount()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
Summary("Company Portal"),
|
||||
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
var stats = root.GetProperty("summary");
|
||||
Assert.Equal(1, stats.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(0, stats.GetProperty("failed").GetInt32()); // ambiguous failure not attributed
|
||||
Assert.Equal(0d, stats.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppAnalytics_AllRowsCollided_ReturnsEmptyShape_WithDisclosure()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR0 (2026-07-26, source-data audit §0.5 of tasks/insights-expansion-spec.md).
|
||||
///
|
||||
/// Ingest side: the agent emits <c>app_install_completed</c> for EVERY terminal transition —
|
||||
/// including Skipped/Postponed no-ops (V1 wire parity; production: 78 % of zero-duration rows
|
||||
/// were skips). The payload's <c>state</c> field must be persisted as
|
||||
/// <see cref="AppInstallSummary.TerminalState"/> so metrics can exclude skips from durations
|
||||
/// and rates.
|
||||
///
|
||||
/// Store side: <see cref="TableStorageService.ReconcileAppInstallSummaryWithExisting"/> pins the
|
||||
/// cross-batch contract — earliest StartedAt wins, terminal status is sticky, and the Q4
|
||||
/// out-of-order case (terminal batch first, started batch later) recomputes the duration.
|
||||
/// </summary>
|
||||
public class AppInstallTerminalStateAndReconcileTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── ingest: TerminalState fold ──────────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent AppEvent(string eventType, DateTime ts, string? state = null, string appName = "App")
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = ts,
|
||||
Data = new Dictionary<string, object> { ["appName"] = appName },
|
||||
};
|
||||
if (state != null) evt.Data["state"] = state;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Installed")]
|
||||
[InlineData("Skipped")]
|
||||
[InlineData("Postponed")]
|
||||
public void Completed_PersistsPayloadState(string state)
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0, state))["App"].Summary;
|
||||
|
||||
Assert.Equal("Succeeded", s.Status); // backward-compatible status
|
||||
Assert.Equal(state, s.TerminalState); // new precision column
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_WithoutState_LeavesSentinelEmpty_NeverGuessed()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Completed_WithNonTerminalOrGarbageState_LeavesSentinelEmpty()
|
||||
{
|
||||
// A payload state outside the terminal set (bug or drift) must not be persisted.
|
||||
var s = Aggregate(AppEvent("app_install_completed", T0, "Downloading"))["App"].Summary;
|
||||
Assert.Equal(string.Empty, s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_SetsError()
|
||||
{
|
||||
var s = Aggregate(AppEvent("app_install_failed", T0))["App"].Summary;
|
||||
Assert.Equal("Failed", s.Status);
|
||||
Assert.Equal("Error", s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedThenCompleted_RetrySucceeds_LastTerminalWins()
|
||||
{
|
||||
// IME retry path (audit Q1): Error → retry → Installed. The later completed event is
|
||||
// the authoritative outcome for Status AND TerminalState.
|
||||
var s = Aggregate(
|
||||
AppEvent("app_install_failed", T0),
|
||||
AppEvent("app_install_completed", T0.AddMinutes(10), "Installed"))["App"].Summary;
|
||||
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal("Installed", s.TerminalState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedEvent_SetsSkipped_ButNeverOverridesStrongerTerminal()
|
||||
{
|
||||
var fresh = Aggregate(AppEvent("app_install_skipped", T0))["App"].Summary;
|
||||
Assert.Equal("Succeeded", fresh.Status);
|
||||
Assert.Equal("Skipped", fresh.TerminalState);
|
||||
|
||||
var afterFailure = Aggregate(
|
||||
AppEvent("app_install_failed", T0),
|
||||
AppEvent("app_install_skipped", T0.AddSeconds(5)))["App"].Summary;
|
||||
Assert.Equal("Error", afterFailure.TerminalState);
|
||||
}
|
||||
|
||||
// ── store: cross-batch reconcile ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4_OutOfOrder_RecomputesDurationFromStoredCompletedAt()
|
||||
{
|
||||
// Terminal batch landed first: row has CompletedAt but no usable duration.
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["Status"] = "Succeeded",
|
||||
["StartedAt"] = new DateTimeOffset(T0.AddMinutes(5)), // late (completed-event) stamp
|
||||
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(5)),
|
||||
};
|
||||
// Started batch arrives now with the true (earlier) start.
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
AppName = "App", SessionId = "session", TenantId = "tenant",
|
||||
Status = "InProgress", StartedAt = T0,
|
||||
};
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal(T0, summary.StartedAt); // earlier start kept
|
||||
Assert.Equal(T0.AddMinutes(5), summary.CompletedAt); // endpoint adopted from the row
|
||||
Assert.Equal(300, summary.DurationSeconds); // recomputed — was permanently unset before PR0
|
||||
Assert.Equal("Succeeded", summary.Status); // sticky terminal (below)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_TerminalStatusIsSticky_AgainstLateInProgressBatch()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Failed", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_TerminalToTerminal_StaysWithIncomingEvidence()
|
||||
{
|
||||
// Failed row + succeeded batch (successful retry) → the new terminal wins.
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "Failed" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "Succeeded", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Equal("Succeeded", summary.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4Guard_DoesNotFireWithoutStoredCompletedAt()
|
||||
{
|
||||
var existing = new TableEntity("tenant", "session_App") { ["Status"] = "InProgress" };
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Null(summary.CompletedAt);
|
||||
Assert.Equal(0, summary.DurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_Q4Guard_IgnoresStoredCompletedAtBeforeIncomingStart()
|
||||
{
|
||||
// Negative interval (clock mess) must not produce a negative duration.
|
||||
var existing = new TableEntity("tenant", "session_App")
|
||||
{
|
||||
["Status"] = "Succeeded",
|
||||
["CompletedAt"] = new DateTimeOffset(T0.AddMinutes(-10)),
|
||||
};
|
||||
var summary = new AppInstallSummary { AppName = "App", Status = "InProgress", StartedAt = T0 };
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
|
||||
Assert.Null(summary.CompletedAt);
|
||||
Assert.Equal(0, summary.DurationSeconds);
|
||||
}
|
||||
|
||||
// ── entity builder: sentinel gating for the new column ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void EntityBuilder_TerminalState_IsSentinelGated()
|
||||
{
|
||||
var withState = new AppInstallSummary { AppName = "App", TerminalState = "Skipped" };
|
||||
var entity = TableStorageService.BuildAppInstallSummaryEntity(withState, "rk");
|
||||
Assert.Equal("Skipped", entity.GetString("TerminalState"));
|
||||
|
||||
var withoutState = new AppInstallSummary { AppName = "App" };
|
||||
var empty = TableStorageService.BuildAppInstallSummaryEntity(withoutState, "rk");
|
||||
// Column absent → Merge-mode preserves a prior batch's terminal state.
|
||||
Assert.False(empty.ContainsKey("TerminalState"));
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,58 @@ public class AppsAnalyticsFailureRateTests
|
||||
Assert.Equal(50d, todayBucket.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
// ── PR0 (2026-07-26): skip / unmeasured classification ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void List_Skips_LeaveRateAndDurations_AndAreReportedSeparately()
|
||||
{
|
||||
var skip = A("Succeeded");
|
||||
skip.TerminalState = "Skipped";
|
||||
skip.DurationSeconds = 0;
|
||||
var unmeasured = A("Succeeded");
|
||||
unmeasured.TerminalState = "Installed";
|
||||
unmeasured.DurationSeconds = 0;
|
||||
|
||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
var app = root.GetProperty("apps")[0];
|
||||
|
||||
Assert.Equal(4, app.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(2, app.GetProperty("succeeded").GetInt32()); // real install + unmeasured
|
||||
Assert.Equal(1, app.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(1, app.GetProperty("unmeasured").GetInt32());
|
||||
// 1 / (1 + 2) — the skip is not an attempt.
|
||||
Assert.Equal(33.3d, app.GetProperty("failureRate").GetDouble());
|
||||
// avg over the single measured row (30s) — the zero must not drag it to 15.
|
||||
Assert.Equal(30d, app.GetProperty("avgDurationSeconds").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_Summary_CarriesSkippedAndUnmeasured_AndP95OverMeasuredOnly()
|
||||
{
|
||||
var skip = A("Succeeded");
|
||||
skip.TerminalState = "Skipped";
|
||||
skip.DurationSeconds = 0;
|
||||
var unmeasured = A("Succeeded");
|
||||
unmeasured.TerminalState = "Installed";
|
||||
unmeasured.DurationSeconds = 0;
|
||||
var measured = A("Succeeded");
|
||||
measured.TerminalState = "Installed";
|
||||
measured.DurationSeconds = 100;
|
||||
|
||||
var root = await BuildAnalyticsAsync(new List<AppInstallSummary> { skip, unmeasured, measured });
|
||||
var summary = root.GetProperty("summary");
|
||||
|
||||
Assert.Equal(2, summary.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(1, summary.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(1, summary.GetProperty("unmeasured").GetInt32());
|
||||
Assert.Equal(100d, summary.GetProperty("avgDurationSeconds").GetDouble());
|
||||
Assert.Equal(100, summary.GetProperty("p95DurationSeconds").GetInt32());
|
||||
Assert.Equal(0d, summary.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_DeviceModelBreakdown_RatesAndSampleFloor_UseFinishedInstalls()
|
||||
{
|
||||
|
||||
@@ -34,6 +34,14 @@ public class AppsProjectionEquivalenceTests
|
||||
["AppType"] = "Win32",
|
||||
["AppVersion"] = "2.1.0",
|
||||
["Status"] = "Failed",
|
||||
// PR0/PR1 classification columns. The fixture MUST populate every column a Build*
|
||||
// method reads: a column absent here passes the equivalence assert trivially (both
|
||||
// sides map to default) — exactly how the missing TerminalState projection slipped
|
||||
// through and zeroed totalSkipped in production (2026-07-27).
|
||||
["TerminalState"] = "Skipped",
|
||||
["AppId"] = "9e8d7c6b-5a49-4832-9182-736451234567",
|
||||
["EspBlocking"] = true,
|
||||
["AppIdCollision"] = true,
|
||||
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)),
|
||||
["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)),
|
||||
["DurationSeconds"] = 340,
|
||||
@@ -83,6 +91,9 @@ public class AppsProjectionEquivalenceTests
|
||||
|
||||
Assert.Equal(fromFull.AppName, fromProjected.AppName);
|
||||
Assert.Equal(fromFull.Status, fromProjected.Status);
|
||||
// IsSkipTerminalState / skip classification input — see the fixture comment.
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
|
||||
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
|
||||
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
|
||||
@@ -122,6 +133,10 @@ public class AppsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
|
||||
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
|
||||
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppId, fromProjected.AppId);
|
||||
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -131,7 +131,7 @@ public class AuthFunctionSideEffectTests
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
null), Times.Once);
|
||||
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
<None Update="device-cert-sample.pem">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData\oobe-bootstrap-golden.ps1">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
|
||||
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
|
||||
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
|
||||
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
|
||||
/// parsing is defensive (only the alert rule name is mandatory).
|
||||
/// </summary>
|
||||
public class AzureMonitorAlertWebhookFunctionTests
|
||||
{
|
||||
// ── SecretMatches ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SecretMatches_accepts_equal_secrets()
|
||||
{
|
||||
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("wrong", "right")]
|
||||
[InlineData("right-but-longer", "right")]
|
||||
[InlineData("", "configured")]
|
||||
[InlineData(null, "configured")]
|
||||
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
|
||||
{
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void SecretMatches_fails_closed_when_not_configured(string? configured)
|
||||
{
|
||||
// While the app setting is unset, NOTHING matches — not even an empty provided value.
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
|
||||
}
|
||||
|
||||
// ── MapSeverity ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sev0", OpsEventSeverity.Critical)]
|
||||
[InlineData("Sev1", OpsEventSeverity.Error)]
|
||||
[InlineData("Sev2", OpsEventSeverity.Warning)]
|
||||
[InlineData("Sev3", OpsEventSeverity.Info)]
|
||||
[InlineData("Sev4", OpsEventSeverity.Info)]
|
||||
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Sev5")]
|
||||
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
|
||||
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
|
||||
{
|
||||
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
// ── TryParse ───────────────────────────────────────────────────────────
|
||||
|
||||
private const string FullPayload = """
|
||||
{
|
||||
"schemaId": "azureMonitorCommonAlertSchema",
|
||||
"data": {
|
||||
"essentials": {
|
||||
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
|
||||
"alertRule": "Portal RSC prefetch tail",
|
||||
"severity": "Sev2",
|
||||
"signalType": "Log",
|
||||
"monitorCondition": "Fired",
|
||||
"monitoringService": "Log Alerts V2",
|
||||
"alertTargetIDs": [
|
||||
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
|
||||
],
|
||||
"description": "RSC prefetches exceeding 10s",
|
||||
"firedDateTime": "2026-07-27T15:30:00.000Z"
|
||||
},
|
||||
"alertContext": {
|
||||
"condition": {
|
||||
"windowSize": "PT1H",
|
||||
"allOf": [
|
||||
{
|
||||
"searchQuery": "dependencies | where name contains '_rsc='",
|
||||
"metricValue": 18.0,
|
||||
"operator": "GreaterThan",
|
||||
"threshold": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void TryParse_extracts_essentials_and_metric_value()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
|
||||
Assert.Equal("Sev2", alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition);
|
||||
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
|
||||
Assert.Equal("Log Alerts V2", alert.MonitoringService);
|
||||
// No targetResourceName in the payload → resource name segment of the first target ID.
|
||||
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
|
||||
Assert.Equal(18.0, alert.MetricValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_prefers_explicit_target_resource_name()
|
||||
{
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
|
||||
"alertTargetIDs":["/sub/x/other-name"]}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.Equal("explicit-name", alert!.TargetResource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_minimal_payload_needs_only_the_alert_rule()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(
|
||||
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("minimal", alert!.AlertRule);
|
||||
Assert.Null(alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
|
||||
Assert.Null(alert.Description);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{}")] // no data
|
||||
[InlineData("""{"data":{}}""")] // no essentials
|
||||
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
|
||||
public void TryParse_rejects_unusable_payloads(string? body)
|
||||
{
|
||||
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_tolerates_malformed_alert_context()
|
||||
{
|
||||
// alertContext with unexpected shapes must not break essentials extraction.
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
|
||||
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Sev0", alert!.Severity);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the injection-defense invariants of <see cref="BootstrapScriptValueValidator"/>,
|
||||
/// ported from the portal's utils/bootstrapValidation.ts (and its vitest suite). The
|
||||
/// validated values are interpolated into a PowerShell script that runs as SYSTEM
|
||||
/// during OOBE — any relaxation here widens the injection surface.
|
||||
/// </summary>
|
||||
public class BootstrapScriptValueValidatorTests
|
||||
{
|
||||
private const string GoodTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string GoodToken = "22222222-2222-2222-2222-222222222222";
|
||||
private const string GoodUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
private static DateTime FutureExpiry => DateTime.UtcNow.AddHours(24);
|
||||
|
||||
private static bool Validate(
|
||||
string tenantId = GoodTenant,
|
||||
string token = GoodToken,
|
||||
string url = GoodUrl,
|
||||
DateTime? expiresAt = null,
|
||||
BootstrapScriptValueValidator.ValidationFailure? expectedFailure = null)
|
||||
{
|
||||
var ok = BootstrapScriptValueValidator.TryValidate(
|
||||
tenantId, token, url, expiresAt ?? FutureExpiry, out _, out var failure);
|
||||
if (!ok && expectedFailure.HasValue)
|
||||
Assert.Equal(expectedFailure.Value, failure);
|
||||
return ok;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_valid_values()
|
||||
{
|
||||
Assert.True(Validate());
|
||||
}
|
||||
|
||||
// ── tenantId / token: exact canonical GUIDs only ──
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("11111111111111111111111111111111")] // "N" format — no hyphens
|
||||
[InlineData("{11111111-1111-1111-1111-111111111111}")] // "B" format
|
||||
[InlineData(" 11111111-1111-1111-1111-111111111111")] // leading space (TryParseExact would trim)
|
||||
[InlineData("11111111-1111-1111-1111-11111111111$")]
|
||||
public void Rejects_non_canonical_tenant_ids(string tenantId)
|
||||
{
|
||||
Assert.False(Validate(tenantId: tenantId,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_non_guid_token()
|
||||
{
|
||||
Assert.False(Validate(token: "'; $(calc); '",
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.Token));
|
||||
}
|
||||
|
||||
// ── agentDownloadUrl: https + host allow-list + strict path ──
|
||||
[Theory]
|
||||
[InlineData("https://evil.example.com/x.zip'; $(calc); '")] // hostile payload from the vitest suite
|
||||
[InlineData("https://evil.example.com/agent/AutopilotMonitor-Agent.zip")]
|
||||
[InlineData("http://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // not https
|
||||
[InlineData("https://download.autopilotmonitor.com:8443/agent/AutopilotMonitor-Agent.zip")] // explicit port
|
||||
[InlineData("https://user:pw@download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")] // userinfo
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip?x=1")] // query
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip#f")] // fragment
|
||||
[InlineData("https://download.autopilotmonitor.com/other/AutopilotMonitor-Agent.zip")] // wrong prefix
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/.hidden.zip")] // leading dot
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.exe")] // not .zip
|
||||
[InlineData("")]
|
||||
public void Rejects_disallowed_download_urls(string url)
|
||||
{
|
||||
Assert.False(Validate(url: url,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip")]
|
||||
[InlineData("https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip")] // legacy blob host
|
||||
[InlineData("https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-v2.zip")]
|
||||
public void Accepts_allowlisted_download_urls(string url)
|
||||
{
|
||||
Assert.True(Validate(url: url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_filename_longer_than_80_chars()
|
||||
{
|
||||
// First char + 80 more before ".zip" exceeds the {0,79} tail bound.
|
||||
var tooLong = "https://download.autopilotmonitor.com/agent/a" + new string('b', 80) + ".zip";
|
||||
Assert.False(Validate(url: tooLong,
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.AgentDownloadUrl));
|
||||
|
||||
var atLimit = "https://download.autopilotmonitor.com/agent/a" + new string('b', 75) + ".zip";
|
||||
Assert.True(Validate(url: atLimit));
|
||||
}
|
||||
|
||||
// ── expiresAt: strictly future, at most 14 days out ──
|
||||
[Fact]
|
||||
public void Rejects_past_expiry()
|
||||
{
|
||||
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddMinutes(-1),
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_expiry_beyond_14_days()
|
||||
{
|
||||
Assert.False(Validate(expiresAt: DateTime.UtcNow.AddDays(15),
|
||||
expectedFailure: BootstrapScriptValueValidator.ValidationFailure.ExpiresAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_expiry_within_the_window()
|
||||
{
|
||||
Assert.True(Validate(expiresAt: DateTime.UtcNow.AddDays(7)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
|
||||
/// a set filter; an unset filter matches everything.
|
||||
/// </summary>
|
||||
public class ConnectionTypeFilterTests
|
||||
{
|
||||
private static SessionSummary Session(string? connectionType) =>
|
||||
new() { SessionId = "s", TenantId = "t", ConnectionType = connectionType };
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("WiFi")]
|
||||
[InlineData("Ethernet")]
|
||||
public void NoFilter_AlwaysMatches(string? connectionType)
|
||||
{
|
||||
var filter = new SessionSearchFilter();
|
||||
Assert.True(TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ethernet", true)]
|
||||
[InlineData("ethernet", true)] // case-insensitive, matching the other string filters
|
||||
[InlineData("WiFi", false)]
|
||||
[InlineData(null, false)] // legacy session without the projected column
|
||||
public void EthernetFilter_MatchesOnlyEthernet(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "Ethernet" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WiFi", true)]
|
||||
[InlineData("Ethernet", false)]
|
||||
[InlineData(null, false)]
|
||||
public void WiFiFilter_MatchesOnlyWiFi(string? connectionType, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { ConnectionType = "WiFi" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesConnectionType(Session(connectionType), filter));
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 15 cascade tables + tombstone = 16 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(16, manifest.Steps.Count);
|
||||
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
|
||||
// no inventory steps when no contributions row.
|
||||
Assert.Equal(17, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsByStage(1, propValue: "stage_marker")
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker"));
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -145,8 +147,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps.
|
||||
Assert.Equal(15, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
|
||||
Assert.Equal(16, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -175,8 +177,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
|
||||
Assert.Equal(18, manifest.Steps.Count);
|
||||
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
|
||||
Assert.Equal(19, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -640,6 +642,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _vulnerabilityReport;
|
||||
private TableEntity? _deviceSnapshot;
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
@@ -719,6 +722,16 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionTimeBreakdown(string propValue = "tab")
|
||||
{
|
||||
_sessionTimeBreakdown = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
};
|
||||
_sessionTimeBreakdown.ETag = new ETag("0xTAB");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithEventTypeIndex(int n, string propValue = "eti")
|
||||
{
|
||||
// PK = {tenantId}_{eventType}, RK ends with _{sessionId}.
|
||||
@@ -796,6 +809,8 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_deviceSnapshot);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_eventSessionIndex);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTimeBreakdowns, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F2 PR4 (insights spec §F2): device-key normalization (junk serials), terminal-only chain
|
||||
/// maintenance (upsert, cap, deletion-ref pruning), journey grouping (first-success end,
|
||||
/// 30-day gap, Incomplete attempts, WG-Pending stays open), the pure daily FTR aggregation
|
||||
/// core (<see cref="MaintenanceService.BuildDeviceJourneyAggregates"/>) and the persistence
|
||||
/// round-trips (table-storage-serialization rule — every property must survive Store→Map).
|
||||
/// </summary>
|
||||
public class DeviceJourneyAndFtrTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
|
||||
private static SessionSummary Session(
|
||||
string sessionId,
|
||||
SessionStatus status = SessionStatus.Succeeded,
|
||||
DateTime? startedAt = null,
|
||||
DateTime? completedAt = null,
|
||||
int? durationSeconds = 1800,
|
||||
string? serial = "PF4X1ABC",
|
||||
string tenantId = TenantA,
|
||||
bool wg = false,
|
||||
string enrollmentType = "v1",
|
||||
string? adminMarkedAction = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
SerialNumber = serial!,
|
||||
Status = status,
|
||||
StartedAt = startedAt ?? T0,
|
||||
CompletedAt = completedAt ?? (startedAt ?? T0).AddSeconds(durationSeconds ?? 0),
|
||||
DurationSeconds = durationSeconds,
|
||||
IsPreProvisioned = wg,
|
||||
EnrollmentType = enrollmentType,
|
||||
AdminMarkedAction = adminMarkedAction,
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Laptop 5",
|
||||
};
|
||||
|
||||
private static DeviceSessionRef Ref(
|
||||
string sessionId, SessionStatus status, DateTime startedAt, DateTime? completedAt = null)
|
||||
=> new()
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = status.ToString(),
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt ?? startedAt.AddMinutes(30),
|
||||
};
|
||||
|
||||
// ── serial normalization / junk exclusion ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("abc")] // shorter than 4 chars
|
||||
[InlineData("0")]
|
||||
[InlineData("None")]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData("Unknown")] // the agent's WMI-failure sentinel (audit Q5)
|
||||
[InlineData("unknown")]
|
||||
[InlineData("System Serial Number")]
|
||||
[InlineData("TO BE FILLED BY O.E.M.")]
|
||||
[InlineData("Default String")]
|
||||
[InlineData(" Default string ")] // junk match happens after trim + case-fold
|
||||
public void NormalizeSerial_JunkAndPlaceholders_YieldNull(string? raw)
|
||||
{
|
||||
Assert.Null(DeviceJourneyCalculator.NormalizeSerial(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PF4X1ABC", "pf4x1abc")]
|
||||
[InlineData(" PF4X1ABC ", "pf4x1abc")] // trim
|
||||
[InlineData("pf4x1abc", "pf4x1abc")]
|
||||
[InlineData("1234", "1234")] // exactly 4 chars is a valid identity
|
||||
public void NormalizeSerial_RealSerials_TrimAndCaseFold(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyCalculator.NormalizeSerial(raw));
|
||||
}
|
||||
|
||||
// ── terminal set / ref building ─────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionStatus.Succeeded, true)]
|
||||
[InlineData(SessionStatus.Failed, true)]
|
||||
[InlineData(SessionStatus.Incomplete, true)]
|
||||
[InlineData(SessionStatus.InProgress, false)]
|
||||
[InlineData(SessionStatus.Pending, false)] // WG part 1 sealed — an OPEN session
|
||||
[InlineData(SessionStatus.Stalled, false)]
|
||||
[InlineData(SessionStatus.AwaitingUser, false)]
|
||||
[InlineData(SessionStatus.Unknown, false)]
|
||||
public void IsTerminal_MatchesTheF2TerminalSet(SessionStatus status, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyCalculator.IsTerminal(status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_MapsAllFields_DurationIsAuthoritativeDurationSeconds()
|
||||
{
|
||||
// CompletedAt deliberately diverges from StartedAt + DurationSeconds (25 % of terminal
|
||||
// sessions in production) — the ref must carry DurationSeconds verbatim.
|
||||
var session = Session("s-1", startedAt: T0, completedAt: T0.AddHours(5), durationSeconds: 1800,
|
||||
wg: true, enrollmentType: "v1", adminMarkedAction: "Succeeded");
|
||||
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(session)!;
|
||||
|
||||
Assert.Equal("s-1", reference.SessionId);
|
||||
Assert.Equal(T0, reference.StartedAt);
|
||||
Assert.Equal(T0.AddHours(5), reference.CompletedAt);
|
||||
Assert.Equal("Succeeded", reference.Status);
|
||||
Assert.Equal("v1", reference.EnrollmentType);
|
||||
Assert.True(reference.IsPreProvisioned);
|
||||
Assert.Equal(1800, reference.DurationSeconds);
|
||||
Assert.True(reference.AdminMarked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_WhiteGlovePending_YieldsNull_NeverAnAttempt()
|
||||
{
|
||||
var pending = Session("s-1", status: SessionStatus.Pending, wg: true, durationSeconds: null);
|
||||
Assert.Null(DeviceJourneyCalculator.BuildSessionRef(pending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionRef_Incomplete_KeepsNullDuration()
|
||||
{
|
||||
var incomplete = Session("s-1", status: SessionStatus.Incomplete, durationSeconds: null);
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(incomplete)!;
|
||||
Assert.Equal("Incomplete", reference.Status);
|
||||
Assert.Null(reference.DurationSeconds);
|
||||
Assert.False(reference.AdminMarked);
|
||||
}
|
||||
|
||||
// ── chain maintenance ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_UpsertsBySessionId_ReclassificationReplacesTheEntry()
|
||||
{
|
||||
var existing = new List<DeviceSessionRef> { Ref("s-1", SessionStatus.Incomplete, T0) };
|
||||
var update = Ref("s-1", SessionStatus.Succeeded, T0);
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(existing, new[] { update });
|
||||
|
||||
var entry = Assert.Single(merged);
|
||||
Assert.Equal("Succeeded", entry.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_OrdersByStartedAt_TiebreakSessionId_AndCapsToMostRecent()
|
||||
{
|
||||
var refs = new List<DeviceSessionRef>();
|
||||
for (var i = 0; i < 25; i++)
|
||||
refs.Add(Ref($"s-{i:D2}", SessionStatus.Failed, T0.AddDays(i)));
|
||||
// Same StartedAt as s-24 — the ordinal sessionId tiebreak must keep the order stable.
|
||||
refs.Add(Ref("s-00-twin", SessionStatus.Failed, T0.AddDays(24)));
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(null, refs);
|
||||
|
||||
Assert.Equal(20, merged.Count); // cap 20 most recent
|
||||
Assert.Equal("s-06", merged[0].SessionId); // s-00..s-05 dropped (oldest)
|
||||
Assert.Equal(T0.AddDays(24), merged[^1].StartedAt);
|
||||
Assert.Equal("s-24", merged[^1].SessionId); // "s-00-twin" < "s-24" ordinal → sorts first
|
||||
Assert.Equal("s-00-twin", merged[^2].SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeChain_DropsNonTerminalRefs_Defensively()
|
||||
{
|
||||
var corrupt = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
new() { SessionId = "s-2", Status = "Pending", StartedAt = T0.AddDays(1) },
|
||||
new() { SessionId = "s-3", Status = "garbage", StartedAt = T0.AddDays(2) },
|
||||
};
|
||||
|
||||
var merged = DeviceJourneyCalculator.MergeChain(corrupt, Array.Empty<DeviceSessionRef>());
|
||||
|
||||
Assert.Equal("s-1", Assert.Single(merged).SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSessionRefs_DropsDeletedSessions_EmptyChainSignalsRowDeletion()
|
||||
{
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
|
||||
var partial = DeviceJourneyCalculator.RemoveSessionRefs(
|
||||
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1" });
|
||||
Assert.Equal("s-2", Assert.Single(partial).SessionId);
|
||||
|
||||
var empty = DeviceJourneyCalculator.RemoveSessionRefs(
|
||||
chain, new HashSet<string>(StringComparer.Ordinal) { "s-1", "s-2" });
|
||||
Assert.Empty(empty);
|
||||
}
|
||||
|
||||
// ── journey grouping ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_SingleSuccess_IsOneCompletedFirstTimeRightJourney()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(
|
||||
new[] { Ref("s-1", SessionStatus.Succeeded, T0) });
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Single(journey.Attempts);
|
||||
Assert.Equal("s-1", journey.CompletingRef!.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_FailAndIncompleteAreAttempts_JourneyEndsWithFirstSuccess()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Incomplete, T0.AddHours(2)),
|
||||
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(4)),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Equal(3, journey.Attempts.Count); // Incomplete is a non-successful attempt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_SessionAfterSuccess_StartsANewJourney_Redeployment()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(2)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeys.Count);
|
||||
Assert.True(journeys[0].Completed);
|
||||
Assert.False(journeys[1].Completed); // open — no failed verdict, just not done yet
|
||||
Assert.Null(journeys[1].CompletingRef);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_FailuresOnly_JourneyStaysOpen_NeverCompleted()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.False(journey.Completed);
|
||||
Assert.Equal(2, journey.Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapOver30Days_StartsANewJourney_AbandonedStaysUncompleted()
|
||||
{
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: T0.AddHours(1)),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddHours(1).AddDays(31)),
|
||||
Ref("s-3", SessionStatus.Succeeded, T0.AddHours(1).AddDays(31).AddHours(3)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeys.Count);
|
||||
Assert.False(journeys[0].Completed); // shelved/abandoned — never counts for FTR
|
||||
Assert.Single(journeys[0].Attempts);
|
||||
Assert.True(journeys[1].Completed);
|
||||
Assert.Equal(2, journeys[1].Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapBoundary_Exactly30DaysStaysOneJourney_StrictlyMoreSplits()
|
||||
{
|
||||
var prevEnd = T0.AddHours(1);
|
||||
|
||||
var exactly30 = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
|
||||
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30)),
|
||||
});
|
||||
Assert.Single(exactly30); // spec: "> 30 days" — the boundary itself does not split
|
||||
|
||||
var over30 = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd),
|
||||
Ref("s-2", SessionStatus.Failed, prevEnd.AddDays(30).AddHours(1)),
|
||||
});
|
||||
Assert.Equal(2, over30.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_GapMeasuredFromCompletedAt_NotStartedAt()
|
||||
{
|
||||
// Previous attempt STARTED 40 days before the next one but went terminal only 10 days
|
||||
// before it — the gap is 10 days, one journey. StartedAt-based measuring would split.
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0.AddDays(-40), completedAt: T0.AddDays(-10)),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0),
|
||||
});
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.Equal(2, journey.Attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupJourneys_WhiteGloveIsOneSessionRow_OneAttempt_NoStitching()
|
||||
{
|
||||
// WG part 1 + part 2 share one session row (second-sweep finding): a completed WG
|
||||
// enrollment is exactly one ref and one first-time-right attempt.
|
||||
var wg = Session("s-1", wg: true, durationSeconds: 5400, completedAt: T0.AddDays(6));
|
||||
var reference = DeviceJourneyCalculator.BuildSessionRef(wg)!;
|
||||
|
||||
var journeys = DeviceJourneyCalculator.GroupJourneys(new[] { reference });
|
||||
|
||||
var journey = Assert.Single(journeys);
|
||||
Assert.True(journey.Completed);
|
||||
Assert.Single(journey.Attempts);
|
||||
Assert.Equal(5400, journey.Attempts[0].DurationSeconds); // pause-free combined duration, verbatim
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Derive_CurrentJourneyAttempts_IsTheLastJourneys_OpenOrCompleted()
|
||||
{
|
||||
var (journeyCount, currentAttempts) = DeviceJourneyCalculator.Derive(new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Succeeded, T0),
|
||||
Ref("s-2", SessionStatus.Failed, T0.AddDays(1)),
|
||||
Ref("s-3", SessionStatus.Failed, T0.AddDays(2)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, journeyCount);
|
||||
Assert.Equal(2, currentAttempts); // the open redeployment journey has 2 attempts so far
|
||||
|
||||
Assert.Equal((0, 0), DeviceJourneyCalculator.Derive(Array.Empty<DeviceSessionRef>()));
|
||||
}
|
||||
|
||||
// ── history row assembly (display precedence) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildDeviceHistoryRow_DisplayFields_FollowTheNewestChainEntry()
|
||||
{
|
||||
var newest = Session("s-2", serial: " PF4X1ABC ", startedAt: T0.AddDays(1));
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
|
||||
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing: null, newest);
|
||||
|
||||
Assert.Equal("PF4X1ABC", row.SerialNumber); // trimmed original casing
|
||||
Assert.Equal("Contoso", row.Manufacturer);
|
||||
Assert.Equal("Laptop 5", row.Model);
|
||||
Assert.Equal(2, row.CurrentJourneyAttempts);
|
||||
Assert.Equal(1, row.JourneyCount);
|
||||
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, row.JourneyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDeviceHistoryRow_BackfillingAnOlderSession_KeepsExistingDisplayFields()
|
||||
{
|
||||
var older = Session("s-1", serial: "pf4x1abc", startedAt: T0);
|
||||
older.Model = "Laptop 4"; // stale model from the older enrollment
|
||||
var chain = new List<DeviceSessionRef>
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddDays(1)),
|
||||
};
|
||||
var existing = new DeviceHistory
|
||||
{
|
||||
TenantId = TenantA, SerialKey = "pf4x1abc",
|
||||
SerialNumber = "PF4X1ABC", Manufacturer = "Contoso", Model = "Laptop 5",
|
||||
};
|
||||
|
||||
var row = TableStorageService.BuildDeviceHistoryRow(TenantA, "pf4x1abc", chain, existing, older);
|
||||
|
||||
Assert.Equal("PF4X1ABC", row.SerialNumber); // not regressed to the older session's data
|
||||
Assert.Equal("Laptop 5", row.Model);
|
||||
}
|
||||
|
||||
// ── persistence round-trips ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DeviceHistoryEntity_RoundTripsAllFields_IncludingChainJson()
|
||||
{
|
||||
var history = new DeviceHistory
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SerialKey = "pf4x1abc",
|
||||
SerialNumber = "PF4X1ABC",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Laptop 5",
|
||||
CurrentJourneyAttempts = 2,
|
||||
JourneyCount = 3,
|
||||
JourneyVersion = 1,
|
||||
LastUpdated = T0,
|
||||
Chain = new List<DeviceSessionRef>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SessionId = "s-1", StartedAt = T0, CompletedAt = T0.AddHours(5),
|
||||
Status = "Succeeded", EnrollmentType = "v2", IsPreProvisioned = true,
|
||||
DurationSeconds = 1800, AdminMarked = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
SessionId = "s-2", StartedAt = T0.AddDays(1), CompletedAt = null,
|
||||
Status = "Incomplete", EnrollmentType = "v1", DurationSeconds = null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var mapped = TableStorageService.MapToDeviceHistory(
|
||||
TableStorageService.BuildDeviceHistoryEntity(history));
|
||||
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("pf4x1abc", mapped.SerialKey);
|
||||
Assert.Equal("PF4X1ABC", mapped.SerialNumber);
|
||||
Assert.Equal("Contoso", mapped.Manufacturer);
|
||||
Assert.Equal("Laptop 5", mapped.Model);
|
||||
Assert.Equal(2, mapped.CurrentJourneyAttempts);
|
||||
Assert.Equal(3, mapped.JourneyCount);
|
||||
Assert.Equal(1, mapped.JourneyVersion);
|
||||
Assert.Equal(T0, mapped.LastUpdated);
|
||||
|
||||
Assert.Equal(2, mapped.Chain.Count);
|
||||
var first = mapped.Chain[0];
|
||||
Assert.Equal("s-1", first.SessionId);
|
||||
Assert.Equal(T0, first.StartedAt);
|
||||
Assert.Equal(T0.AddHours(5), first.CompletedAt);
|
||||
Assert.Equal("Succeeded", first.Status);
|
||||
Assert.Equal("v2", first.EnrollmentType);
|
||||
Assert.True(first.IsPreProvisioned);
|
||||
Assert.Equal(1800, first.DurationSeconds);
|
||||
Assert.True(first.AdminMarked);
|
||||
var second = mapped.Chain[1];
|
||||
Assert.Null(second.CompletedAt); // Incomplete: no terminal timestamp requirement
|
||||
Assert.Null(second.DurationSeconds); // Incomplete: deliberately no duration — stays null
|
||||
Assert.False(second.AdminMarked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceHistoryEntity_RowKeyEncoding_MakesForbiddenKeyCharsSafe_AndRoundTrips()
|
||||
{
|
||||
// '/', '\', '#', '?' are forbidden in Table Storage keys — a defensive encoding keeps a
|
||||
// pathological serial writable, and the mapper restores the exact normalized serial.
|
||||
var weird = "ab/cd\\ef#1?x";
|
||||
var history = new DeviceHistory { TenantId = TenantA, SerialKey = weird };
|
||||
|
||||
var entity = TableStorageService.BuildDeviceHistoryEntity(history);
|
||||
Assert.All(new[] { "/", "\\", "#", "?" }, c => Assert.DoesNotContain(c, entity.RowKey));
|
||||
|
||||
Assert.Equal(weird, TableStorageService.MapToDeviceHistory(entity).SerialKey);
|
||||
|
||||
// Ordinary serials stay human-readable.
|
||||
Assert.Equal("pf4x1abc", TableStorageService.SerialRowKey("pf4x1abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceJourneyAggregateEntity_RoundTripsAllFields_RowKeyIsTheDate()
|
||||
{
|
||||
var aggregate = new DeviceJourneyDailyAggregate
|
||||
{
|
||||
TenantId = TenantA,
|
||||
Date = "2026-07-20",
|
||||
JourneyVersion = 1,
|
||||
CompletedJourneyCount = 23,
|
||||
FirstTimeRightCount = 19,
|
||||
ExcludedSessionCount = 2,
|
||||
ComputedAt = T0,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
|
||||
{
|
||||
new() { Attempts = 1, JourneyCount = 19 },
|
||||
new() { Attempts = 2, JourneyCount = 3 },
|
||||
new() { Attempts = 4, JourneyCount = 1 },
|
||||
},
|
||||
};
|
||||
|
||||
var entity = TableStorageService.BuildDeviceJourneyAggregateEntity(aggregate);
|
||||
Assert.Equal("2026-07-20", entity.RowKey);
|
||||
|
||||
var mapped = TableStorageService.MapToDeviceJourneyAggregate(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("2026-07-20", mapped.Date);
|
||||
Assert.Equal(1, mapped.JourneyVersion);
|
||||
Assert.Equal(23, mapped.CompletedJourneyCount);
|
||||
Assert.Equal(19, mapped.FirstTimeRightCount);
|
||||
Assert.Equal(2, mapped.ExcludedSessionCount);
|
||||
Assert.Equal(T0, mapped.ComputedAt);
|
||||
Assert.Equal(3, mapped.AttemptHistogram.Count);
|
||||
Assert.Equal(1, mapped.AttemptHistogram[0].Attempts);
|
||||
Assert.Equal(19, mapped.AttemptHistogram[0].JourneyCount);
|
||||
Assert.Equal(4, mapped.AttemptHistogram[2].Attempts);
|
||||
}
|
||||
|
||||
// ── daily FTR aggregation core ──────────────────────────────────────────
|
||||
|
||||
private static readonly DateTime WindowStart = T0.Date.AddDays(-30);
|
||||
private static readonly DateTime WindowEnd = T0.Date.AddDays(1);
|
||||
|
||||
private static DeviceHistory History(string tenantId, params DeviceSessionRef[] chain)
|
||||
=> new() { TenantId = tenantId, SerialKey = Guid.NewGuid().ToString("N"), Chain = chain.ToList() };
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_BucketByCompletingSessionsStartDate_AndMirrorGlobal()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Tenant A device 1: fail → success (2 attempts, completes on T0 date).
|
||||
History(TenantA,
|
||||
Ref("a1-1", SessionStatus.Failed, T0.AddDays(-1)),
|
||||
Ref("a1-2", SessionStatus.Succeeded, T0)),
|
||||
// Tenant A device 2: first-time right on the same date.
|
||||
History(TenantA, Ref("a2-1", SessionStatus.Succeeded, T0.AddHours(2))),
|
||||
// Tenant B device: first-time right on a different date.
|
||||
History(TenantB, Ref("b1-1", SessionStatus.Succeeded, T0.AddDays(-3))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
// Tenant A (1 date) + Tenant B (1 date) + global (2 dates) = 4 rows.
|
||||
Assert.Equal(4, rows.Count);
|
||||
|
||||
var tenantARow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(T0.ToString("yyyy-MM-dd"), tenantARow.Date);
|
||||
Assert.Equal(2, tenantARow.CompletedJourneyCount);
|
||||
Assert.Equal(1, tenantARow.FirstTimeRightCount);
|
||||
Assert.Equal(DeviceJourneyCalculator.CurrentVersion, tenantARow.JourneyVersion);
|
||||
Assert.Equal(2, tenantARow.AttemptHistogram.Count);
|
||||
Assert.Equal(1, tenantARow.AttemptHistogram[0].Attempts);
|
||||
Assert.Equal(1, tenantARow.AttemptHistogram[0].JourneyCount);
|
||||
Assert.Equal(2, tenantARow.AttemptHistogram[1].Attempts);
|
||||
|
||||
var globalOnT0 = rows.Single(r => r.TenantId == "global" && r.Date == T0.ToString("yyyy-MM-dd"));
|
||||
Assert.Equal(2, globalOnT0.CompletedJourneyCount); // tenant B's journey sits on its own date
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_OpenAndAbandonedJourneys_NeverCount()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Open journey: failures only (e.g. a WG device still waiting for its user session
|
||||
// has, at most, prior failed attempts in the chain — the Pending row itself never
|
||||
// becomes a ref).
|
||||
History(TenantA, Ref("a1-1", SessionStatus.Failed, T0)),
|
||||
// Abandoned journey (31d gap, no success) followed by a completed journey.
|
||||
History(TenantA,
|
||||
Ref("a2-1", SessionStatus.Failed, T0.AddDays(-20), completedAt: T0.AddDays(-20).AddHours(1)),
|
||||
Ref("a2-2", SessionStatus.Succeeded, T0.AddDays(11).AddHours(2))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, T0.Date.AddDays(12), T0);
|
||||
|
||||
// Only the completed journey produced rows: tenant + global on one date.
|
||||
Assert.Equal(2, rows.Count);
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(1, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(1, tenantRow.FirstTimeRightCount); // the abandoned attempt belongs to a DIFFERENT journey
|
||||
Assert.Equal(T0.AddDays(11).ToString("yyyy-MM-dd"), tenantRow.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_JourneysCompletedOutsideTheWindow_AreSkipped()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
History(TenantA, Ref("a1-1", SessionStatus.Succeeded, WindowStart.AddDays(-1))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
Assert.Empty(rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_JunkSerialExclusions_ProduceDisclosureRows()
|
||||
{
|
||||
var excluded = new List<SessionSummary>
|
||||
{
|
||||
Session("x-1", serial: "Unknown", startedAt: T0),
|
||||
Session("x-2", serial: "Unknown", startedAt: T0.AddHours(1)),
|
||||
Session("x-3", serial: "Unknown", startedAt: WindowStart.AddDays(-2)), // outside window — ignored
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
Array.Empty<DeviceHistory>(), excluded, WindowStart, WindowEnd, T0);
|
||||
|
||||
Assert.Equal(2, rows.Count); // tenant + global disclosure rows, no journey claim
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(0, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(0, tenantRow.FirstTimeRightCount);
|
||||
Assert.Empty(tenantRow.AttemptHistogram);
|
||||
Assert.Equal(2, tenantRow.ExcludedSessionCount);
|
||||
Assert.Equal(2, rows.Single(r => r.TenantId == "global").ExcludedSessionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FtrAggregates_TwoCompletedJourneysOfOneDevice_SameDay_BothCount()
|
||||
{
|
||||
// Redeploy on the same day: success closes journey 1, the next success closes journey 2.
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
History(TenantA,
|
||||
Ref("a1-1", SessionStatus.Succeeded, T0),
|
||||
Ref("a1-2", SessionStatus.Succeeded, T0.AddHours(3))),
|
||||
};
|
||||
|
||||
var rows = MaintenanceService.BuildDeviceJourneyAggregates(
|
||||
histories, Array.Empty<SessionSummary>(), WindowStart, WindowEnd, T0);
|
||||
|
||||
var tenantRow = rows.Single(r => r.TenantId == TenantA);
|
||||
Assert.Equal(2, tenantRow.CompletedJourneyCount);
|
||||
Assert.Equal(2, tenantRow.FirstTimeRightCount);
|
||||
}
|
||||
|
||||
// ── attempt number (session banner, PR5) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_TerminalSessionInChain_ReturnsPositionWithinItsJourney()
|
||||
{
|
||||
var chain = new[]
|
||||
{
|
||||
Ref("s-1", SessionStatus.Failed, T0),
|
||||
Ref("s-2", SessionStatus.Succeeded, T0.AddHours(2)),
|
||||
Ref("s-3", SessionStatus.Failed, T0.AddDays(5)), // redeployment → new journey
|
||||
};
|
||||
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-1", T0));
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddHours(2)));
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", T0.AddDays(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_ContinuesTheOpenJourney()
|
||||
{
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0) };
|
||||
// s-2 is still InProgress — not a chain ref; the virtual-attempt rule places it.
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_AfterCompletedJourney_IsAttemptOne()
|
||||
{
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Succeeded, T0) };
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", T0.AddDays(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_LiveSession_AfterThirtyDayGap_IsAttemptOne()
|
||||
{
|
||||
var prevEnd = T0.AddHours(1);
|
||||
var chain = new[] { Ref("s-1", SessionStatus.Failed, T0, completedAt: prevEnd) };
|
||||
Assert.Equal(1, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-2", prevEnd.AddDays(31)));
|
||||
// Inside the gap boundary the open journey continues instead.
|
||||
Assert.Equal(2, DeviceJourneyCalculator.ComputeAttemptNumber(chain, "s-3", prevEnd.AddDays(29)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAttemptNumber_EmptyChain_YieldsNull_NoGuessedPosition()
|
||||
{
|
||||
Assert.Null(DeviceJourneyCalculator.ComputeAttemptNumber(
|
||||
Array.Empty<DeviceSessionRef>(), "s-1", T0));
|
||||
}
|
||||
|
||||
// ── fleet response helpers (PR5) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SumAggregates_SumsAdditiveCounts_AndMergesHistograms()
|
||||
{
|
||||
var daily = new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CompletedJourneyCount = 2, FirstTimeRightCount = 1, ExcludedSessionCount = 1,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket>
|
||||
{
|
||||
new() { Attempts = 1, JourneyCount = 1 },
|
||||
new() { Attempts = 2, JourneyCount = 1 },
|
||||
},
|
||||
},
|
||||
new()
|
||||
{
|
||||
CompletedJourneyCount = 1, FirstTimeRightCount = 1, ExcludedSessionCount = 0,
|
||||
AttemptHistogram = new List<DeviceJourneyAttemptBucket> { new() { Attempts = 1, JourneyCount = 1 } },
|
||||
},
|
||||
};
|
||||
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
|
||||
|
||||
Assert.Equal(3, totals.CompletedJourneys);
|
||||
Assert.Equal(2, totals.FirstTimeRight);
|
||||
Assert.Equal(66.7, totals.FtrRatePct);
|
||||
Assert.Equal(1, totals.ExcludedSessions);
|
||||
Assert.Equal(2, totals.AttemptHistogram.Count);
|
||||
Assert.Equal(2, totals.AttemptHistogram.Single(b => b.Attempts == 1).JourneyCount);
|
||||
Assert.Equal(1, totals.AttemptHistogram.Single(b => b.Attempts == 2).JourneyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
||||
{
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
||||
});
|
||||
|
||||
Assert.Equal(0, totals.CompletedJourneys);
|
||||
Assert.Null(totals.FtrRatePct);
|
||||
Assert.Equal(3, totals.ExcludedSessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)] // default window
|
||||
[InlineData("garbage", 30)]
|
||||
[InlineData("7", 7)]
|
||||
[InlineData("0", 1)] // clamped low
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
|
||||
{
|
||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||
// days=1 summed yesterday AND today.
|
||||
var today = new DateTime(2026, 7, 27);
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
||||
}
|
||||
|
||||
private static DeviceHistory RepeaterHistory(
|
||||
string serial, int attempts, params DeviceSessionRef[] chain)
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SerialKey = serial,
|
||||
SerialNumber = serial.ToUpperInvariant(),
|
||||
Chain = chain.ToList(),
|
||||
CurrentJourneyAttempts = attempts,
|
||||
JourneyCount = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SelectRepeatDevices_FiltersGate_WindowsOnNewestRef_AndPicksNewestFailure()
|
||||
{
|
||||
var histories = new List<DeviceHistory>
|
||||
{
|
||||
// Single attempt — not a repeat device.
|
||||
RepeaterHistory("clean-1", attempts: 1, Ref("c1", SessionStatus.Succeeded, T0)),
|
||||
// Repeat device, active in window: newest failed ref carries the reason lookup.
|
||||
RepeaterHistory("hot-1", attempts: 3,
|
||||
Ref("h1", SessionStatus.Failed, T0.AddDays(-2)),
|
||||
Ref("h2", SessionStatus.Failed, T0.AddDays(-1)),
|
||||
Ref("h3", SessionStatus.Incomplete, T0)),
|
||||
// Repeat device, but its pain is OLDER than the window — current lists only.
|
||||
RepeaterHistory("old-1", attempts: 4,
|
||||
Ref("o1", SessionStatus.Failed, T0.AddDays(-40))),
|
||||
// Lower attempt count → sorts after hot-1.
|
||||
RepeaterHistory("warm-1", attempts: 2,
|
||||
Ref("w1", SessionStatus.Failed, T0.AddDays(-3)),
|
||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
||||
};
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(2, selected.Count);
|
||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
||||
Assert.Equal("h3", selected[0].Newest.SessionId);
|
||||
Assert.Equal("h2", selected[0].NewestFailed!.SessionId); // last FAILED, not last ref
|
||||
Assert.Equal("warm-1", selected[1].History.SerialKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectRepeatDevices_CapsAtTen_ByAttemptsThenRecency()
|
||||
{
|
||||
var histories = new List<DeviceHistory>();
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
histories.Add(RepeaterHistory($"dev-{i:D2}", attempts: 2 + i,
|
||||
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
|
||||
}
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(10, selected.Count);
|
||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00");
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01");
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,77 @@ public class DistressValidationTests
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.ExtractTrustedHop("10.0.0.1, 203.0.113.5, "));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ClientIpExtractor.GetHeaderFromBindingDataJson — Flex Consumption strips the
|
||||
// X-Forwarded-* family from the proxied HTTP request; the host passes the
|
||||
// ORIGINAL header set as trigger metadata (BindingData["Headers"], JSON object).
|
||||
// Malformed metadata must fail soft (null), never throw into request handling.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetHeaderFromBindingDataJson_XffPresent_ReturnsValue()
|
||||
{
|
||||
var json = """{"Host":"example.com","X-Forwarded-For":"1.1.1.1, 203.0.113.5:443"}""";
|
||||
Assert.Equal(
|
||||
"1.1.1.1, 203.0.113.5:443",
|
||||
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHeaderFromBindingDataJson_HeaderNameCaseInsensitive()
|
||||
{
|
||||
var json = """{"x-forwarded-for":"203.0.113.5"}""";
|
||||
Assert.Equal(
|
||||
"203.0.113.5",
|
||||
ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")] // JSON but not an object
|
||||
[InlineData("""{"Host":"example.com"}""")] // header absent
|
||||
[InlineData("""{"X-Forwarded-For":42}""")] // non-string value
|
||||
public void GetHeaderFromBindingDataJson_MissingOrMalformed_ReturnsNull(string? json)
|
||||
{
|
||||
Assert.Null(ClientIpExtractor.GetHeaderFromBindingDataJson(json, "X-Forwarded-For"));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ClientIpExtractor.RemoteIpToString — transport-level fallback. On Flex the
|
||||
// worker's TCP peer is the host's LOCAL proxy: loopback means "not a client"
|
||||
// and must map to "unknown", never be stored as a forensic SourceIp
|
||||
// (production 2026-07-29: distress reports carried SourceIp 127.0.0.1).
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_Null_ReturnsUnknown()
|
||||
{
|
||||
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
|
||||
public void RemoteIpToString_Loopback_ReturnsUnknown(string ip)
|
||||
{
|
||||
Assert.Equal("unknown", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_PublicIpv4_ReturnsIp()
|
||||
{
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("203.0.113.5")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteIpToString_Ipv4MappedIpv6_FoldsToIpv4()
|
||||
{
|
||||
Assert.Equal("203.0.113.5", ClientIpExtractor.RemoteIpToString(System.Net.IPAddress.Parse("::ffff:203.0.113.5")));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sanitize — Control character stripping and truncation
|
||||
// =========================================================================
|
||||
|
||||
@@ -50,6 +50,8 @@ public class GeoMetricsProjectionEquivalenceTests
|
||||
["GeoCity"] = "Frankfurt",
|
||||
["GeoLoc"] = "50.11,8.68",
|
||||
["IsPreProvisioned"] = false,
|
||||
["AvgApiLatencyMs"] = 217.4,
|
||||
["ApiRequestCount"] = 270,
|
||||
// Noise the geo aggregation never reads.
|
||||
["SerialNumber"] = "SN-FULL",
|
||||
["Manufacturer"] = "Contoso",
|
||||
@@ -76,6 +78,8 @@ public class GeoMetricsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion);
|
||||
Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity);
|
||||
Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc);
|
||||
Assert.Equal(fromFull.AvgApiLatencyMs, fromProjected.AvgApiLatencyMs);
|
||||
Assert.Equal(fromFull.ApiRequestCount, fromProjected.ApiRequestCount);
|
||||
|
||||
// The grouping key must be identical too — it is composed from the Geo* fields.
|
||||
Assert.Equal(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the API-latency aggregation of
|
||||
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
|
||||
/// per-location and the global figure are REQUEST-WEIGHTED averages —
|
||||
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
|
||||
/// with many uploads counts proportionally. Sessions without latency data (agents predating
|
||||
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
|
||||
/// </summary>
|
||||
public class GeographicMetricsApiLatencyTests
|
||||
{
|
||||
private static SessionSummary S(string country, string city, double? avgLatencyMs, int? requestCount)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-000000000fe0",
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
Status = SessionStatus.Succeeded,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
GeoCountry = country,
|
||||
GeoRegion = country,
|
||||
GeoCity = city,
|
||||
GeoLoc = "0,0",
|
||||
AvgApiLatencyMs = avgLatencyMs,
|
||||
ApiRequestCount = requestCount,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LocationLatency_IsRequestWeighted_NotAverageOfAverages()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("ID", "Jakarta", 1000, 300),
|
||||
S("ID", "Jakarta", 500, 100),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
|
||||
Assert.Equal(875d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(2, loc.ApiLatencySessionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("DE", "Frankfurt", avgLatencyMs: null, requestCount: null), // pre-feature agent
|
||||
S("DE", "Frankfurt", avgLatencyMs: 0, requestCount: 50), // zero = no signal
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
Assert.Equal(3, loc.SessionCount);
|
||||
Assert.Equal(1, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(200d, loc.AvgApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoLatencyDataAnywhere_YieldsZeroes_NotDivisionByZero()
|
||||
{
|
||||
var sessions = new List<SessionSummary> { S("DE", "Frankfurt", null, null) };
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
Assert.Equal(0d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(0, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
|
||||
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalLatency_WeightsAcrossLocations_AndVsGlobalPctCompares()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("ID", "Jakarta", 800, 100),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
// Equal request weights → global = (200 + 800) / 2 = 500.
|
||||
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
|
||||
var de = result.Locations.Single(l => l.Country == "DE");
|
||||
var id = result.Locations.Single(l => l.Country == "ID");
|
||||
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
|
||||
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocationWithoutData_KeepsZeroPct_EvenWhenGlobalHasData()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("FR", "Paris", null, null),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var fr = result.Locations.Single(l => l.Country == "FR");
|
||||
Assert.Equal(0d, fr.AvgApiLatencyMs);
|
||||
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using AutopilotMonitor.Functions.Functions.Bootstrap;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
|
||||
/// the OOBE bootstrap script endpoint reached by customers as
|
||||
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
|
||||
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
|
||||
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
|
||||
/// the no-store cache classification — are pinned here. Script generation and value
|
||||
/// validation have their own suites.
|
||||
/// </summary>
|
||||
public class GetBootstrapScriptFunctionTests
|
||||
{
|
||||
private const string Route = "/api/bootstrap/go/abcd";
|
||||
|
||||
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
|
||||
// Table ported from the vitest suite of the original Next.js route. ──
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("abc")] // too short
|
||||
[InlineData("abcdefghijk")] // too long
|
||||
[InlineData("ab cd")]
|
||||
[InlineData("../etc")]
|
||||
[InlineData("ab%2Fcd")]
|
||||
[InlineData("abc$")]
|
||||
[InlineData("ab'cd")]
|
||||
[InlineData("ab.cd")]
|
||||
public void Rejects_invalid_code_formats(string code)
|
||||
{
|
||||
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abcd")]
|
||||
[InlineData("ABC123")]
|
||||
[InlineData("a1b2c3d4e5")]
|
||||
public void Accepts_valid_code_formats(string code)
|
||||
{
|
||||
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
||||
}
|
||||
|
||||
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
|
||||
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
|
||||
// so this pins reachability. ──
|
||||
[Fact]
|
||||
public void Route_is_registered_as_PublicAnonymous()
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_is_jwt_exempt()
|
||||
{
|
||||
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
|
||||
}
|
||||
|
||||
// ── The script body inlines the bearer token — must never be cached. ──
|
||||
[Fact]
|
||||
public void Route_is_classified_no_store()
|
||||
{
|
||||
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
|
||||
}
|
||||
|
||||
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
|
||||
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
|
||||
// someone appended to what AFD set — untrusted, fail closed. ──
|
||||
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { Fdid }, true)]
|
||||
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
|
||||
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
|
||||
[InlineData(new[] { "other-profile-id" }, false)]
|
||||
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
|
||||
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
|
||||
[InlineData(new[] { "spoofed," + Fdid }, false)]
|
||||
[InlineData(new string[0], false)]
|
||||
[InlineData(new[] { "" }, false)]
|
||||
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
|
||||
/// previously unguarded, which made a silent origin change possible. The literal here
|
||||
/// is a deliberate independent oracle per the url-registry doctrine (test files are
|
||||
/// excluded from HardcodedUrlGuardTests).
|
||||
/// </summary>
|
||||
public class CreateBootstrapSessionUrlShapeTests
|
||||
{
|
||||
[Fact]
|
||||
public void BootstrapGoBaseUrl_is_the_go_domain()
|
||||
{
|
||||
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
|
||||
}
|
||||
|
||||
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
|
||||
// query-string shape the static-export portal understands. Literal is a
|
||||
// deliberate independent oracle; the legacy /sessions/{id} path shape is
|
||||
// rewritten client-side by the portal forever. ──
|
||||
[Fact]
|
||||
public void PortalSessionUrl_is_the_query_string_shape()
|
||||
{
|
||||
Assert.Equal(
|
||||
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
|
||||
Constants.PortalSessionUrl("abc-123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortalSessionUrl_escapes_the_session_id()
|
||||
{
|
||||
Assert.Equal(
|
||||
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
|
||||
Constants.PortalSessionUrl("a&b=c"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
|
||||
{
|
||||
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
|
||||
// injectable seam): the create function must derive BootstrapUrl from
|
||||
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
|
||||
// path segment between host and code.
|
||||
var repoRoot = FindRepoRoot();
|
||||
var source = File.ReadAllText(Path.Combine(
|
||||
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
|
||||
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
|
||||
|
||||
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
|
||||
Assert.DoesNotContain("WebsiteBaseUrl}/go/", source);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the cross-tenant redaction on GET metrics/ime-versions.
|
||||
///
|
||||
/// The route is <c>MemberRead</c> (EndpointAccessPolicyCatalog) and the archive itself is a
|
||||
/// single global partition — every tenant member reads the SAME platform-wide rows. The only
|
||||
/// thing standing between an ordinary tenant user and another tenant's identifiers is the
|
||||
/// projection in <see cref="GetImeVersionHistoryFunction.BuildResponsePayload"/>. That made it
|
||||
/// worth pinning: the redaction had no test at all, and a boundary nobody asserts is one a
|
||||
/// later refactor can open without a single failure.
|
||||
///
|
||||
/// The JSON tests are the ones that matter — a property dropped from an anonymous type is only
|
||||
/// a real redaction once it is also absent from the wire, and the wire is what the customer's
|
||||
/// MCP client receives. camelCase mirrors Program.cs's PropertyNamingPolicy.
|
||||
/// </summary>
|
||||
public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
private static readonly DateTime First = new(2026, 7, 8, 3, 18, 13, DateTimeKind.Utc);
|
||||
private static readonly DateTime Last = new(2026, 7, 27, 16, 59, 34, DateTimeKind.Utc);
|
||||
|
||||
private static readonly JsonSerializerOptions CamelCase =
|
||||
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
// =========================================================================
|
||||
// Non-global caller (ordinary tenant member) — the redaction
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_DropsForeignTenantAndSessionIds()
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("firstSeenTenantId", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The values, not just the property names: a rename of the model property must not let the
|
||||
/// identifier survive under a different key. Asserting on the GUIDs catches that.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_ForeignIdentifierValuesAppearNowhereInThePayload()
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_ExposesExactlyTheFourAllowedProperties()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
|
||||
|
||||
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
|
||||
Assert.Equal(new[] { "firstSeenAt", "lastSeenAt", "sessionCount", "version" }, keys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SessionCount stays on purpose — it is a platform-wide total, and the route exists so any
|
||||
/// tenant member can track Microsoft's IME rollout. Pinned so that "the counts are global"
|
||||
/// stays a decision someone made rather than something that quietly drifts either way.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_KeepsVersionDatesAndGlobalSessionCount()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("1.103.101.0", row.GetProperty("version").GetString());
|
||||
Assert.Equal(First, row.GetProperty("firstSeenAt").GetDateTime());
|
||||
Assert.Equal(Last, row.GetProperty("lastSeenAt").GetDateTime());
|
||||
Assert.Equal(3109, row.GetProperty("sessionCount").GetInt32());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Global caller (Global Admin or Global Reader) — full archive
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_KeepsTenantAndSessionIds()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_ExposesTheFullEntry()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
|
||||
keys);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shape-preserving properties of the projection
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BothScopes_EmitAJsonArray_EvenForASingleRow()
|
||||
{
|
||||
foreach (var scope in new[] { true, false })
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(scope, MakeEntry()));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothScopes_EmptyArchive_SerializesAsEmptyArray()
|
||||
{
|
||||
foreach (var scope in new[] { true, false })
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(scope));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The repository orders by FirstSeenAt descending; the projection must not reshuffle, or a
|
||||
/// redacted caller would see a different sequence than a Global Admin for the same archive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_PreservesRowOrderAndCount()
|
||||
{
|
||||
var newest = MakeEntry("1.103.101.0", First);
|
||||
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
|
||||
var oldest = MakeEntry("1.99.101.0", First.AddDays(-131));
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(false, newest, older, oldest));
|
||||
|
||||
Assert.Equal(3, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
|
||||
Assert.Equal("1.99.101.0", doc.RootElement[2].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildResponsePayload returns `object`; the branch that keeps everything hands back the
|
||||
/// entries themselves. System.Text.Json resolves an `object` declared type via the runtime
|
||||
/// type, which is what keeps the wire format identical to the pre-extraction inline code —
|
||||
/// assert it rather than trust it, since a regression here would silently emit "{}" rows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobalPayload_SerializedAsObject_StillEmitsPopulatedRows()
|
||||
{
|
||||
var payload = GetImeVersionHistoryFunction.BuildResponsePayload([MakeEntry()], hasGlobalScope: true);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, CamelCase);
|
||||
|
||||
Assert.Contains("1.103.101.0", json, StringComparison.Ordinal);
|
||||
Assert.NotEqual("[{}]", json);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static string SerializeFor(bool hasGlobalScope, params ImeVersionHistoryEntry[] versions) =>
|
||||
JsonSerializer.Serialize(
|
||||
GetImeVersionHistoryFunction.BuildResponsePayload(versions, hasGlobalScope),
|
||||
CamelCase);
|
||||
|
||||
private static ImeVersionHistoryEntry MakeEntry(string version = "1.103.101.0", DateTime? firstSeenAt = null) =>
|
||||
new()
|
||||
{
|
||||
Version = version,
|
||||
FirstSeenAt = firstSeenAt ?? First,
|
||||
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
|
||||
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class HardcodedUrlGuardTests
|
||||
"www.autopilotmonitor.com",
|
||||
"docs.autopilotmonitor.com",
|
||||
"download.autopilotmonitor.com",
|
||||
"go.autopilotmonitor.com",
|
||||
"mcp.autopilotmonitor.com",
|
||||
"autopilotmonitor-api-eu.azurewebsites.net",
|
||||
"autopilotmonitor.blob.core.windows.net",
|
||||
|
||||
+56
@@ -135,8 +135,64 @@ public class HardwareRejectionNotificationTrackerDedupTests
|
||||
Assert.Equal(2, harness.Store.Count);
|
||||
}
|
||||
|
||||
// ── F3 rule regression: one bell per episode, keyspaces stay independent ──────
|
||||
|
||||
[Fact]
|
||||
public async Task RuleRegression_SecondRegister_SameRule_ReturnsFalse_OneBellPerEpisode()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var alert = new AutopilotMonitor.Shared.Models.RuleRegressionAlert
|
||||
{
|
||||
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
|
||||
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
|
||||
Assert.False(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
|
||||
|
||||
var entity = Assert.Single(harness.Store.Values);
|
||||
Assert.Equal("ruleregression|analyze-net-001", entity.RowKey);
|
||||
Assert.NotNull(entity.GetDateTime("FirstNotifiedAt")); // the retention sweep depends on it
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleRegression_DeleteReArms_AndDoesNotTouchOtherKeyspaces()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var alert = new AutopilotMonitor.Shared.Models.RuleRegressionAlert
|
||||
{
|
||||
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
|
||||
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
|
||||
};
|
||||
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
|
||||
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
|
||||
|
||||
await harness.Sut.DeleteRuleRegressionAsync(TenantId, "ANALYZE-NET-001");
|
||||
|
||||
// The episode re-arms; the unrelated tpmpss row survives.
|
||||
Assert.Equal("tpmpss|s4sq8685", Assert.Single(harness.Store.Values).RowKey);
|
||||
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId, alert));
|
||||
}
|
||||
|
||||
// ── Retention sweep ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOlderThanAsync_PrunesRuleRegressionRows_ReArmingLongBurningEpisodes()
|
||||
{
|
||||
var harness = new Harness();
|
||||
Assert.True(await harness.Sut.TryRegisterRuleRegressionAsync(TenantId,
|
||||
new AutopilotMonitor.Shared.Models.RuleRegressionAlert
|
||||
{
|
||||
TenantId = TenantId, RuleId = "ANALYZE-NET-001",
|
||||
FirstNotifiedAt = DateTime.UtcNow, LastEvaluatedAt = DateTime.UtcNow,
|
||||
}));
|
||||
harness.AgeRows(TimeSpan.FromDays(31));
|
||||
|
||||
// Spec §F3: retention cleanup re-arms — a month-old still-burning regression rings again.
|
||||
Assert.Equal(1, await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)));
|
||||
Assert.Empty(harness.Store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOlderThanAsync_PrunesTpmPssRows_AndReArmsThatDevicesBell()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-session API-latency projection (2026-07-28): the V2 agent emits cumulative
|
||||
/// counters <c>net_total_latency_ms</c> / <c>net_total_requests</c> in every
|
||||
/// <c>agent_metrics_snapshot</c>; ingest derives the session-wide average from the LAST
|
||||
/// snapshot of the batch and persists it via <c>UpdateSessionNetworkLatencyAsync</c>
|
||||
/// (last-write-wins on cumulative counters → replay-idempotent).
|
||||
/// Wire-format note: the ingest body is parsed with Newtonsoft into
|
||||
/// <c>Dictionary<string, object></c>, so integer JSON numbers arrive as boxed
|
||||
/// <c>long</c> — fixtures mirror that, not the agent's in-memory types.
|
||||
/// </summary>
|
||||
public class IngestApiLatencyProjectionTests
|
||||
{
|
||||
private static EnrollmentEvent Snapshot(object? totalLatencyMs, object? totalRequests)
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "agent_metrics_snapshot",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["agent_cpu_percent"] = 3.62,
|
||||
["net_requests"] = 50L,
|
||||
["net_avg_latency_ms"] = 513.0,
|
||||
},
|
||||
};
|
||||
if (totalLatencyMs != null) evt.Data["net_total_latency_ms"] = totalLatencyMs;
|
||||
if (totalRequests != null) evt.Data["net_total_requests"] = totalRequests;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CumulativeCounters_YieldSessionAverage()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(51_300L, 100L) };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(513.0, avg);
|
||||
Assert.Equal(100, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AverageIsRounded_ToOneDecimal()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(1_000L, 3L) };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out _));
|
||||
Assert.Equal(333.3, avg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastSnapshotOfBatch_Wins()
|
||||
{
|
||||
// Cumulative counters grow monotonically — the last snapshot carries the most
|
||||
// complete session average.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Snapshot(51_300L, 100L),
|
||||
new EnrollmentEvent { EventType = "download_progress" },
|
||||
Snapshot(120_000L, 200L),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(600.0, avg);
|
||||
Assert.Equal(200, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldAgentSnapshot_WithoutTotalLatencyField_DoesNotMatch()
|
||||
{
|
||||
// Agents predating net_total_latency_ms emit net_total_requests only.
|
||||
var events = new List<EnrollmentEvent> { Snapshot(totalLatencyMs: null, totalRequests: 100L) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotWithLatencyButMissingRequests_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(51_300L, totalRequests: null) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRequests_DoesNotMatch()
|
||||
{
|
||||
// No requests yet (first snapshot before the first upload completes) — no division.
|
||||
var events = new List<EnrollmentEvent> { Snapshot(0L, 0L) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingLastSnapshot_SkipsOlderAgentSnapshotAfterIt()
|
||||
{
|
||||
// A field-less (old-schema) snapshot later in the batch must not shadow an earlier
|
||||
// one that carries the counters — the predicate selects the last MATCHING snapshot.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Snapshot(51_300L, 100L),
|
||||
Snapshot(totalLatencyMs: null, totalRequests: 150L),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(513.0, avg);
|
||||
Assert.Equal(100, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchWithoutSnapshots_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "esp_phase_changed" },
|
||||
new EnrollmentEvent { EventType = "app_install_started" },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
// -------- TryGetDouble coercion ladder (mirrors PlatformMetricsService.GetDouble) --------
|
||||
|
||||
[Theory]
|
||||
[InlineData(513.5)] // double — decimal JSON number
|
||||
[InlineData(513)] // int — in-memory constructed events (tests, V1 paths)
|
||||
[InlineData(513L)] // long — Newtonsoft wire form for integer JSON numbers
|
||||
[InlineData(513f)] // float
|
||||
public void TryGetDouble_CoercesBoxedNumericPrimitives(object boxed)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = boxed };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
|
||||
Assert.Equal(Convert.ToDouble(boxed), value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDouble_ParsesStrings_InvariantCulture()
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = "513.5" };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
|
||||
Assert.Equal(513.5, value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-number")]
|
||||
[InlineData("")]
|
||||
public void TryGetDouble_RejectsUnparseableStrings(string raw)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = raw };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryGetDouble(data, "k", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDouble_MissingKey_ReturnsFalse()
|
||||
{
|
||||
Assert.False(EventIngestProcessor.TryGetDouble(new Dictionary<string, object>(), "k", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-session connection-type projection (2026-07-28): the agent stamps
|
||||
/// <c>connectionType</c> ("WiFi"/"Ethernet") into every <c>network_interface_info</c>
|
||||
/// emission (initial collect + re-collect on change); ingest takes the LAST matching
|
||||
/// event of the batch and persists it via <c>UpdateSessionConnectionTypeAsync</c>
|
||||
/// (last-write-wins → a device that switches media mid-enrollment is relabeled).
|
||||
/// The no-NIC payload ({"status":"no_active_interface"}) carries no connectionType
|
||||
/// and must never match.
|
||||
/// </summary>
|
||||
public class IngestConnectionTypeProjectionTests
|
||||
{
|
||||
private static EnrollmentEvent NicInfo(string? connectionType)
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["adapterName"] = "Ethernet 2",
|
||||
["linkSpeedMbps"] = 1000L,
|
||||
},
|
||||
};
|
||||
if (connectionType != null) evt.Data["connectionType"] = connectionType;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EthernetEmission_IsExtracted()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { NicInfo("Ethernet") };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("Ethernet", type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastEmissionOfBatch_Wins()
|
||||
{
|
||||
// Media switch mid-batch (dock removed → WiFi) — the most recent state wins.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
NicInfo("Ethernet"),
|
||||
new EnrollmentEvent { EventType = "download_progress" },
|
||||
NicInfo("WiFi"),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("WiFi", type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoActiveInterfacePayload_DoesNotMatch()
|
||||
{
|
||||
// The no-NIC emission carries only {"status":"no_active_interface"}.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
|
||||
},
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoActiveInterfaceAfterValidEmission_DoesNotShadowIt()
|
||||
{
|
||||
// The predicate selects the last event that CARRIES connectionType.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
NicInfo("WiFi"),
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = "network_interface_info",
|
||||
Data = new Dictionary<string, object> { ["status"] = "no_active_interface" },
|
||||
},
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryExtractConnectionType(events, out var type));
|
||||
Assert.Equal("WiFi", type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Cellular")]
|
||||
[InlineData("wifi")] // agent emits exact casing — anything else is unexpected
|
||||
[InlineData("")]
|
||||
public void UnexpectedValues_AreDroppedDefensively(string raw)
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { NicInfo(raw) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchWithoutNicEvents_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "esp_phase_changed" },
|
||||
new EnrollmentEvent { EventType = "agent_metrics_snapshot" },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullData_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "network_interface_info", Data = null! },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryExtractConnectionType(events, out _));
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,120 @@ public class MetricsMathAppPayloadTests
|
||||
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
|
||||
}
|
||||
|
||||
// ── PR0 (2026-07-26): skip/unmeasured classification ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Skips_LeaveTheFailureRate_AndAreReportedSeparately()
|
||||
{
|
||||
// 1 Failed + 1 real install + 3 skips ("Update for X" not applicable).
|
||||
// Rate must be 1/(1+1) = 50%, NOT 1/(1+4) = 20% — skips are not attempts.
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "App A", Status = "Failed", TerminalState = "Error" },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 30 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Skipped" },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Skipped" },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Postponed" },
|
||||
};
|
||||
|
||||
var root = Build(summaries);
|
||||
var app = root.GetProperty("topFailingApps")[0];
|
||||
|
||||
Assert.Equal(50d, app.GetProperty("failureRate").GetDouble());
|
||||
Assert.Equal(1, app.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(3, app.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(0, app.GetProperty("unmeasured").GetInt32());
|
||||
Assert.Equal(3, root.GetProperty("totalSkipped").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
|
||||
{
|
||||
// Two measured installs (100s, 200s) + one whose start was never observed (audit §0.5:
|
||||
// agent attach window — duration UNKNOWN). avg must be 150, not (100+200+0)/3 = 100.
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 0 },
|
||||
};
|
||||
|
||||
var root = Build(summaries);
|
||||
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
|
||||
|
||||
// Rebuild with a third measured row to clear the sample floor and read the average.
|
||||
summaries.Add(new AppInstallSummary { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 150 });
|
||||
var slowest = Build(summaries).GetProperty("slowestApps");
|
||||
Assert.Equal(1, slowest.GetArrayLength());
|
||||
Assert.Equal(150d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
|
||||
Assert.Equal(1, slowest[0].GetProperty("unmeasured").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyZeroDurationRows_StayInRate_ButNeverInDurations()
|
||||
{
|
||||
// Rows written before TerminalState existed: cannot be classified as skip vs unmeasured.
|
||||
// They keep the legacy rate behavior (counted as succeeded) and are excluded from
|
||||
// durations (counted as unmeasured — duration genuinely not measured).
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "App A", Status = "Failed" },
|
||||
new() { AppName = "App A", Status = "Succeeded", DurationSeconds = 0 }, // legacy, no TerminalState
|
||||
new() { AppName = "App A", Status = "Succeeded", DurationSeconds = 60, TerminalState = "Installed" },
|
||||
};
|
||||
|
||||
var app = Build(summaries).GetProperty("topFailingApps")[0];
|
||||
|
||||
Assert.Equal(2, app.GetProperty("succeeded").GetInt32()); // legacy row stays in the rate
|
||||
Assert.Equal(33.3d, app.GetProperty("failureRate").GetDouble());
|
||||
Assert.Equal(1, app.GetProperty("unmeasured").GetInt32());
|
||||
Assert.Equal(0, app.GetProperty("skipped").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplausibleDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
|
||||
{
|
||||
// A duration beyond the agent's max observation lifetime is a back-stamped emission
|
||||
// artifact (CompletedAt pinned at session end — production: 60 175 s app rows inside a
|
||||
// 2 697 s session), not a slow install. It must fall out of the averages exactly like
|
||||
// an unobserved start. Boundary: exactly 6 h is still plausible/measured.
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
|
||||
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds }, // boundary: measured
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
|
||||
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds + 1 }, // artifact: unmeasured
|
||||
};
|
||||
|
||||
var root = Build(summaries);
|
||||
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
|
||||
|
||||
var slowest = root.GetProperty("slowestApps");
|
||||
Assert.Equal(1, slowest.GetArrayLength());
|
||||
Assert.Equal(3, slowest[0].GetProperty("measuredInstalls").GetInt32());
|
||||
// avg over {100, 200, 21600} — the 21601 artifact contributes nothing.
|
||||
Assert.Equal(7300d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
|
||||
Assert.Equal(MetricsMath.MaxPlausibleInstallDurationSeconds, slowest[0].GetProperty("maxDurationSeconds").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowestApps_GateOnMeasuredInstalls_NotOnSucceededCount()
|
||||
{
|
||||
// 3 succeeded but only 2 measured → below the floor; an app must not rank "fast"
|
||||
// (or at all) on a handful of unobserved zeros.
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 5 },
|
||||
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 5 },
|
||||
new() { AppName = "Mostly Unmeasured", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 0 },
|
||||
};
|
||||
|
||||
var slowest = Build(summaries).GetProperty("slowestApps");
|
||||
Assert.Equal(0, slowest.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowestApps_DropsAppsBelowSampleFloor()
|
||||
{
|
||||
|
||||
@@ -125,5 +125,6 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<bool> IsSessionIndexEmptyAsync() => throw new NotImplementedException();
|
||||
public Task<List<OrphanedEventSession>> GetOrphanedEventSessionsAsync(TimeSpan gracePeriod) => throw new NotImplementedException();
|
||||
public Task DeleteEventSessionIndexEntryAsync(string tenantId, string sessionId) => throw new NotImplementedException();
|
||||
public Task<List<(string TenantId, string SessionId)>> GetAllSessionTombstoneKeysAsync() => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using AutopilotMonitor.Functions.Functions.Bootstrap;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the OOBE bootstrap script generator, ported from the portal's
|
||||
/// app/go/[code]/route.ts. The golden test asserts byte parity (modulo line
|
||||
/// endings) with the original TypeScript template output — the reference file
|
||||
/// TestData/oobe-bootstrap-golden.ps1 was captured by evaluating the TS template
|
||||
/// literal with the same fixed inputs. Ports the behavioral cases of the vitest
|
||||
/// suite app/go/[code]/__tests__/route.test.ts that concern script generation.
|
||||
/// </summary>
|
||||
public class OobeBootstrapScriptGeneratorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Token = "22222222-2222-2222-2222-222222222222";
|
||||
private const string DownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
private static BootstrapScriptValueValidator.ValidatedValues FixedValues() =>
|
||||
new(TenantId, Token, DownloadUrl,
|
||||
new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
private static string Generate() =>
|
||||
OobeBootstrapScriptGenerator.GenerateSuccessScript(
|
||||
FixedValues(),
|
||||
new DateTime(2026, 7, 28, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// ── Golden parity with the original Next.js template ──
|
||||
[Fact]
|
||||
public void SuccessScript_matches_the_typescript_reference_output()
|
||||
{
|
||||
var goldenPath = Path.Combine(AppContext.BaseDirectory, "TestData", "oobe-bootstrap-golden.ps1");
|
||||
var golden = File.ReadAllText(goldenPath);
|
||||
|
||||
var actual = Generate();
|
||||
|
||||
// Normalize line endings on both sides: git checkout / editors may flip
|
||||
// LF↔CRLF in either the .cs raw string or the golden asset; PowerShell
|
||||
// accepts both. Content parity is what the test pins.
|
||||
static string Normalize(string s) => s.Replace("\r\n", "\n");
|
||||
Assert.Equal(Normalize(golden), Normalize(actual));
|
||||
}
|
||||
|
||||
// ── Success-path markers (vitest parity) ──
|
||||
[Fact]
|
||||
public void SuccessScript_contains_the_expected_markers()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
Assert.StartsWith("#Requires -RunAsAdministrator", script);
|
||||
Assert.Contains($"--tenant-id '{TenantId}'", script);
|
||||
Assert.Contains($"--bootstrap-token '{Token}'", script);
|
||||
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
|
||||
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
|
||||
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
|
||||
Assert.Contains("https://portal.autopilotmonitor.com", script);
|
||||
}
|
||||
|
||||
// ── SECURITY INVARIANT: every substituted value sits inside a single-quoted
|
||||
// PS literal (single-quoted strings do not expand $(), $var, or backticks).
|
||||
// The expiry timestamp appears twice: once in the plain-text comment block
|
||||
// and once quoted in the Write-Log line. ──
|
||||
[Fact]
|
||||
public void SuccessScript_frames_all_interpolated_values_in_single_quotes()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
Assert.Contains($"'{DownloadUrl}'", script);
|
||||
Assert.Contains($"'{Token}'", script);
|
||||
Assert.Contains($"'{TenantId}'", script);
|
||||
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
|
||||
}
|
||||
|
||||
// ── Error script: message surfaces via Write-Host, always as a script ──
|
||||
[Fact]
|
||||
public void ErrorScript_contains_the_message()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("Invalid bootstrap code format.");
|
||||
|
||||
Assert.Contains("ERROR: Invalid bootstrap code format.", script);
|
||||
Assert.Contains("Write-Host", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorScript_doubles_single_quotes_for_powershell()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript("it's a 'bad' code");
|
||||
|
||||
Assert.Contains("it''s a ''bad'' code", script);
|
||||
}
|
||||
|
||||
// ── Cap is applied to the RAW message before quote-escaping, so a
|
||||
// quote-heavy message cannot double past the cap. ──
|
||||
[Fact]
|
||||
public void ErrorScript_caps_the_message_at_200_chars_before_escaping()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('X', 300));
|
||||
|
||||
Assert.Contains(new string('X', 200) + "...", script);
|
||||
Assert.DoesNotContain(new string('X', 201), script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorScript_cap_counts_raw_length_not_escaped_length()
|
||||
{
|
||||
var script = OobeBootstrapScriptGenerator.GenerateErrorScript(new string('\'', 300));
|
||||
|
||||
// 200 raw quotes escape to 400 — allowed, because the cap is pre-escape.
|
||||
Assert.Contains(new string('\'', 400) + "...", script);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F3 PR6 (insights spec §F3): the Wilson primitive (hand-computed vectors), the radar's
|
||||
/// detection gates and EVERY suppression branch (table-driven), the re-arm rule, the
|
||||
/// dimension-concentration gates, the tracker keyspace round-trip (tri-states included)
|
||||
/// and the alert wording contracts ("correlated, not necessarily causal").
|
||||
/// </summary>
|
||||
public class RuleRegressionRadarTests
|
||||
{
|
||||
private static readonly DateTime Target = new(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc);
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string RuleX = "ANALYZE-NET-001";
|
||||
|
||||
// ── Wilson interval (MetricsMath) — hand-computed 95 % vectors ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 20, 0.2993, 0.7007)] // symmetric mid-range
|
||||
[InlineData(0, 20, 0.0000, 0.1611)] // zero successes: lower pinned at 0
|
||||
[InlineData(5, 25, 0.0886, 0.3913)]
|
||||
public void WilsonInterval_MatchesHandComputedVectors(int successes, int trials, double lower, double upper)
|
||||
{
|
||||
var (l, u) = MetricsMath.WilsonInterval(successes, trials);
|
||||
Assert.Equal(lower, l, 3);
|
||||
Assert.Equal(upper, u, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WilsonInterval_ZeroTrials_IsUninformative_NeverClaimsSeparation()
|
||||
{
|
||||
Assert.Equal((0.0, 1.0), MetricsMath.WilsonInterval(0, 0));
|
||||
// A zero-denominator window can never be "separated" from anything.
|
||||
Assert.False(MetricsMath.RateIncreaseSeparated(0, 0, 10, 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateIncreaseSeparated_DisjointIntervals_True_OverlappingFalse()
|
||||
{
|
||||
// 15 % on n=100 (lower ≈ 0.093) vs 2 % baseline on n=500 (upper ≈ 0.036) → separated.
|
||||
Assert.True(MetricsMath.RateIncreaseSeparated(15, 100, 10, 500));
|
||||
// 5 % on n=100 vs 4 % baseline on n=500 → intervals overlap → noise, not a regression.
|
||||
Assert.False(MetricsMath.RateIncreaseSeparated(5, 100, 20, 500));
|
||||
}
|
||||
|
||||
// ── radar evaluation — gates + suppression branches ─────────────────────
|
||||
|
||||
private static RuleStatsEntry Entry(
|
||||
string date, int fire, int sessions, string ruleId = RuleX, string ruleType = "analyze")
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
Date = date,
|
||||
RuleId = ruleId,
|
||||
RuleType = ruleType,
|
||||
RuleTitle = "Network check",
|
||||
Category = "network",
|
||||
Severity = "warning",
|
||||
FireCount = fire,
|
||||
EvaluationCount = sessions,
|
||||
SessionsEvaluated = sessions,
|
||||
};
|
||||
|
||||
private static string Day(int daysBeforeTarget) => Target.AddDays(-daysBeforeTarget).ToString("yyyy-MM-dd");
|
||||
|
||||
/// <summary>Window fire=15/100 across two days; baseline fire=10/500 across three days — fires all gates.</summary>
|
||||
private static List<RuleStatsEntry> RegressedEntries() => new()
|
||||
{
|
||||
Entry(Day(0), 10, 60),
|
||||
Entry(Day(6), 5, 40), // windowStart boundary — still window
|
||||
Entry(Day(7), 4, 200), // first baseline day
|
||||
Entry(Day(20), 4, 200),
|
||||
Entry(Day(34), 2, 100), // baselineStart boundary — still baseline
|
||||
};
|
||||
|
||||
private static Dictionary<string, RuleRegressionRadar.RuleTimestamps> OldRule(
|
||||
DateTime? createdAt = null, DateTime? updatedAt = null, string ruleId = RuleX)
|
||||
=> new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[ruleId] = new RuleRegressionRadar.RuleTimestamps(
|
||||
createdAt ?? Target.AddDays(-100), updatedAt ?? Target.AddDays(-50)),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_AllGatesPass_FiresWithFullNumbers()
|
||||
{
|
||||
var findings = RuleRegressionRadar.Evaluate(RegressedEntries(), Target, OldRule());
|
||||
|
||||
var finding = Assert.Single(findings);
|
||||
Assert.Equal(RuleX, finding.RuleId);
|
||||
Assert.Equal(15, finding.WindowFireCount);
|
||||
Assert.Equal(100, finding.WindowSessionCount);
|
||||
Assert.Equal(10, finding.BaselineFireCount);
|
||||
Assert.Equal(500, finding.BaselineSessionCount);
|
||||
Assert.Equal(15.0, finding.WindowRatePct);
|
||||
Assert.Equal(2.0, finding.BaselineRatePct);
|
||||
Assert.Equal(7.5, finding.Lift);
|
||||
Assert.Equal(Day(6), finding.WindowStartDate);
|
||||
Assert.Equal(Day(0), finding.WindowEndDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ZeroBaselineFires_FiresAsNewSignal_WithNullLift()
|
||||
{
|
||||
var entries = new List<RuleStatsEntry>
|
||||
{
|
||||
Entry(Day(0), 15, 100),
|
||||
Entry(Day(10), 0, 500),
|
||||
};
|
||||
|
||||
var finding = Assert.Single(RuleRegressionRadar.Evaluate(entries, Target, OldRule()));
|
||||
Assert.Null(finding.Lift); // no finite lift is ever invented (truthfulness rule 1)
|
||||
Assert.Equal(0.0, finding.BaselineRatePct);
|
||||
}
|
||||
|
||||
public static TheoryData<string, List<RuleStatsEntry>> SuppressedByCounts() => new()
|
||||
{
|
||||
// Below the ≥5 hit-session gate.
|
||||
{ "below hit gate", new List<RuleStatsEntry> { Entry(Day(0), 4, 100), Entry(Day(10), 1, 500) } },
|
||||
// Below the ≥20 evaluated-session gate.
|
||||
{ "below session gate", new List<RuleStatsEntry> { Entry(Day(0), 5, 19), Entry(Day(10), 1, 500) } },
|
||||
// No baseline denominator at all (rule disabled through the baseline, re-enabled recently).
|
||||
{ "no baseline sessions", new List<RuleStatsEntry> { Entry(Day(0), 15, 100) } },
|
||||
// Lift below 2× (10 % vs 6 %).
|
||||
{ "lift below 2x", new List<RuleStatsEntry> { Entry(Day(0), 10, 100), Entry(Day(10), 30, 500) } },
|
||||
// Lift 2.1× but Wilson intervals overlap (5/20 vs 60/500) — small-n noise never alerts.
|
||||
{ "not separated", new List<RuleStatsEntry> { Entry(Day(0), 5, 20), Entry(Day(10), 60, 500) } },
|
||||
// Gather rows are ignored entirely (audit Q6: per-batch dedup — bad math until fixed).
|
||||
{ "gather ignored", new List<RuleStatsEntry>
|
||||
{ Entry(Day(0), 50, 60, ruleType: "gather"), Entry(Day(10), 1, 500, ruleType: "gather") } },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SuppressedByCounts))]
|
||||
public void Evaluate_SuppressionBranches_ProduceNoFinding(string branch, List<RuleStatsEntry> entries)
|
||||
{
|
||||
Assert.True(RuleRegressionRadar.Evaluate(entries, Target, OldRule()).Count == 0,
|
||||
$"branch '{branch}' must not fire");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RuleEntityGates_Suppress()
|
||||
{
|
||||
var entries = RegressedEntries();
|
||||
|
||||
// Deleted rule (no entity) never alerts.
|
||||
Assert.Empty(RuleRegressionRadar.Evaluate(
|
||||
entries, Target, new Dictionary<string, RuleRegressionRadar.RuleTimestamps>()));
|
||||
|
||||
// Grace period: created inside baseline+window (< 34 days before target).
|
||||
Assert.Empty(RuleRegressionRadar.Evaluate(
|
||||
entries, Target, OldRule(createdAt: Target.AddDays(-20))));
|
||||
|
||||
// Edited inside the 7-day window — edits legitimately change hit rates.
|
||||
Assert.Empty(RuleRegressionRadar.Evaluate(
|
||||
entries, Target, OldRule(updatedAt: Target.AddDays(-2))));
|
||||
|
||||
// Edited just BEFORE the window → not suppressed.
|
||||
Assert.Single(RuleRegressionRadar.Evaluate(
|
||||
entries, Target, OldRule(updatedAt: Target.AddDays(-8))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SumWindows_BoundaryDates_LandInTheRightBucket()
|
||||
{
|
||||
var entries = new List<RuleStatsEntry>
|
||||
{
|
||||
Entry(Target.AddDays(1).ToString("yyyy-MM-dd"), 100, 100), // future — ignored
|
||||
Entry(Day(0), 1, 10),
|
||||
Entry(Day(6), 2, 20), // last window day
|
||||
Entry(Day(7), 4, 40), // first baseline day
|
||||
Entry(Day(34), 8, 80), // last baseline day
|
||||
Entry(Day(35), 100, 100), // beyond horizon — ignored
|
||||
};
|
||||
|
||||
var sums = RuleRegressionRadar.SumWindows(entries, Target);
|
||||
Assert.Equal(3, sums.WindowFire);
|
||||
Assert.Equal(30, sums.WindowSessions);
|
||||
Assert.Equal(12, sums.BaselineFire);
|
||||
Assert.Equal(120, sums.BaselineSessions);
|
||||
}
|
||||
|
||||
// ── re-arm rule ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_FiresStopped_OrRateBackUnderOnePointFive()
|
||||
{
|
||||
// Nothing fired in the window anymore → re-arm (also covers "no stats rows at all").
|
||||
Assert.True(RuleRegressionRadar.ShouldReArm(new List<RuleStatsEntry>(), Target));
|
||||
Assert.True(RuleRegressionRadar.ShouldReArm(
|
||||
new List<RuleStatsEntry> { Entry(Day(0), 0, 100), Entry(Day(10), 10, 500) }, Target));
|
||||
|
||||
// 2 % window vs 2 % baseline → 1.0× < 1.5 → re-arm.
|
||||
Assert.True(RuleRegressionRadar.ShouldReArm(
|
||||
new List<RuleStatsEntry> { Entry(Day(0), 2, 100), Entry(Day(10), 10, 500) }, Target));
|
||||
|
||||
// 4 % window vs 2 % baseline → 2.0× ≥ 1.5 → still elevated, keep the episode.
|
||||
Assert.False(RuleRegressionRadar.ShouldReArm(
|
||||
new List<RuleStatsEntry> { Entry(Day(0), 4, 100), Entry(Day(10), 10, 500) }, Target));
|
||||
|
||||
// Zero-baseline episode ("new signal"): no rate threshold to fall under — only the
|
||||
// fires-stopped branch re-arms it.
|
||||
Assert.False(RuleRegressionRadar.ShouldReArm(
|
||||
new List<RuleStatsEntry> { Entry(Day(0), 3, 100), Entry(Day(10), 0, 500) }, Target));
|
||||
}
|
||||
|
||||
// ── dimension concentration ─────────────────────────────────────────────
|
||||
|
||||
private static SessionSummary DimSession(string osBuild = "", string model = "", string agentVersion = "")
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
SerialNumber = "PF4X1ABC",
|
||||
OsBuild = osBuild,
|
||||
Manufacturer = "Contoso",
|
||||
Model = model,
|
||||
AgentVersion = agentVersion,
|
||||
ImeAgentVersion = "",
|
||||
StartedAt = Target,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void DimensionConcentration_DominantOsBuild_ReportsBothShares()
|
||||
{
|
||||
var all = new List<SessionSummary>();
|
||||
for (var i = 0; i < 20; i++) all.Add(DimSession(osBuild: "26100.4652"));
|
||||
for (var i = 0; i < 80; i++) all.Add(DimSession(osBuild: "22631.3880"));
|
||||
var hits = all.Take(16).Concat(all.Skip(20).Take(4)).ToList(); // 16 on the new build, 4 on the old
|
||||
|
||||
var result = RuleRegressionRadar.ComputeDimensionConcentration(hits, all)!;
|
||||
|
||||
Assert.Equal("osBuild", result.Dimension);
|
||||
Assert.Equal("26100.4652", result.Value);
|
||||
Assert.Equal(16, result.HitCount);
|
||||
Assert.Equal(80.0, result.HitSharePct);
|
||||
Assert.Equal(20.0, result.AllSharePct);
|
||||
Assert.Equal(4.0, result.Lift);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DimensionConcentration_BelowGates_YieldsNull_NeverStretched()
|
||||
{
|
||||
var all = Enumerable.Range(0, 100).Select(_ => DimSession(osBuild: "26100.1")).ToList();
|
||||
|
||||
// Every session shares the build → lift 1.0 < 2 → no claim.
|
||||
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(all.Take(10).ToList(), all));
|
||||
|
||||
// Concentrated but only 4 hit sessions (< 5) → no claim.
|
||||
var mixed = Enumerable.Range(0, 96).Select(_ => DimSession(osBuild: "22631.1"))
|
||||
.Concat(Enumerable.Range(0, 4).Select(_ => DimSession(osBuild: "26100.9")))
|
||||
.ToList();
|
||||
var hits4 = mixed.Skip(96).ToList();
|
||||
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(hits4, mixed));
|
||||
|
||||
// No hit sessions at all → no claim.
|
||||
Assert.Null(RuleRegressionRadar.ComputeDimensionConcentration(new List<SessionSummary>(), all));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DimensionConcentration_PicksTheHighestLiftAcrossDimensions()
|
||||
{
|
||||
var all = new List<SessionSummary>();
|
||||
for (var i = 0; i < 25; i++) all.Add(DimSession(osBuild: "26100.1", model: "Laptop 5"));
|
||||
for (var i = 0; i < 75; i++) all.Add(DimSession(osBuild: "22631.1", model: i < 25 ? "Laptop 5" : "Laptop 4"));
|
||||
// Hits: 8 of 10 on the new build (lift 80/25 = 3.2); model "Contoso Laptop 5" is 50 % of
|
||||
// fleet and 80 % of hits (lift 1.6 — below gate).
|
||||
var hits = all.Take(8).Concat(all.Skip(25).Take(2)).ToList();
|
||||
|
||||
var result = RuleRegressionRadar.ComputeDimensionConcentration(hits, all)!;
|
||||
Assert.Equal("osBuild", result.Dimension);
|
||||
Assert.Equal(3.2, result.Lift);
|
||||
}
|
||||
|
||||
// ── tracker keyspace (round-trip + key shape) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RuleRegressionRowKey_PrefixedAndCaseFolded()
|
||||
{
|
||||
Assert.Equal("ruleregression|analyze-net-001",
|
||||
TableHardwareRejectionNotificationTracker.BuildRuleRegressionRowKey(" ANALYZE-NET-001 "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleRegressionEntity_RoundTripsAllFields_IncludingDimension()
|
||||
{
|
||||
var alert = new RuleRegressionAlert
|
||||
{
|
||||
TenantId = TenantA,
|
||||
RuleId = RuleX,
|
||||
RuleTitle = "Network check",
|
||||
WindowFireCount = 15,
|
||||
WindowSessionCount = 100,
|
||||
BaselineFireCount = 10,
|
||||
BaselineSessionCount = 500,
|
||||
WindowRatePct = 15.0,
|
||||
BaselineRatePct = 2.0,
|
||||
Lift = 7.5,
|
||||
WindowStartDate = "2026-07-20",
|
||||
WindowEndDate = "2026-07-26",
|
||||
Dimension = new RuleRegressionDimension
|
||||
{
|
||||
Dimension = "osBuild", Value = "26100.4652",
|
||||
HitCount = 12, HitSharePct = 80.0, AllSharePct = 20.0, Lift = 4.0,
|
||||
},
|
||||
FirstNotifiedAt = Target,
|
||||
LastEvaluatedAt = Target.AddHours(2),
|
||||
};
|
||||
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildRuleRegressionEntity(TenantA, alert);
|
||||
Assert.Equal(TenantA.ToLowerInvariant(), entity.PartitionKey);
|
||||
Assert.Equal("ruleregression|analyze-net-001", entity.RowKey);
|
||||
|
||||
var mapped = TableHardwareRejectionNotificationTracker.MapToRuleRegressionAlert(entity);
|
||||
Assert.Equal(RuleX, mapped.RuleId);
|
||||
Assert.Equal("Network check", mapped.RuleTitle);
|
||||
Assert.Equal(15, mapped.WindowFireCount);
|
||||
Assert.Equal(100, mapped.WindowSessionCount);
|
||||
Assert.Equal(10, mapped.BaselineFireCount);
|
||||
Assert.Equal(500, mapped.BaselineSessionCount);
|
||||
Assert.Equal(15.0, mapped.WindowRatePct);
|
||||
Assert.Equal(2.0, mapped.BaselineRatePct);
|
||||
Assert.Equal(7.5, mapped.Lift);
|
||||
Assert.Equal("2026-07-20", mapped.WindowStartDate);
|
||||
Assert.Equal("2026-07-26", mapped.WindowEndDate);
|
||||
Assert.Equal(Target, mapped.FirstNotifiedAt);
|
||||
Assert.Equal(Target.AddHours(2), mapped.LastEvaluatedAt);
|
||||
Assert.NotNull(mapped.Dimension);
|
||||
Assert.Equal("osBuild", mapped.Dimension!.Dimension);
|
||||
Assert.Equal(4.0, mapped.Dimension.Lift);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleRegressionEntity_TriStates_AbsentColumnsMapToNull_NeverInvented()
|
||||
{
|
||||
var alert = new RuleRegressionAlert
|
||||
{
|
||||
TenantId = TenantA, RuleId = RuleX,
|
||||
Lift = null, // new signal — no finite lift
|
||||
Dimension = null, // no clear concentration
|
||||
FirstNotifiedAt = Target, LastEvaluatedAt = Target,
|
||||
};
|
||||
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildRuleRegressionEntity(TenantA, alert);
|
||||
Assert.False(entity.ContainsKey("Lift"));
|
||||
Assert.False(entity.ContainsKey("DimensionJson"));
|
||||
|
||||
var mapped = TableHardwareRejectionNotificationTracker.MapToRuleRegressionAlert(entity);
|
||||
Assert.Null(mapped.Lift);
|
||||
Assert.Null(mapped.Dimension);
|
||||
}
|
||||
|
||||
// ── wording contracts ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DescribeDimension_UsesCorrelationWording_NeverCausal()
|
||||
{
|
||||
var text = MaintenanceService.DescribeDimension(new RuleRegressionDimension
|
||||
{
|
||||
Dimension = "osBuild", Value = "26100.4652",
|
||||
HitCount = 12, HitSharePct = 87.0, AllSharePct = 22.0, Lift = 4.0,
|
||||
})!;
|
||||
|
||||
Assert.Contains("87% of affected sessions are on osBuild 26100.4652", text);
|
||||
Assert.Contains("vs 22% of all sessions (lift 4x)", text);
|
||||
Assert.Contains("correlated, not necessarily causal", text);
|
||||
Assert.Null(MaintenanceService.DescribeDimension(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRegressionMessage_CarriesFullNumbers_AndHonestNoDimensionFallback()
|
||||
{
|
||||
var finding = new RuleRegressionFinding
|
||||
{
|
||||
TenantId = TenantA, RuleId = RuleX, RuleTitle = "Network check",
|
||||
WindowFireCount = 15, WindowSessionCount = 100, WindowRatePct = 15.0,
|
||||
BaselineFireCount = 10, BaselineSessionCount = 500, BaselineRatePct = 2.0,
|
||||
Lift = 7.5, WindowStartDate = "2026-07-20", WindowEndDate = "2026-07-26",
|
||||
};
|
||||
|
||||
var message = MaintenanceService.BuildRegressionMessage(finding, dimensionSummary: null);
|
||||
Assert.Contains("15 of 100 evaluated sessions", message);
|
||||
Assert.Contains("(15%)", message);
|
||||
Assert.Contains("baseline 2% (10/500 over the prior 28 days)", message);
|
||||
Assert.Contains("lift 7.5x", message);
|
||||
Assert.Contains("No clear dimension concentration.", message);
|
||||
|
||||
var newSignal = MaintenanceService.BuildRegressionMessage(
|
||||
new RuleRegressionFinding
|
||||
{
|
||||
RuleId = RuleX, RuleTitle = "Network check",
|
||||
WindowFireCount = 15, WindowSessionCount = 100, WindowRatePct = 15.0,
|
||||
BaselineFireCount = 0, BaselineSessionCount = 500, BaselineRatePct = 0.0,
|
||||
Lift = null, WindowStartDate = "2026-07-20", WindowEndDate = "2026-07-26",
|
||||
},
|
||||
dimensionSummary: "dim summary");
|
||||
Assert.Contains("new signal", newSignal);
|
||||
Assert.Contains("dim summary.", newSignal);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ public class SessionIndexProjectionTests
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
// Device-validation path recorded at registration — shown on the session detail page.
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
|
||||
["AvgApiLatencyMs"] = 513.5,
|
||||
["ApiRequestCount"] = 100,
|
||||
// Connection-type projection (UpdateSessionConnectionTypeAsync merges this) —
|
||||
// search-filterable (connectionType= pushes an OData eq on the index).
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt);
|
||||
@@ -67,6 +73,9 @@ public class SessionIndexProjectionTests
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
|
||||
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
|
||||
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
|
||||
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
|
||||
Assert.Equal("Ethernet", idx.GetString("ConnectionType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,6 +95,8 @@ public class SessionIndexProjectionTests
|
||||
Assert.False(idx.ContainsKey("FailureSource"));
|
||||
Assert.False(idx.ContainsKey("StalledAt"));
|
||||
Assert.False(idx.ContainsKey("ValidatedBy"));
|
||||
Assert.False(idx.ContainsKey("AvgApiLatencyMs"));
|
||||
Assert.False(idx.ContainsKey("ConnectionType"));
|
||||
// Always-present counts/flags mirror the Sessions defaults.
|
||||
Assert.Equal(0, idx.GetInt32("PlatformScriptCount"));
|
||||
Assert.Equal(0, idx.GetInt32("RemediationScriptCount"));
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The diagnostics-archive copier preserves a session's diag ZIP with a session report.
|
||||
/// Its contract: NEVER throw (a failed copy must not block the report), re-verify the
|
||||
/// hosted tenant prefix (a tampered Sessions row must not reach foreign blobs), and honour
|
||||
/// the same size cap as the download proxy. Tests pin every status branch via a recording
|
||||
/// subclass — no Azurite, no HTTP.
|
||||
/// </summary>
|
||||
public class SessionReportDiagnosticsArchiveCopierTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
private const string HostedBlob = $"{TenantA}/AgentDiagnostics-{SessionId}-20260729T120000.zip";
|
||||
private const string CustomerBlob = $"AgentDiagnostics-{SessionId}-20260729T120000.zip";
|
||||
private const string DestBlob = $"{TenantA}_{SessionId}_diag_archive_20260729_120000.zip";
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task CopyAsync_NoSource_FailsSoft_NoStorageCalls(string? source)
|
||||
{
|
||||
var copier = new RecordingCopier();
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, source, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedNoDiagnostics, result.Status);
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_HostedBlobOfForeignTenant_Rejected_WithoutRead()
|
||||
{
|
||||
// Spoof guard: even if the Sessions row was tampered to point at another tenant's
|
||||
// hosted blob, the classifier re-checks the prefix against the validated tenant.
|
||||
var copier = new RecordingCopier();
|
||||
var foreignBlob = $"{TenantB}/AgentDiagnostics-{SessionId}-x.zip";
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, foreignBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
|
||||
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
|
||||
{
|
||||
var copier = new RecordingCopier { SourceSizeBytes = 1234 };
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.Copied, result.Status);
|
||||
Assert.Equal(1234, result.SizeBytes);
|
||||
Assert.Equal(1, copier.HostedOpenCalls);
|
||||
Assert.Equal(HostedBlob, copier.HostedOpenLastPath);
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
Assert.Equal(DestBlob, copier.UploadLastName);
|
||||
Assert.DoesNotContain("/", copier.UploadLastName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_CustomerSas_HappyPath_BuildsSasBlobUrl()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
SourceSizeBytes = 42,
|
||||
CustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=r&sig=x",
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, copier.CustomerOpenCalls);
|
||||
Assert.Equal(
|
||||
$"https://customer.blob/diag/{CustomerBlob}?sv=2024&sp=r&sig=x",
|
||||
copier.CustomerOpenLastUri!.ToString());
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_CustomerSas_NoSasConfigured_FailsSoft()
|
||||
{
|
||||
var copier = new RecordingCopier { CustomerSasUrl = null };
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, CustomerBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasNotConfigured, result.Status);
|
||||
Assert.Equal(0, copier.CustomerOpenCalls + copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_SourceAboveSizeCap_Rejected_NoUpload()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
MaxDownloadSizeMB = 1,
|
||||
SourceSizeBytes = 2 * 1024 * 1024,
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTooLarge, result.Status);
|
||||
Assert.Equal(2 * 1024 * 1024, result.SizeBytes);
|
||||
Assert.Equal(0, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_SizeCapZero_MeansUnlimited()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
MaxDownloadSizeMB = 0,
|
||||
SourceSizeBytes = 5L * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(404, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSourceNotFound)]
|
||||
[InlineData(403, SessionReportDiagnosticsArchiveCopier.Statuses.FailedSasReadDenied)]
|
||||
[InlineData(500, SessionReportDiagnosticsArchiveCopier.Statuses.FailedError)]
|
||||
public async Task CopyAsync_RequestFailed_MapsStatus_WithoutThrowing(int httpStatus, string expected)
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
HostedOpenException = new RequestFailedException(httpStatus, $"storage error {httpStatus}"),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(expected, result.Status);
|
||||
Assert.Equal(0, copier.UploadCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_Timeout_MapsToTimeoutStatus()
|
||||
{
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
HostedOpenException = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedTimeout, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsync_UploadFailure_FailsSoft_AsError()
|
||||
{
|
||||
// Read succeeded but the write into session-reports blew up — still fail-soft.
|
||||
var copier = new RecordingCopier
|
||||
{
|
||||
UploadException = new InvalidOperationException("container gone"),
|
||||
};
|
||||
|
||||
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, DestBlob);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedError, result.Status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides every storage/config seam with an in-memory recording version, so the
|
||||
/// tests exercise the REAL CopyAsync routing (classify → config → open → cap → upload)
|
||||
/// without touching Azure.
|
||||
/// </summary>
|
||||
private sealed class RecordingCopier : SessionReportDiagnosticsArchiveCopier
|
||||
{
|
||||
public int MaxDownloadSizeMB { get; set; } = 500;
|
||||
public long SourceSizeBytes { get; set; } = 100;
|
||||
public string? CustomerSasUrl { get; set; }
|
||||
public Exception? HostedOpenException { get; set; }
|
||||
public Exception? UploadException { get; set; }
|
||||
|
||||
public int HostedOpenCalls { get; private set; }
|
||||
public string? HostedOpenLastPath { get; private set; }
|
||||
public int CustomerOpenCalls { get; private set; }
|
||||
public Uri? CustomerOpenLastUri { get; private set; }
|
||||
public int UploadCalls { get; private set; }
|
||||
public string? UploadLastName { get; private set; }
|
||||
|
||||
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
|
||||
=> Task.FromResult(new AdminConfiguration
|
||||
{
|
||||
MaxDiagnosticsDownloadSizeMB = MaxDownloadSizeMB,
|
||||
DiagnosticsDownloadTimeoutSeconds = 120,
|
||||
});
|
||||
|
||||
protected override Task<TenantConfiguration> GetTenantConfigurationAsync(string tenantId)
|
||||
=> Task.FromResult(new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = CustomerSasUrl!,
|
||||
});
|
||||
|
||||
protected override Task<(Stream Content, long ContentLength)> OpenHostedSourceAsync(
|
||||
string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
HostedOpenCalls++;
|
||||
HostedOpenLastPath = blobPath;
|
||||
if (HostedOpenException != null) throw HostedOpenException;
|
||||
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
|
||||
}
|
||||
|
||||
protected override Task<(Stream Content, long ContentLength)> OpenCustomerSourceAsync(
|
||||
Uri blobUri, CancellationToken cancellationToken)
|
||||
{
|
||||
CustomerOpenCalls++;
|
||||
CustomerOpenLastUri = blobUri;
|
||||
return Task.FromResult<(Stream, long)>((new MemoryStream(new byte[8]), SourceSizeBytes));
|
||||
}
|
||||
|
||||
protected override Task UploadToReportsContainerAsync(
|
||||
string destinationBlobName, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
UploadCalls++;
|
||||
UploadLastName = destinationBlobName;
|
||||
if (UploadException != null) throw UploadException;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-supplied attachment file names go verbatim into ZIP entry names of the
|
||||
/// session-report archive. The sanitizer must strip path components so a crafted name
|
||||
/// cannot produce traversal-shaped entries that an extraction tool would write outside
|
||||
/// the target directory.
|
||||
/// </summary>
|
||||
public class SessionReportZipEntryNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("agent.log", "agent.log")]
|
||||
[InlineData("agent_20260729.log", "agent_20260729.log")]
|
||||
[InlineData("logs/agent.log", "agent.log")]
|
||||
[InlineData("..\\..\\evil.exe", "evil.exe")]
|
||||
[InlineData("../../../etc/passwd", "passwd")]
|
||||
[InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")]
|
||||
[InlineData(" spaced.log ", "spaced.log")]
|
||||
public void SanitizeZipEntryName_StripsPathComponents(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("logs/")]
|
||||
[InlineData("a/../")]
|
||||
public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input)
|
||||
{
|
||||
Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log"));
|
||||
}
|
||||
}
|
||||
+4
@@ -40,6 +40,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
["PlatformScriptCount"] = 3,
|
||||
["RemediationScriptCount"] = 5,
|
||||
["RebootCount"] = 7,
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
|
||||
@@ -71,6 +72,9 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.Equal(7, harness.Written.GetInt32("RebootCount"));
|
||||
// EventCount preservation is the established sibling behaviour — assert it as a sanity anchor.
|
||||
Assert.Equal(42, harness.Written.GetInt32("EventCount"));
|
||||
// Merge-mode field (UpdateSessionConnectionTypeAsync) — must survive the Replace so the
|
||||
// value isn't lost in the window until the restarted agent re-emits network_interface_info.
|
||||
Assert.Equal("Ethernet", harness.Written.GetString("ConnectionType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Autopilot Monitor OOBE Bootstrap
|
||||
Generated: 2026-07-28T12:00:00.000Z
|
||||
Expires: 2026-08-01T12:00:00.000Z
|
||||
|
||||
This script was generated by the Autopilot Monitor platform.
|
||||
It installs the monitoring agent before Intune enrollment begins,
|
||||
enabling full session capture from the start of OOBE.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$AgentDownloadUrl = 'https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
|
||||
# Create directories
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Extract
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
}
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
Write-Log "===== OOBE Bootstrap Complete ====="
|
||||
Write-Log "Agent is now monitoring. View progress at https://portal.autopilotmonitor.com"
|
||||
Write-Log ""
|
||||
Write-Log "The agent will:"
|
||||
Write-Log " - Monitor enrollment from the very start"
|
||||
Write-Log " - Switch to certificate auth after Intune enrollment"
|
||||
Write-Log " - Self-destruct when enrollment completes"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Autopilot Monitor agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host "View enrollment progress at https://portal.autopilotmonitor.com" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== OOBE Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Check log file: $LogFile" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden fixtures for <see cref="TimeAttributionCalculator"/> (F1 PR1, insights spec §F1) —
|
||||
/// synthetic sessions only (no customer data), each pinning one semantic decision:
|
||||
/// linear phase partition, clock skew, late start, WhiteGlove two-window split, missing /
|
||||
/// truncated blocking lists, IME retry occupancy, and reboot gaps. EVERY fixture asserts the
|
||||
/// exact-partition invariant: sum(span seconds) + unattributed == wall clock ==
|
||||
/// the session's DurationSeconds (truthfulness rule 2 — never normalized, never interpolated).
|
||||
/// </summary>
|
||||
public class TimeAttributionCalculatorTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string AppA = "aaaaaaaa-1111-2222-3333-444444444444"; // in blocking list
|
||||
private const string AppB = "bbbbbbbb-1111-2222-3333-444444444444"; // in blocking list, never observed
|
||||
private const string AppC = "cccccccc-1111-2222-3333-444444444444"; // observed, NOT listed
|
||||
|
||||
private static long _seq;
|
||||
|
||||
private static EnrollmentEvent Evt(
|
||||
DateTime ts, string eventType,
|
||||
EnrollmentPhase phase = EnrollmentPhase.Unknown,
|
||||
Dictionary<string, object>? data = null)
|
||||
=> new()
|
||||
{
|
||||
EventType = eventType,
|
||||
Timestamp = ts,
|
||||
Sequence = ++_seq,
|
||||
Phase = phase,
|
||||
Data = data ?? new Dictionary<string, object>(),
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> AppData(string appId, string appName, string? state = null)
|
||||
{
|
||||
var d = new Dictionary<string, object> { ["appId"] = appId, ["appName"] = appName };
|
||||
if (state != null) d["state"] = state;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>Storage-shape blocking lists (EventDataNormalizer yields List<object>).</summary>
|
||||
private static Dictionary<string, object> EspLists(long? win32Count = null, params string[] win32Ids)
|
||||
=> new()
|
||||
{
|
||||
["espTrackedWin32AppIds"] = new List<object>(win32Ids),
|
||||
["espTrackedUserWin32AppIds"] = new List<object>(),
|
||||
["espTrackedMsiProductCodes"] = new List<object>(),
|
||||
["espTrackedModernAppPfns"] = new List<object>(),
|
||||
["espTrackedWin32Count"] = win32Count ?? win32Ids.Length,
|
||||
["espTrackedMsiCount"] = 0L,
|
||||
["espTrackedModernCount"] = 0L,
|
||||
};
|
||||
|
||||
private static TimeAttributionInput Input(
|
||||
List<EnrollmentEvent> events, DateTime completedAt, int durationSeconds,
|
||||
string status = "Succeeded", bool wg = false, DateTime? resumedAt = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-0000000000t1",
|
||||
SessionId = "00000000-0000-0000-0000-0000000000s1",
|
||||
Status = status,
|
||||
StartedAt = T0,
|
||||
CompletedAt = completedAt,
|
||||
DurationSeconds = durationSeconds,
|
||||
IsPreProvisioned = wg,
|
||||
ResumedAt = resumedAt,
|
||||
Events = events,
|
||||
};
|
||||
|
||||
/// <summary>The invariant every fixture must satisfy exactly (spec §0 rule 2).</summary>
|
||||
private static void AssertExactPartition(SessionTimeBreakdown b)
|
||||
{
|
||||
Assert.Equal(b.WallClockSeconds, b.Segments.Sum(s => s.Seconds) + b.UnattributedSeconds);
|
||||
Assert.True(b.UnattributedSeconds >= 0, "unattributed must never be negative");
|
||||
}
|
||||
|
||||
// ── golden: linear UDE session ──────────────────────────────────────────
|
||||
|
||||
private static List<EnrollmentEvent> LinearSession()
|
||||
=> new()
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(2), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA, AppB })),
|
||||
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(T0.AddMinutes(10), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
Evt(T0.AddMinutes(11), "app_install_started", data: AppData(AppC, "Untracked App")),
|
||||
Evt(T0.AddMinutes(13), "app_install_completed", data: AppData(AppC, "Untracked App", "Installed")),
|
||||
Evt(T0.AddMinutes(15), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Evt(T0.AddMinutes(20), "esp_phase_changed", EnrollmentPhase.AppsUser),
|
||||
Evt(T0.AddMinutes(25), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
|
||||
Evt(T0.AddMinutes(28), "desktop_arrived"),
|
||||
Evt(T0.AddMinutes(30), "enrollment_complete"),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Linear_Session_PartitionsIntoAllFiveSegments_Exactly()
|
||||
{
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(1800, b.WallClockSeconds);
|
||||
Assert.Equal(0, b.UnattributedSeconds);
|
||||
Assert.Equal(TimeAttributionFlags.None, b.QualityFlags);
|
||||
Assert.Equal(TimeAttributionCalculator.CurrentVersion, b.AttributionVersion);
|
||||
|
||||
var totals = b.GetSegmentTotals();
|
||||
Assert.Equal(300, totals[TimeAttributionSegments.DevicePrep]); // T0 → apps entry (+5m)
|
||||
Assert.Equal(600, totals[TimeAttributionSegments.EspApps]); // +5m → +15m
|
||||
Assert.Equal(480, totals[TimeAttributionSegments.IdentityHello]); // AccountSetup 300 + Finalizing 180
|
||||
Assert.Equal(300, totals[TimeAttributionSegments.UserEsp]); // +20m → +25m
|
||||
Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // desktop_arrived → terminal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Linear_Session_BlockingJoin_IsPositiveEvidenceOnly()
|
||||
{
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
|
||||
|
||||
// App A is listed → blocking, interval from EVENT timestamps (started → completed).
|
||||
var interval = Assert.Single(b.BlockingApps);
|
||||
Assert.Equal(AppA, interval.AppId);
|
||||
Assert.Equal(240, interval.Seconds); // +6m → +10m
|
||||
// App C installed fine but is NOT in the lists → unknown, NEVER "not blocking":
|
||||
// it appears nowhere (no negative claim), and only App A counts as matched.
|
||||
Assert.Equal(1, b.BlockingAppCount);
|
||||
// Occupancy = App A's interval clipped to the esp_apps span.
|
||||
Assert.Equal(240, b.EspAppsOccupancySeconds);
|
||||
}
|
||||
|
||||
// ── skew: backward anchor is dropped, dust stays honest ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void BackwardPhaseAnchor_IsDropped_WithClockSkewFlag()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(10), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
// Replayed declaration whose timestamp runs BACKWARD in canonical order.
|
||||
Evt(T0.AddMinutes(3), "esp_phase_changed", EnrollmentPhase.AccountSetup),
|
||||
Evt(T0.AddMinutes(30), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(30), durationSeconds: 1800))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.ClockSkewDropped));
|
||||
var totals = b.GetSegmentTotals();
|
||||
Assert.Equal(600, totals[TimeAttributionSegments.DevicePrep]);
|
||||
Assert.Equal(1200, totals[TimeAttributionSegments.EspApps]); // runs to window end — no phantom AccountSetup
|
||||
Assert.False(totals.ContainsKey(TimeAttributionSegments.IdentityHello));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubSecondBoundaries_FloorIntoUnattributedDust_NeverInvented()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(90.5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddSeconds(300), durationSeconds: 300))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var totals = b.GetSegmentTotals();
|
||||
Assert.Equal(90, totals[TimeAttributionSegments.DevicePrep]); // floor(90.5)
|
||||
Assert.Equal(209, totals[TimeAttributionSegments.EspApps]); // floor(209.5)
|
||||
Assert.Equal(1, b.UnattributedSeconds); // the dust, disclosed
|
||||
}
|
||||
|
||||
// ── late start ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AgentLateStart_SetsPartialObservation()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(1), "agent_late_start"),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PartialObservation));
|
||||
}
|
||||
|
||||
// ── WhiteGlove: two windows in ONE session, pause never attributed ──────
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_SplitsIntoTwoWindows_PauseExcluded_WallClockIsDurationSeconds()
|
||||
{
|
||||
var part1End = T0.AddMinutes(20);
|
||||
var resumedAt = T0.AddDays(2); // 2-day pause
|
||||
var completedAt = resumedAt.AddMinutes(10); // part 2 = 600 s
|
||||
// Stored combined duration: part1 (1200 s) + part2 (600 s) — pause excluded by design.
|
||||
const int duration = 1800;
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(part1End, "whiteglove_part1_complete"),
|
||||
// — pause —
|
||||
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
Evt(resumedAt.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.FinalizingSetup),
|
||||
Evt(resumedAt.AddMinutes(8), "desktop_arrived"),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, duration, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
// Wall clock is the stored DurationSeconds — NOT CompletedAt − StartedAt (≈ 2 days).
|
||||
Assert.Equal(1800, b.WallClockSeconds);
|
||||
Assert.Equal(TimeAttributionFlags.None, b.QualityFlags); // part1 anchored on the event
|
||||
|
||||
var totals = b.GetSegmentTotals();
|
||||
Assert.Equal(120, totals[TimeAttributionSegments.DevicePrep]); // W1: T0 → +2m
|
||||
Assert.Equal(1080, totals[TimeAttributionSegments.EspApps]); // W1: +2m → part1End
|
||||
Assert.Equal(450, totals[TimeAttributionSegments.IdentityHello]); // W2: +30s → +8m
|
||||
Assert.Equal(120, totals[TimeAttributionSegments.DesktopHandoff]); // W2: +8m → +10m
|
||||
// W2's 30 s resume gap has no phase context — unattributed, not guessed.
|
||||
Assert.Equal(30, b.UnattributedSeconds);
|
||||
// No span may live inside the pause.
|
||||
Assert.DoesNotContain(b.Segments, s => s.StartUtc > part1End && s.EndUtc < resumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_WithoutPart1CompleteEvent_FallsBackAndFlags()
|
||||
{
|
||||
var resumedAt = T0.AddDays(1);
|
||||
var completedAt = resumedAt.AddMinutes(10);
|
||||
var lastPart1Event = T0.AddMinutes(20);
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(lastPart1Event, "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(resumedAt.AddSeconds(10), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete));
|
||||
Assert.Equal(1800, b.WallClockSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_Part2LongerThanStoredDuration_DegradesToSingleWindow_Flagged()
|
||||
{
|
||||
// Stored duration (600 s) contradicts the resume stamp (part 2 alone spans 900 s):
|
||||
// no honest two-window split exists — degrade to the single derived window instead
|
||||
// of inventing a negative part 1.
|
||||
var resumedAt = T0.AddDays(1);
|
||||
var completedAt = resumedAt.AddMinutes(15);
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(resumedAt.AddSeconds(5), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 600, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Equal(600, b.WallClockSeconds);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.WhiteGloveAnchorsIncomplete));
|
||||
}
|
||||
|
||||
// ── blocking list missing / truncated ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NoBlockingLists_MeansUnknown_NotZero()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown));
|
||||
Assert.Empty(b.BlockingApps);
|
||||
Assert.Equal(0, b.BlockingAppCount);
|
||||
Assert.Null(b.EspAppsOccupancySeconds); // unknown — NOT 0 (rule 1)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspConfigWithoutListKeys_IsNotEvidence_LaterEmissionNeverErasesEarlierLists()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(T0.AddMinutes(8), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
// Later emission whose probe found no Diagnostics key — no espTracked* keys at all.
|
||||
Evt(T0.AddMinutes(9), "esp_config_detected",
|
||||
data: new Dictionary<string, object> { ["source"] = "registry_firstsync" }),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetUnknown));
|
||||
Assert.Equal(AppA, Assert.Single(b.BlockingApps).AppId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TruncatedBlockingList_SetsFlag_AbsentAppsStayUnknown()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
// Uncapped total (60) exceeds the emitted list (1 id) → cap dropped entries.
|
||||
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Count: 60, AppA)),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(3), "app_install_started", data: AppData(AppC, "Maybe Capped App")),
|
||||
Evt(T0.AddMinutes(7), "app_install_completed", data: AppData(AppC, "Maybe Capped App", "Installed")),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.BlockingSetTruncated));
|
||||
// App C is absent from the (capped) list → it stays UNKNOWN: no interval, no claim.
|
||||
Assert.Empty(b.BlockingApps);
|
||||
Assert.Equal(0, b.BlockingAppCount);
|
||||
}
|
||||
|
||||
// ── retry: occupancy spans to the LAST terminal event (audit Q1) ────────
|
||||
|
||||
[Fact]
|
||||
public void Retry_IntervalRunsFromFirstStart_ToLastTerminalEvent()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(6), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(T0.AddMinutes(8), "app_install_failed", data: AppData(AppA, "App A")),
|
||||
Evt(T0.AddMinutes(9), "app_install_started", data: AppData(AppA, "App A")), // IME retry
|
||||
Evt(T0.AddMinutes(12), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
Evt(T0.AddMinutes(15), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(15), durationSeconds: 900))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var interval = Assert.Single(b.BlockingApps);
|
||||
// First started (+6m) → LAST terminal (+12m) = 360 s. The payload timing would have
|
||||
// frozen at the first failure (+8m) — event timestamps are the only valid source.
|
||||
Assert.Equal(360, interval.Seconds);
|
||||
Assert.Equal(360, b.EspAppsOccupancySeconds);
|
||||
}
|
||||
|
||||
// ── reboots: spans from event gaps, never the detection event's stamp ───
|
||||
|
||||
[Fact]
|
||||
public void RebootSpan_ComesFromEventGapAroundLastBootUtc_NotFromDetectionTimestamp()
|
||||
{
|
||||
var boot = T0.AddMinutes(12);
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(10), "app_install_started", data: AppData(AppA, "App A")), // last pre-reboot
|
||||
Evt(T0.AddMinutes(14), "system_reboot_detected", // next run, detection-time
|
||||
data: new Dictionary<string, object> { ["lastBootUtc"] = boot.ToString("o") }),
|
||||
// Duplicate detection of the SAME boot (batch replay) — must not double-count.
|
||||
Evt(T0.AddMinutes(14.5), "system_reboot_detected",
|
||||
data: new Dictionary<string, object> { ["lastBootUtc"] = boot.ToString("o") }),
|
||||
Evt(T0.AddMinutes(20), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var span = Assert.Single(b.RebootSpans);
|
||||
// Gap = last event before boot (+10m) → first observation at/after boot (+14m).
|
||||
Assert.Equal(T0.AddMinutes(10), span.StartUtc);
|
||||
Assert.Equal(T0.AddMinutes(14), span.EndUtc);
|
||||
Assert.Equal(240, span.Seconds);
|
||||
Assert.Equal(240, b.RebootSeconds);
|
||||
// The reboot began inside the apps phase — cross-cutting annotation, segment unchanged.
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey);
|
||||
Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]);
|
||||
}
|
||||
|
||||
// ── 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 ─────────────
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_RebootGapAcrossPause_CountsOnlyInWindowFlanks()
|
||||
{
|
||||
var part1End = T0.AddMinutes(20);
|
||||
var resumedAt = T0.AddDays(2); // 2-day pause
|
||||
var completedAt = resumedAt.AddMinutes(10); // part 2 = 600 s → part 1 = 1200 s
|
||||
var bootInsidePause = T0.AddDays(1);
|
||||
|
||||
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"),
|
||||
// — pause: device sealed, user boots it a day later —
|
||||
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
Evt(resumedAt.AddSeconds(40), "system_reboot_detected",
|
||||
data: new Dictionary<string, object> { ["lastBootUtc"] = bootInsidePause.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.RebootSpans);
|
||||
// Gap = part1End → first post-boot event (+30 s into part 2). In-window seconds are
|
||||
// ONLY the part-2 flank (30 s) — the ~2-day pause never counts (pre-fix: ~172 830 s
|
||||
// of "reboot" in a 30-minute enrollment).
|
||||
Assert.Equal(part1End, span.StartUtc);
|
||||
Assert.Equal(resumedAt.AddSeconds(30), span.EndUtc);
|
||||
Assert.Equal(30, span.Seconds);
|
||||
Assert.Equal(30, b.RebootSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_BlockingIntervalAcrossPause_CountsOnlyInWindowSeconds()
|
||||
{
|
||||
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(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(10), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(part1End, "whiteglove_part1_complete"),
|
||||
// — pause —
|
||||
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
// The app's final terminal lands in part 2 (user-scope finish after resume).
|
||||
Evt(resumedAt.AddMinutes(2), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
var interval = Assert.Single(b.BlockingApps);
|
||||
// Chronological endpoints stay the real event times…
|
||||
Assert.Equal(T0.AddMinutes(10), interval.StartUtc);
|
||||
Assert.Equal(resumedAt.AddMinutes(2), interval.EndUtc);
|
||||
// …but Seconds is the in-window sum: part 1 tail (10 min) + part 2 head (2 min).
|
||||
Assert.Equal(720, interval.Seconds);
|
||||
// Occupancy intersects with the esp_apps span (part 1 only) — pause never leaks in.
|
||||
Assert.Equal(600, b.EspAppsOccupancySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGlove_IntervalWhollyInsidePause_MakesNoClaim_AndNoSkewFlag()
|
||||
{
|
||||
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(1), "esp_config_detected", data: EspLists(win32Ids: new[] { AppA })),
|
||||
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(part1End, "whiteglove_part1_complete"),
|
||||
// Both endpoints inside the pause (post-seal tail activity) — observed, but in no window.
|
||||
Evt(part1End.AddHours(1), "app_install_started", data: AppData(AppA, "App A")),
|
||||
Evt(part1End.AddHours(2), "app_install_completed", data: AppData(AppA, "App A", "Installed")),
|
||||
Evt(resumedAt.AddSeconds(30), "agent_started", EnrollmentPhase.AccountSetup),
|
||||
Evt(completedAt, "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
// No in-window observation → no interval claim; and nothing is wrong with the clocks,
|
||||
// so the skew flag must NOT fire (it would exclude the session from fleet medians).
|
||||
Assert.Empty(b.BlockingApps);
|
||||
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.ClockSkewDropped));
|
||||
}
|
||||
|
||||
// ── non-computable sessions ─────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Incomplete")] // terminal but deliberately NO DurationSeconds
|
||||
[InlineData("InProgress")]
|
||||
[InlineData("Pending")]
|
||||
public void NonComputableStatuses_ReturnNull(string status)
|
||||
{
|
||||
var input = Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 1800, status: status);
|
||||
Assert.Null(TimeAttributionCalculator.Compute(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDuration_ReturnsNull_NeverGuessesFromCompletedMinusStarted()
|
||||
{
|
||||
var input = Input(LinearSession(), completedAt: T0.AddMinutes(30), durationSeconds: 0);
|
||||
Assert.Null(TimeAttributionCalculator.Compute(input));
|
||||
}
|
||||
|
||||
// ── no phase evidence at all ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NoPhaseDeclarations_LeaveEverythingUnattributed()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0.AddMinutes(1), "network_state_change"),
|
||||
Evt(T0.AddMinutes(9), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.Empty(b.Segments);
|
||||
Assert.Equal(600, b.UnattributedSeconds); // no evidence → no claim (rule 1)
|
||||
}
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// F1 PR2 (insights spec §F1 "Data & compute changes"): persistence round-trips for the
|
||||
/// SessionTimeBreakdowns / TimeAttributionAggregates rows (table-storage-serialization rule —
|
||||
/// every property must survive Store→Map, tri-states included), the pure daily-aggregation
|
||||
/// core (<see cref="MaintenanceService.BuildTimeAttributionAggregates"/>: clean/flagged/missing
|
||||
/// disclosure, per-class bucketing, fixed segment stack, per-app gates) and the what-if bound.
|
||||
/// </summary>
|
||||
public class TimeAttributionPersistenceAndAggregationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 7, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
private const string AppX = "aaaaaaaa-1111-2222-3333-444444444444";
|
||||
private const string AppY = "bbbbbbbb-1111-2222-3333-444444444444";
|
||||
|
||||
// ── breakdown row round-trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BreakdownEntity_RoundTripsAllFields_IncludingJsonLists()
|
||||
{
|
||||
var breakdown = new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SessionId = "s-1",
|
||||
AttributionVersion = 1,
|
||||
EventCountAtCompute = 261,
|
||||
WallClockSeconds = 1800,
|
||||
UnattributedSeconds = 30,
|
||||
RebootSeconds = 240,
|
||||
BlockingAppCount = 3,
|
||||
EspAppsOccupancySeconds = 600,
|
||||
QualityFlags = TimeAttributionFlags.PartialObservation | TimeAttributionFlags.BlockingSetTruncated,
|
||||
Segments = new List<TimeAttributionSpan>
|
||||
{
|
||||
new() { SegmentKey = TimeAttributionSegments.DevicePrep, StartUtc = T0, EndUtc = T0.AddMinutes(5), Seconds = 300 },
|
||||
new() { SegmentKey = TimeAttributionSegments.EspApps, StartUtc = T0.AddMinutes(5), EndUtc = T0.AddMinutes(30), Seconds = 1500 },
|
||||
},
|
||||
RebootSpans = new List<RebootSpan>
|
||||
{
|
||||
new() { StartUtc = T0.AddMinutes(10), EndUtc = T0.AddMinutes(14), Seconds = 240, SegmentKey = TimeAttributionSegments.EspApps },
|
||||
},
|
||||
BlockingApps = new List<BlockingAppInterval>
|
||||
{
|
||||
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
|
||||
},
|
||||
};
|
||||
|
||||
var mapped = TableStorageService.MapToSessionTimeBreakdown(
|
||||
TableStorageService.BuildSessionTimeBreakdownEntity(breakdown));
|
||||
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("s-1", mapped.SessionId);
|
||||
Assert.Equal(1, mapped.AttributionVersion);
|
||||
// Sweep change signal (Codex review: late batches must trigger a recompute) — a lost
|
||||
// column would read as 0 and recompute forever OR mask real drift.
|
||||
Assert.Equal(261, mapped.EventCountAtCompute);
|
||||
Assert.Equal(1800, mapped.WallClockSeconds);
|
||||
Assert.Equal(30, mapped.UnattributedSeconds);
|
||||
Assert.Equal(240, mapped.RebootSeconds);
|
||||
Assert.Equal(3, mapped.BlockingAppCount);
|
||||
Assert.Equal(600, mapped.EspAppsOccupancySeconds);
|
||||
Assert.Equal(TimeAttributionFlags.PartialObservation | TimeAttributionFlags.BlockingSetTruncated, mapped.QualityFlags);
|
||||
|
||||
Assert.Equal(2, mapped.Segments.Count);
|
||||
Assert.Equal(TimeAttributionSegments.DevicePrep, mapped.Segments[0].SegmentKey);
|
||||
Assert.Equal(T0, mapped.Segments[0].StartUtc);
|
||||
Assert.Equal(300, mapped.Segments[0].Seconds);
|
||||
|
||||
var reboot = Assert.Single(mapped.RebootSpans);
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, reboot.SegmentKey);
|
||||
Assert.Equal(240, reboot.Seconds);
|
||||
|
||||
var app = Assert.Single(mapped.BlockingApps);
|
||||
Assert.Equal(AppX, app.AppId);
|
||||
Assert.Equal("App X", app.AppName);
|
||||
Assert.Equal(600, app.Seconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakdownEntity_OccupancyTriState_AbsentColumnMapsToNull_NeverZero()
|
||||
{
|
||||
var unknown = new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = TenantA, SessionId = "s-1", WallClockSeconds = 600,
|
||||
EspAppsOccupancySeconds = null, QualityFlags = TimeAttributionFlags.BlockingSetUnknown,
|
||||
};
|
||||
|
||||
var entity = TableStorageService.BuildSessionTimeBreakdownEntity(unknown);
|
||||
Assert.False(entity.ContainsKey("EspAppsOccupancySeconds"));
|
||||
Assert.Null(TableStorageService.MapToSessionTimeBreakdown(entity).EspAppsOccupancySeconds);
|
||||
|
||||
// Zero is a real measured value (blocking set known, nothing occupied) — must survive.
|
||||
var zero = new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = TenantA, SessionId = "s-2", WallClockSeconds = 600, EspAppsOccupancySeconds = 0,
|
||||
};
|
||||
Assert.Equal(0, TableStorageService.MapToSessionTimeBreakdown(
|
||||
TableStorageService.BuildSessionTimeBreakdownEntity(zero)).EspAppsOccupancySeconds);
|
||||
}
|
||||
|
||||
// ── aggregate row round-trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AggregateEntity_RoundTripsAllFields()
|
||||
{
|
||||
var aggregate = new TimeAttributionDailyAggregate
|
||||
{
|
||||
TenantId = TenantA,
|
||||
Date = "2026-07-26",
|
||||
EnrollmentClass = "user_driven",
|
||||
AttributionVersion = 1,
|
||||
CleanSessionCount = 23,
|
||||
FlaggedExcludedCount = 4,
|
||||
MissingBreakdownCount = 1,
|
||||
ComputedAt = T0,
|
||||
SegmentStats = new List<TimeAttributionSegmentStat>
|
||||
{
|
||||
new() { SegmentKey = TimeAttributionSegments.EspApps, MedianSeconds = 600, P75Seconds = 700, P90Seconds = 900 },
|
||||
},
|
||||
TopBlockingApps = new List<TimeAttributionBlockingAppStat>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AppId = AppX, AppName = "App X", SessionCount = 12,
|
||||
MedianSeconds = 300, P75Seconds = 400, MedianSavingSeconds = 120, P75SavingSeconds = 200,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var entity = TableStorageService.BuildTimeAttributionAggregateEntity(aggregate);
|
||||
Assert.Equal("2026-07-26|user_driven", entity.RowKey);
|
||||
|
||||
var mapped = TableStorageService.MapToTimeAttributionAggregate(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal("2026-07-26", mapped.Date);
|
||||
Assert.Equal("user_driven", mapped.EnrollmentClass);
|
||||
Assert.Equal(1, mapped.AttributionVersion);
|
||||
Assert.Equal(23, mapped.CleanSessionCount);
|
||||
Assert.Equal(4, mapped.FlaggedExcludedCount);
|
||||
Assert.Equal(1, mapped.MissingBreakdownCount);
|
||||
|
||||
var segment = Assert.Single(mapped.SegmentStats);
|
||||
Assert.Equal(TimeAttributionSegments.EspApps, segment.SegmentKey);
|
||||
Assert.Equal(600, segment.MedianSeconds);
|
||||
Assert.Equal(900, segment.P90Seconds);
|
||||
|
||||
var app = Assert.Single(mapped.TopBlockingApps);
|
||||
Assert.Equal(AppX, app.AppId);
|
||||
Assert.Equal(12, app.SessionCount);
|
||||
Assert.Equal(120, app.MedianSavingSeconds);
|
||||
}
|
||||
|
||||
// ── enrollment class ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentClass_Precedence_WhiteGlove_Wdp_SelfDeploying_UserDriven()
|
||||
{
|
||||
Assert.Equal("whiteglove", TimeAttributionCalculator.GetEnrollmentClass(
|
||||
new SessionSummary { IsPreProvisioned = true, EnrollmentType = "v2", IsSelfDeployingProfile = true }));
|
||||
Assert.Equal("device_preparation", TimeAttributionCalculator.GetEnrollmentClass(
|
||||
new SessionSummary { EnrollmentType = "v2", IsSelfDeployingProfile = true }));
|
||||
Assert.Equal("self_deploying", TimeAttributionCalculator.GetEnrollmentClass(
|
||||
new SessionSummary { IsSelfDeployingProfile = true }));
|
||||
Assert.Equal("user_driven", TimeAttributionCalculator.GetEnrollmentClass(new SessionSummary()));
|
||||
}
|
||||
|
||||
// ── what-if bound ───────────────────────────────────────────────────────
|
||||
|
||||
private static BlockingAppInterval Interval(string appId, DateTime start, DateTime end)
|
||||
=> new() { AppId = appId, AppName = appId, StartUtc = start, EndUtc = end, Seconds = (int)(end - start).TotalSeconds };
|
||||
|
||||
[Fact]
|
||||
public void WhatIf_LastEndingApp_SavesTailToPreviousEnd_OthersSaveNothing()
|
||||
{
|
||||
var intervals = new List<BlockingAppInterval>
|
||||
{
|
||||
Interval(AppX, T0, T0.AddMinutes(10)), // ends 10:10
|
||||
Interval(AppY, T0.AddMinutes(5), T0.AddMinutes(20)), // ends 10:20 — critical-path end
|
||||
};
|
||||
|
||||
// Removing Y moves the path end 10:20 → 10:10.
|
||||
Assert.Equal(600, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppY));
|
||||
// Removing X changes nothing — Y still ends at 10:20.
|
||||
Assert.Equal(0, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
|
||||
// An app without a measured interval yields no claim.
|
||||
Assert.Equal(0, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, "cccccccc-0000-0000-0000-000000000000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhatIf_OnlyBlockingApp_FallsBackToOwnStart()
|
||||
{
|
||||
var intervals = new List<BlockingAppInterval> { Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(20)) };
|
||||
Assert.Equal(900, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhatIf_IdleGapBeforeLastApp_CountsIntoTheBound()
|
||||
{
|
||||
// A ends 10:00; X runs 10:05 → 10:20. Removing X exposes 10:00 as the path end —
|
||||
// the ESP waited for X to START too, so the bound is 20 min, not 15 (spec formula).
|
||||
var intervals = new List<BlockingAppInterval>
|
||||
{
|
||||
Interval(AppY, T0.AddMinutes(-30), T0),
|
||||
Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(20)),
|
||||
};
|
||||
Assert.Equal(1200, TimeAttributionCalculator.WhatIfSavingSeconds(intervals, AppX));
|
||||
}
|
||||
|
||||
// ── daily aggregation core ──────────────────────────────────────────────
|
||||
|
||||
private static SessionSummary Session(string tenantId, DateTime startedAt, bool wg = false)
|
||||
=> new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
StartedAt = startedAt,
|
||||
Status = SessionStatus.Succeeded,
|
||||
IsPreProvisioned = wg,
|
||||
};
|
||||
|
||||
private static SessionTimeBreakdown Breakdown(
|
||||
TimeAttributionFlags flags = TimeAttributionFlags.None,
|
||||
int espAppsSeconds = 600,
|
||||
int unattributed = 0,
|
||||
List<BlockingAppInterval>? apps = null)
|
||||
=> new()
|
||||
{
|
||||
AttributionVersion = TimeAttributionCalculator.CurrentVersion,
|
||||
WallClockSeconds = espAppsSeconds + 300 + unattributed,
|
||||
UnattributedSeconds = unattributed,
|
||||
QualityFlags = flags,
|
||||
Segments = new List<TimeAttributionSpan>
|
||||
{
|
||||
new() { SegmentKey = TimeAttributionSegments.DevicePrep, StartUtc = T0, EndUtc = T0.AddSeconds(300), Seconds = 300 },
|
||||
new() { SegmentKey = TimeAttributionSegments.EspApps, StartUtc = T0.AddSeconds(300), EndUtc = T0.AddSeconds(300 + espAppsSeconds), Seconds = espAppsSeconds },
|
||||
},
|
||||
BlockingApps = apps ?? new List<BlockingAppInterval>(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_BucketPerTenantClassDate_AndMirrorGlobal()
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
|
||||
(Session(TenantA, T0), Breakdown(espAppsSeconds: 300)),
|
||||
(Session(TenantA, T0, wg: true), Breakdown(espAppsSeconds: 900)), // separate class
|
||||
(Session(TenantB, T0), Breakdown(espAppsSeconds: 500)), // separate tenant
|
||||
};
|
||||
|
||||
var aggregates = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0);
|
||||
|
||||
// Tenant A: user_driven + whiteglove; Tenant B: user_driven; global: user_driven + whiteglove.
|
||||
Assert.Equal(5, aggregates.Count);
|
||||
|
||||
var tenantAUde = aggregates.Single(a => a.TenantId == TenantA && a.EnrollmentClass == "user_driven");
|
||||
Assert.Equal("2026-07-26", tenantAUde.Date);
|
||||
Assert.Equal(2, tenantAUde.CleanSessionCount);
|
||||
Assert.Equal(TimeAttributionCalculator.CurrentVersion, tenantAUde.AttributionVersion);
|
||||
// Median of {100, 300} = 100 (nearest-rank: ceil(0.5·2)−1 = index 0 — repo convention).
|
||||
Assert.Equal(100, tenantAUde.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
|
||||
|
||||
var wg = aggregates.Single(a => a.TenantId == TenantA && a.EnrollmentClass == "whiteglove");
|
||||
Assert.Equal(1, wg.CleanSessionCount); // classes never mixed
|
||||
|
||||
var globalUde = aggregates.Single(a => a.TenantId == "global" && a.EnrollmentClass == "user_driven");
|
||||
Assert.Equal(3, globalUde.CleanSessionCount); // A(2) + B(1)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_FlaggedSessions_AreExcludedFromStats_ButDisclosed()
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.PartialObservation, espAppsSeconds: 99999)),
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(1, aggregate.CleanSessionCount);
|
||||
Assert.Equal(1, aggregate.FlaggedExcludedCount);
|
||||
// The flagged 99999s outlier must not shape the median.
|
||||
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_AllFlaggedDay_YieldsDisclosureRow_WithoutStats()
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.ClockSkewDropped)),
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(0, aggregate.CleanSessionCount);
|
||||
Assert.Equal(1, aggregate.FlaggedExcludedCount);
|
||||
Assert.Empty(aggregate.SegmentStats); // no clean sessions → no statistical claim
|
||||
Assert.Empty(aggregate.TopBlockingApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_BlockingOnlyFlags_StayInSegmentStats()
|
||||
{
|
||||
// BlockingSetUnknown/Truncated limit per-app blocking EVIDENCE, not the measured spans.
|
||||
// Gating on any flag starved the fleet aggregates in production (903/1000 breakdowns
|
||||
// carried BlockingSetUnknown, 2026-07-27) — blocking-only flagged sessions must count
|
||||
// as clean; only duration-critical flags exclude.
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown, espAppsSeconds: 100)),
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetTruncated, espAppsSeconds: 300)),
|
||||
(Session(TenantA, T0), Breakdown(
|
||||
flags: TimeAttributionFlags.BlockingSetUnknown | TimeAttributionFlags.PartialObservation,
|
||||
espAppsSeconds: 99999)), // duration-critical bit set → still excluded
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(2, aggregate.CleanSessionCount);
|
||||
Assert.Equal(1, aggregate.FlaggedExcludedCount);
|
||||
// Median of {100, 300} = 100 (nearest-rank, lower value — repo convention).
|
||||
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_MissingBreakdowns_AreCounted_NeverGuessed()
|
||||
{
|
||||
var missing = new List<SessionSummary> { Session(TenantA, T0) };
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
Array.Empty<(SessionSummary, SessionTimeBreakdown)>(), missing, T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(0, aggregate.CleanSessionCount);
|
||||
Assert.Equal(1, aggregate.MissingBreakdownCount);
|
||||
Assert.Empty(aggregate.SegmentStats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_SegmentStack_AlwaysCarriesAllSegmentsPlusUnattributed_AbsentAsZero()
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(unattributed: 42)),
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(6, aggregate.SegmentStats.Count); // 5 canonical + unattributed — the full stack
|
||||
// The fixture has no user_esp span → honest 0, not absent.
|
||||
Assert.Equal(0, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.UserEsp).MedianSeconds);
|
||||
Assert.Equal(42, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.Unattributed).MedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_RollingDateKey_MergesAllDatesIntoRangeStatistics()
|
||||
{
|
||||
// Two sessions on DIFFERENT days — the rolling row must merge them (a median of
|
||||
// per-day medians would not be the range median; the rolling rows are the honest
|
||||
// range statistics the fleet panel reads).
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(espAppsSeconds: 100)),
|
||||
(Session(TenantA, T0.AddDays(-3)), Breakdown(espAppsSeconds: 300)),
|
||||
};
|
||||
|
||||
var rolling = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0, MaintenanceService.RollingAggregateDateKey);
|
||||
|
||||
var row = rolling.Single(a => a.TenantId == TenantA);
|
||||
Assert.Equal(MaintenanceService.RollingAggregateDateKey, row.Date);
|
||||
Assert.Equal(2, row.CleanSessionCount);
|
||||
// Rolling rows sort AFTER every "yyyy-MM-dd|…" key so date-range reads and the
|
||||
// age-based retention filter never touch them.
|
||||
Assert.True(string.CompareOrdinal($"{row.Date}|user_driven", "2026-12-31|zzz") > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_PerAppRows_GateAtFiveSessions_AndCarryWhatIfBounds()
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>();
|
||||
// App X in 5 sessions (ends last each time → saving = its full tail over device-prep-only baseline).
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
pairs.Add((Session(TenantA, T0), Breakdown(apps: new List<BlockingAppInterval>
|
||||
{
|
||||
Interval(AppX, T0.AddMinutes(5), T0.AddMinutes(15)), // only measured blocking app → saving 600
|
||||
})));
|
||||
}
|
||||
// App Y in only 4 sessions → below the per-app row gate.
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
pairs.Add((Session(TenantA, T0), Breakdown(apps: new List<BlockingAppInterval>
|
||||
{
|
||||
Interval(AppY, T0.AddMinutes(5), T0.AddMinutes(6)),
|
||||
})));
|
||||
}
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
var row = Assert.Single(aggregate.TopBlockingApps);
|
||||
Assert.Equal(AppX, row.AppId);
|
||||
Assert.Equal(5, row.SessionCount);
|
||||
Assert.Equal(600, row.MedianSeconds);
|
||||
Assert.Equal(600, row.MedianSavingSeconds); // sole blocking app → bound = full interval
|
||||
}
|
||||
|
||||
// ── stale-bucket reconcile key consistency (Codex review) ───────────────
|
||||
|
||||
[Fact]
|
||||
public void RollingAggregateEntity_RowKey_MatchesTargetedDeleteKey()
|
||||
{
|
||||
// The sweep's reconcile deletes by (Date, EnrollmentClass) — the entity builder and the
|
||||
// delete path must agree on the RK shape for BOTH daily and rolling rows, or stale
|
||||
// rolling rows would never be removed.
|
||||
var rolling = new TimeAttributionDailyAggregate
|
||||
{
|
||||
TenantId = TenantA, Date = "rolling30", EnrollmentClass = "whiteglove",
|
||||
};
|
||||
Assert.Equal("rolling30|whiteglove", TableStorageService.BuildTimeAttributionAggregateEntity(rolling).RowKey);
|
||||
|
||||
var daily = new TimeAttributionDailyAggregate
|
||||
{
|
||||
TenantId = TenantA, Date = "2026-07-27", EnrollmentClass = "user_driven",
|
||||
};
|
||||
Assert.Equal("2026-07-27|user_driven", TableStorageService.BuildTimeAttributionAggregateEntity(daily).RowKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeAttribution_InclusiveWindowStart_YieldsExactly30CalendarDays()
|
||||
{
|
||||
// Codex review: both range ends are inclusive — subtracting the full WindowDays
|
||||
// returned 31 day keys under a "windowDays: 30" label.
|
||||
var today = new System.DateTime(2026, 7, 27);
|
||||
Assert.Equal(today.AddDays(-29),
|
||||
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
|
||||
}
|
||||
}
|
||||
+2
@@ -413,6 +413,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
|
||||
{ "NtpServer", config.NtpServer },
|
||||
{ "EnableImeMatchLog", config.EnableImeMatchLog },
|
||||
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
|
||||
{ "LogLevel", config.LogLevel },
|
||||
{ "MaxBatchSize", config.MaxBatchSize },
|
||||
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
|
||||
@@ -514,6 +515,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
|
||||
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
|
||||
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
|
||||
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
|
||||
LogLevel = entity.GetString("LogLevel"),
|
||||
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
|
||||
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
|
||||
|
||||
+163
-6
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -13,6 +15,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed)
|
||||
/// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix
|
||||
/// cannot collide with a hardware key because no real manufacturer is named "tpmpss")
|
||||
/// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows
|
||||
/// that are refreshed while the episode is active and deleted on re-arm)
|
||||
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
|
||||
/// </summary>
|
||||
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
|
||||
@@ -109,16 +113,102 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryRegisterRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
await _table.AddEntityAsync(BuildRuleRegressionEntity(tenantId, alert!));
|
||||
_logger.LogInformation(
|
||||
"RuleRegression tracker registered: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
// Episode already active — one bell per episode.
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"RuleRegression tracker failed: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
|
||||
// Fail closed so we do not double-fire if the row was actually written.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
|
||||
return;
|
||||
try
|
||||
{
|
||||
// Replace-mode upsert with the FULL payload incl. the caller-carried
|
||||
// FirstNotifiedAt — the retention sweep re-arms on the ORIGINAL age, so a
|
||||
// refresh must never rejuvenate the row.
|
||||
await _table.UpsertEntityAsync(BuildRuleRegressionEntity(tenantId, alert!), TableUpdateMode.Replace);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"RuleRegression tracker refresh failed: tenant={TenantId} rule={RuleId} (stale numbers remain)",
|
||||
tenantId, alert!.RuleId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteRuleRegressionAsync(string tenantId, string ruleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(ruleId))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await _table.DeleteEntityAsync(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(ruleId));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already gone (retention sweep or concurrent pass) — idempotent.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"RuleRegression tracker delete failed: tenant={TenantId} rule={RuleId} (episode stays until retention)",
|
||||
tenantId, ruleId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<RuleRegressionAlert>> GetRuleRegressionsAsync(string tenantId)
|
||||
{
|
||||
var results = new List<RuleRegressionAlert>();
|
||||
if (string.IsNullOrWhiteSpace(tenantId)) return results;
|
||||
try
|
||||
{
|
||||
var partitionKey = tenantId.ToLowerInvariant();
|
||||
// '}' (0x7D) sorts directly after '|' (0x7C) — prefix range over "ruleregression|…".
|
||||
var filter = $"PartitionKey eq '{partitionKey}' and RowKey ge '{RuleRegressionRowKeyPrefix}' and RowKey lt 'ruleregression}}'";
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
results.Add(MapToRuleRegressionAlert(entity));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "RuleRegression tracker list failed for tenant {TenantId}", tenantId);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteOlderThanAsync(DateTime cutoffUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time.
|
||||
// Pruning here resets the lifetime dedup for BOTH key spaces in this table: a hardware
|
||||
// model rejected again after the cutoff rings once more, and so does a TPM-PSS device
|
||||
// that reports again ("tpmpss|{serial}" rows are pruned by the same sweep). Acceptable
|
||||
// in both cases — the portal only surfaces recent rejections anyway, and a device whose
|
||||
// TPM was never fixed is worth surfacing again after a month of silence.
|
||||
// FirstNotifiedAt == the row's first registration (rule-regression refreshes carry
|
||||
// it forward unchanged). Pruning here resets the dedup for EVERY key space in this
|
||||
// table: a hardware model rejected again after the cutoff rings once more, so does
|
||||
// a TPM-PSS device that reports again ("tpmpss|{serial}" rows), and a rule
|
||||
// regression still active after the window fires a fresh bell — a month-old
|
||||
// still-burning regression is worth a reminder (spec §F3 retention re-arm).
|
||||
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
|
||||
var query = _table.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "RowKey" });
|
||||
|
||||
@@ -160,5 +250,72 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return $"tpmpss|{sn}";
|
||||
}
|
||||
|
||||
internal const string RuleRegressionRowKeyPrefix = "ruleregression|";
|
||||
|
||||
internal static string BuildRuleRegressionRowKey(string ruleId)
|
||||
{
|
||||
var id = (ruleId ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return $"{RuleRegressionRowKeyPrefix}{id}";
|
||||
}
|
||||
|
||||
// internal static: entity builder + mapper are pinned by round-trip unit tests
|
||||
// (table-storage-serialization rule — every property must survive Store→Map).
|
||||
internal static TableEntity BuildRuleRegressionEntity(string tenantId, RuleRegressionAlert alert)
|
||||
{
|
||||
var entity = new TableEntity(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(alert.RuleId))
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["RuleId"] = alert.RuleId,
|
||||
["RuleTitle"] = alert.RuleTitle ?? string.Empty,
|
||||
["WindowFireCount"] = alert.WindowFireCount,
|
||||
["WindowSessionCount"] = alert.WindowSessionCount,
|
||||
["BaselineFireCount"] = alert.BaselineFireCount,
|
||||
["BaselineSessionCount"] = alert.BaselineSessionCount,
|
||||
["WindowRatePct"] = alert.WindowRatePct,
|
||||
["BaselineRatePct"] = alert.BaselineRatePct,
|
||||
["WindowStartDate"] = alert.WindowStartDate ?? string.Empty,
|
||||
["WindowEndDate"] = alert.WindowEndDate ?? string.Empty,
|
||||
["FirstNotifiedAt"] = alert.FirstNotifiedAt,
|
||||
["LastEvaluatedAt"] = alert.LastEvaluatedAt,
|
||||
};
|
||||
// Tri-states: absent column = "no claim" — a zero-baseline alert has no finite lift,
|
||||
// and a missing dimension means "no clear concentration", never a guessed one.
|
||||
if (alert.Lift.HasValue)
|
||||
entity["Lift"] = alert.Lift.Value;
|
||||
if (alert.Dimension != null)
|
||||
entity["DimensionJson"] = JsonSerializer.Serialize(alert.Dimension);
|
||||
return entity;
|
||||
}
|
||||
|
||||
internal static RuleRegressionAlert MapToRuleRegressionAlert(TableEntity entity)
|
||||
{
|
||||
RuleRegressionDimension? dimension = null;
|
||||
var dimensionJson = entity.GetString("DimensionJson");
|
||||
if (!string.IsNullOrEmpty(dimensionJson))
|
||||
{
|
||||
try { dimension = JsonSerializer.Deserialize<RuleRegressionDimension>(dimensionJson!); }
|
||||
catch (JsonException) { dimension = null; } // corrupt column — degrade to "no claim"
|
||||
}
|
||||
|
||||
return new RuleRegressionAlert
|
||||
{
|
||||
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
|
||||
RuleId = entity.GetString("RuleId") ?? entity.RowKey.Substring(RuleRegressionRowKeyPrefix.Length),
|
||||
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
|
||||
WindowFireCount = entity.GetInt32("WindowFireCount") ?? 0,
|
||||
WindowSessionCount = entity.GetInt32("WindowSessionCount") ?? 0,
|
||||
BaselineFireCount = entity.GetInt32("BaselineFireCount") ?? 0,
|
||||
BaselineSessionCount = entity.GetInt32("BaselineSessionCount") ?? 0,
|
||||
WindowRatePct = entity.GetDouble("WindowRatePct") ?? 0,
|
||||
BaselineRatePct = entity.GetDouble("BaselineRatePct") ?? 0,
|
||||
Lift = entity.GetDouble("Lift"),
|
||||
WindowStartDate = entity.GetString("WindowStartDate") ?? string.Empty,
|
||||
WindowEndDate = entity.GetString("WindowEndDate") ?? string.Empty,
|
||||
Dimension = dimension,
|
||||
FirstNotifiedAt = entity.GetDateTimeOffset("FirstNotifiedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -99,6 +99,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task DeleteEventSessionIndexEntryAsync(string tenantId, string sessionId)
|
||||
=> _storage.DeleteEventSessionIndexEntryAsync(tenantId, sessionId);
|
||||
|
||||
public Task<List<(string TenantId, string SessionId)>> GetAllSessionTombstoneKeysAsync()
|
||||
=> _storage.GetAllSessionTombstoneKeysAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Tenant offboarding cascade-worker session enumerator. Yields one row per session
|
||||
/// belonging to <paramref name="tenantId"/>. Storage exceptions are intentionally
|
||||
|
||||
+48
@@ -111,5 +111,53 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
public Task<int> DeleteRuleStatsOlderThanAsync(DateTime cutoffDate)
|
||||
=> _storage.DeleteRuleStatsOlderThanAsync(cutoffDate);
|
||||
|
||||
public Task<SessionTimeBreakdown?> ComputeAndStoreSessionTimeBreakdownAsync(string tenantId, string sessionId)
|
||||
=> _storage.ComputeAndStoreSessionTimeBreakdownAsync(tenantId, sessionId);
|
||||
|
||||
public Task<SessionTimeBreakdown?> GetSessionTimeBreakdownAsync(string tenantId, string sessionId)
|
||||
=> _storage.GetSessionTimeBreakdownAsync(tenantId, sessionId);
|
||||
|
||||
public Task ResolveEspBlockingForSessionAsync(string tenantId, string sessionId)
|
||||
=> _storage.ResolveEspBlockingForSessionAsync(tenantId, sessionId);
|
||||
|
||||
public Task<bool> SaveTimeAttributionAggregateAsync(TimeAttributionDailyAggregate aggregate)
|
||||
=> _storage.SaveTimeAttributionAggregateAsync(aggregate);
|
||||
|
||||
public Task<List<TimeAttributionDailyAggregate>> GetTimeAttributionAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate)
|
||||
=> _storage.GetTimeAttributionAggregatesAsync(tenantId, startDate, endDate);
|
||||
|
||||
public Task<List<TimeAttributionDailyAggregate>> GetRollingTimeAttributionAggregatesAsync(string tenantId)
|
||||
=> _storage.GetRollingTimeAttributionAggregatesAsync(tenantId);
|
||||
|
||||
public Task DeleteTimeAttributionAggregateAsync(string tenantId, string dateKey, string enrollmentClass)
|
||||
=> _storage.DeleteTimeAttributionAggregateAsync(tenantId, dateKey, enrollmentClass);
|
||||
|
||||
public Task<int> DeleteTimeAttributionAggregatesOlderThanAsync(DateTime cutoffDate)
|
||||
=> _storage.DeleteTimeAttributionAggregatesOlderThanAsync(cutoffDate);
|
||||
|
||||
public Task<DeviceHistory?> GetDeviceHistoryAsync(string tenantId, string serialKey)
|
||||
=> _storage.GetDeviceHistoryAsync(tenantId, serialKey);
|
||||
|
||||
public Task<List<DeviceHistory>> GetDeviceHistoriesByTenantAsync(string tenantId)
|
||||
=> _storage.GetDeviceHistoriesByTenantAsync(tenantId);
|
||||
|
||||
public Task<bool> UpsertDeviceHistoryAsync(DeviceHistory history)
|
||||
=> _storage.UpsertDeviceHistoryAsync(history);
|
||||
|
||||
public Task DeleteDeviceHistoryAsync(string tenantId, string serialKey)
|
||||
=> _storage.DeleteDeviceHistoryAsync(tenantId, serialKey);
|
||||
|
||||
public Task<bool> SaveDeviceJourneyAggregateAsync(DeviceJourneyDailyAggregate aggregate)
|
||||
=> _storage.SaveDeviceJourneyAggregateAsync(aggregate);
|
||||
|
||||
public Task<List<DeviceJourneyDailyAggregate>> GetDeviceJourneyAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate)
|
||||
=> _storage.GetDeviceJourneyAggregatesAsync(tenantId, startDate, endDate);
|
||||
|
||||
public Task DeleteDeviceJourneyAggregateAsync(string tenantId, string dateKey)
|
||||
=> _storage.DeleteDeviceJourneyAggregateAsync(tenantId, dateKey);
|
||||
|
||||
public Task<int> DeleteDeviceJourneyAggregatesOlderThanAsync(DateTime cutoffDate)
|
||||
=> _storage.DeleteDeviceJourneyAggregatesOlderThanAsync(cutoffDate);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -213,6 +213,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["ReportType"] = string.IsNullOrEmpty(metadata.ReportType) ? ReportTypes.Session : metadata.ReportType
|
||||
};
|
||||
|
||||
// Diagnostics-copy columns only exist on rows where the copy was requested —
|
||||
// legacy-shaped rows stay untouched and map back to null.
|
||||
if (metadata.DiagnosticsBlobName != null)
|
||||
entity["DiagnosticsBlobName"] = metadata.DiagnosticsBlobName;
|
||||
if (metadata.DiagnosticsCopyStatus != null)
|
||||
entity["DiagnosticsCopyStatus"] = metadata.DiagnosticsCopyStatus;
|
||||
|
||||
await _reportsTableClient.UpsertEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
@@ -365,8 +372,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
|
||||
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
AdminNote = entity.GetString("AdminNote") ?? string.Empty,
|
||||
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType
|
||||
ReportType = string.IsNullOrEmpty(reportType) ? ReportTypes.Session : reportType,
|
||||
DiagnosticsBlobName = NullIfEmpty(entity.GetString("DiagnosticsBlobName")),
|
||||
DiagnosticsCopyStatus = NullIfEmpty(entity.GetString("DiagnosticsCopyStatus"))
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
{
|
||||
return string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<RuleResult>> GetRuleResultsAsync(string tenantId, string sessionId)
|
||||
=> _storage.GetRuleResultsAsync(tenantId, sessionId);
|
||||
|
||||
public Task<List<string>> GetRuleHitSessionIdsAsync(string tenantId, string ruleId, DateTime sinceUtc, int maxResults = 2000)
|
||||
=> _storage.GetRuleHitSessionIdsAsync(tenantId, ruleId, sinceUtc, maxResults);
|
||||
|
||||
public Task<bool> StoreGatherRuleAsync(GatherRule rule, string tenantId = "global")
|
||||
=> _storage.StoreGatherRuleAsync(rule, tenantId);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user