mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba67162aa | |||
| efc96258d6 | |||
| 9d8b0fc254 | |||
| f3028041af | |||
| 3628c7bc79 | |||
| 395fdd49df | |||
| 5edb64f3c9 | |||
| 46965af290 | |||
| 1bfdb68ff6 | |||
| 5ee5114d5c | |||
| a7bb7ebf15 | |||
| a7a1cc43c1 | |||
| 2a5ad65dd7 | |||
| 960b3d8ed9 | |||
| 86bb255b11 | |||
| d3a10dfb96 | |||
| 80d0b30cba | |||
| aee723e497 | |||
| 427c923530 | |||
| b46a8f1571 | |||
| 6e294c010e | |||
| dc54c9555c | |||
| 784fd99240 | |||
| 607d6615b0 | |||
| 8d4f7ea0f9 | |||
| 0e2d620ad4 | |||
| 69d61c6c26 | |||
| cfb28d83b6 | |||
| f795fbcb4e | |||
| d9d22bc443 | |||
| 6bd5728652 | |||
| d6affae8fc | |||
| a70c5de667 | |||
| e62bac9fea | |||
| 1e94a7aa91 | |||
| c330e5c91a | |||
| 6015548172 | |||
| 3f661d097d | |||
| 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 |
+1
-1
Submodule .claude/commands updated: 997df4b5ed...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 115
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -39,6 +39,10 @@ jobs:
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
# and the default app for browsers without a stored selection (legacy|primary).
|
||||
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
|
||||
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
@@ -52,7 +56,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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
|
||||
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](LICENSE)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
|
||||
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
|
||||
|
||||
## Private Preview
|
||||
## Availability
|
||||
|
||||
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
|
||||
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/SessionList.png" width="45%" />
|
||||
|
||||
@@ -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,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,75 @@
|
||||
---
|
||||
type: concept
|
||||
title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Activation
|
||||
|
||||
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
|
||||
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
|
||||
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
|
||||
30 s negative cache).
|
||||
|
||||
Two activation routes, one shared implementation
|
||||
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
|
||||
|
||||
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
|
||||
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
|
||||
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
|
||||
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
|
||||
Typical latency: ~1 minute after signup.
|
||||
|
||||
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
|
||||
arbitrates concurrent activations; exactly one caller runs the side effects
|
||||
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
|
||||
|
||||
## The welcome-mail race
|
||||
|
||||
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
|
||||
same table) — the only reliable address: it is typed by the user on the activation-pending
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
**Arbitration — write-then-read on both sides plus a send-once marker:**
|
||||
|
||||
* The approval path writes the `approved` row, then reads the address.
|
||||
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
|
||||
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
|
||||
activation for exactly this window).
|
||||
* Since both do write-then-read, at least one sees both halves and calls
|
||||
`TenantApprovalService.TrySendWelcomeEmailAsync`.
|
||||
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
|
||||
sends: at most one wins. The marker is consumed strictly **after** the address check —
|
||||
consuming it without an address would permanently suppress the mail.
|
||||
|
||||
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
|
||||
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
|
||||
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
notification-email save path"; user saves address at T+69 s → save path sees approved
|
||||
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
|
||||
* Address saved before manual approve (classic shape): approval path finds the address,
|
||||
wins the marker, sends; a later re-save of the same address does not re-send.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
+4
-1
@@ -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,9 +33,11 @@ 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.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [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
|
||||
@@ -43,7 +46,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
# 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 against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the six connections that also serve the document and the route chunks. Includes where browser telemetry actually lives.
|
||||
* [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
|
||||
|
||||
|
||||
+19
-1
@@ -1,8 +1,26 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
|
||||
|
||||
## 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). That origin serves the document, the route chunks and the RSC payloads over **HTTP/1.1**, so the six-connection-per-origin cap applies; 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 in-flight prefetches held every socket and the navigation the user wanted could not get one. 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 `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.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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 against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the connection pool that serves the document and the route chunks.
|
||||
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
|
||||
@@ -25,21 +25,26 @@ it enters the viewport.
|
||||
|
||||
# Mechanism
|
||||
|
||||
Three properties combine into a portal-wide freeze:
|
||||
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. **Shared origin over HTTP/1.1.** `portal.autopilotmonitor.com` serves the document, the JS route
|
||||
chunks *and* the RSC payloads, and it negotiates HTTP/1.1 — not HTTP/2. Browsers cap concurrent
|
||||
connections per origin at six.
|
||||
3. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
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.
|
||||
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
|
||||
|
||||
When the tail hits, the in-flight prefetches hold all six connections. The document request and the
|
||||
route chunks of the page the user actually wants then have no socket available, and the tab shows a
|
||||
loading spinner or a blank page until the queue drains. Nothing fails — the requests eventually
|
||||
return `200`, which is why the failure leaves no error trace anywhere.
|
||||
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
|
||||
|
||||
@@ -62,6 +67,39 @@ document immediately and the browser then waited nearly four minutes for the `lo
|
||||
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
|
||||
|
||||
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
|
||||
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
|
||||
@@ -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]
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -92,6 +92,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
|
||||
}
|
||||
|
||||
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
|
||||
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
|
||||
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
|
||||
{
|
||||
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
|
||||
// lines with an explicit bias. Without bias handling the line either failed to
|
||||
// match at all (old regex) or would be misread in agent-local time.
|
||||
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
|
||||
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
|
||||
}
|
||||
|
||||
// ── Oversized line → parses, no throw, no truncation of the message ───
|
||||
|
||||
[Fact]
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -22,7 +22,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
Assert.InRange(result.SignalPercent, 0, 100);
|
||||
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");
|
||||
}
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
+28
-5
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
// Session df1fcf47: the DO collector observed the completion live, but the event
|
||||
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
|
||||
// the past — which dragged Sessions.StartedAt back and inflated the duration.
|
||||
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
|
||||
// last matched line.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do-poll", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
|
||||
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
// Not a replay — live observation must never trip the replay suppression,
|
||||
// even with a stale unrelated line on the tracker.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
int priorDoCalls = 0;
|
||||
int priorScriptCalls = 0;
|
||||
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
|
||||
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
|
||||
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.OnImeAgentVersion("1.0.0.0");
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
|
||||
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
|
||||
|
||||
Assert.Equal(1, priorVersionCalls);
|
||||
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
Action<string> priorVersion = _ => { };
|
||||
Action<AppPackageState> priorDo = _ => { };
|
||||
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
|
||||
Action<ScriptExecutionState> priorScript = _ => { };
|
||||
f.Tracker.OnImeAgentVersion = priorVersion;
|
||||
f.Tracker.OnDoTelemetryReceived = priorDo;
|
||||
|
||||
+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()
|
||||
{
|
||||
|
||||
@@ -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>()
|
||||
|
||||
+25
-5
@@ -22,9 +22,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format
|
||||
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
|
||||
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
|
||||
// UTC = local + bias) — without the optional bias group such lines would not match
|
||||
// at all and their content would be invisible to every pattern.
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
);
|
||||
|
||||
@@ -45,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var biasStr = match.Groups["bias"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
var component = match.Groups["component"].Value;
|
||||
var typeStr = match.Groups["type"].Value;
|
||||
@@ -52,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
|
||||
DateTime timestamp;
|
||||
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
|
||||
if (!TryParseTimestamp(dateStr, timeStr, biasStr, out timestamp))
|
||||
{
|
||||
timestamp = DateTime.UtcNow;
|
||||
}
|
||||
@@ -72,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, string biasStr, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
|
||||
@@ -116,7 +120,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"M-d-yyyy HH:mm:ss"
|
||||
};
|
||||
|
||||
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
|
||||
// Bias-suffixed time ("+480"): the writer declared its own UTC offset in minutes
|
||||
// (GetTimeZoneInformation convention: UTC = written time + bias). Honor it instead
|
||||
// of assuming the agent's local timezone — writer and agent can disagree (mixed
|
||||
// components, mid-enrollment timezone change; session df1fcf47).
|
||||
if (!string.IsNullOrEmpty(biasStr)
|
||||
&& int.TryParse(biasStr, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var biasMinutes))
|
||||
{
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out result))
|
||||
{
|
||||
result = DateTime.SpecifyKind(result.AddMinutes(biasMinutes), DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// No bias: CMTrace timestamps are in LOCAL time. Parse as local, then convert to UTC.
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal, out result))
|
||||
{
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// <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).</summary>
|
||||
public int SignalPercent { 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; }
|
||||
@@ -37,20 +40,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
/// 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
|
||||
{
|
||||
if (WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle) != ERROR_SUCCESS)
|
||||
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
|
||||
{
|
||||
if (WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList) != ERROR_SUCCESS)
|
||||
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);
|
||||
@@ -61,17 +89,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
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);
|
||||
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
|
||||
@@ -80,8 +112,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
WlanFreeMemory(interfaceList);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
@@ -91,21 +124,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
}
|
||||
}
|
||||
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid)
|
||||
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
|
||||
{
|
||||
var dataPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
|
||||
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
|
||||
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
|
||||
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
|
||||
|
||||
+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()}");
|
||||
|
||||
+13
-9
@@ -909,24 +909,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["wifiSignalPercent"] = wifi.SignalPercent
|
||||
};
|
||||
_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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var message = wifi.Ssid != null
|
||||
? $"WiFi: {wifi.Ssid}, Signal: {wifi.SignalPercent}%"
|
||||
: $"WiFi signal info, Signal: {wifi.SignalPercent}%";
|
||||
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})";
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
+7
-1
@@ -398,7 +398,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
try
|
||||
{
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid);
|
||||
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
|
||||
if (wifi == null)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
|
||||
wifi = NetshWifiFallback.TryRead();
|
||||
}
|
||||
|
||||
if (wifi != null)
|
||||
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
getPackageStates: () => imeHost.PackageStates,
|
||||
onDoTelemetryReceived: pkg =>
|
||||
{
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
// null source timestamp: poll-driven completion, no log line backs it —
|
||||
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>C:\Windows\System32\tzutil.exe</summary>
|
||||
public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\netsh.exe</summary>
|
||||
public static readonly string Netsh = Path.Combine(System32, "netsh.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\schtasks.exe</summary>
|
||||
public static readonly string Schtasks = Path.Combine(System32, "schtasks.exe");
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
|
||||
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitImeAgentVersion(version);
|
||||
}
|
||||
|
||||
private void OnDoTelemetryReceived(AppPackageState app)
|
||||
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnDoTelemetryReceived?.Invoke(app);
|
||||
EmitDoTelemetry(app);
|
||||
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitAppState(app, oldState, newState);
|
||||
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
|
||||
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app)
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id)) return;
|
||||
|
||||
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
// Two trigger paths with different time grounding (session df1fcf47: a single
|
||||
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
|
||||
// session StartedAt back 7h and inflated the reported duration):
|
||||
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
|
||||
// event time — resolve/clamp it and apply historic-replay suppression.
|
||||
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
|
||||
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
|
||||
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
|
||||
DateTime now;
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
if (sourceTimestampUtc.HasValue)
|
||||
{
|
||||
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
now = _clock.UtcNow;
|
||||
derivedFromClock = true;
|
||||
rawSourceTs = null;
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
|
||||
+25
-4
@@ -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,
|
||||
@@ -777,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))
|
||||
@@ -813,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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + round-trip tests for <see cref="AdminConfiguration.AutoApproveNewTenants"/>.
|
||||
/// The flag gates the tenant auto-approve queue worker — defaulting to false keeps new tenant
|
||||
/// signups on manual Global Admin approval until the operator deliberately opts in.
|
||||
/// </summary>
|
||||
public class AdminConfigurationAutoApproveNewTenantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
// CreateDefault() is called when no admin-config row exists yet — if it somehow
|
||||
// enabled auto-approve, tenants would activate without any operator opt-in.
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { AutoApproveNewTenants = true };
|
||||
Assert.True(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,7 @@ public class AdminConfigurationRateLimitSyncTests
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + fail-closed tests for <see cref="AdminConfiguration.SelfServiceAppHomingEnabled"/>.
|
||||
/// The flag is the kill switch of the dual app-reg self-service homing flip — defaulting to false
|
||||
/// keeps the consent funnel and auto-flip off until the operator deliberately opts in, and a
|
||||
/// storage error must read as "disabled" (kill switch wins over a blip).
|
||||
/// </summary>
|
||||
public class AdminConfigurationSelfServiceAppHomingTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { SelfServiceAppHomingEnabled = true };
|
||||
Assert.True(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_returns_true_when_flag_enabled()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { SelfServiceAppHomingEnabled = true });
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.True(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_fails_closed_on_repository_error()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.False(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization/validation matrix for <see cref="AppHomingService.EvaluateManualFlip"/> — the
|
||||
/// pure decision behind <c>POST config/{tenantId}/app-homing</c>. Contract: tenant admins may
|
||||
/// only flip TO primary, without force, with the self-service flag on, and only after a
|
||||
/// successful consent probe; Global Admins may flip both directions and force past the probe.
|
||||
/// </summary>
|
||||
public class AppHomingDecisionTests
|
||||
{
|
||||
private static readonly AppHomingProbeResult ProbeOk = new(Succeeded: true, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeFail = new(Succeeded: false, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeTransient = new(Succeeded: false, IsTransient: true);
|
||||
|
||||
private static AppHomingDecision Evaluate(
|
||||
bool isGlobalAdmin = false,
|
||||
bool selfServiceEnabled = true,
|
||||
bool legacyConfigured = true,
|
||||
bool currentlyPrimary = false,
|
||||
bool targetPrimary = true,
|
||||
bool force = false,
|
||||
AppHomingProbeResult? probe = null)
|
||||
=> AppHomingService.EvaluateManualFlip(
|
||||
isGlobalAdmin, selfServiceEnabled, legacyConfigured, currentlyPrimary, targetPrimary, force, probe);
|
||||
|
||||
[Fact]
|
||||
public void ParallelWindowInactive_denies_for_everyone()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, legacyConfigured: false, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("parallel-window-inactive", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)] // legacy-homed, target legacy
|
||||
[InlineData(true, true)] // primary-homed, target primary
|
||||
public void AlreadyAtTarget_is_a_noop(bool currentlyPrimary, bool targetPrimary)
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: currentlyPrimary, targetPrimary: targetPrimary);
|
||||
Assert.Equal(AppHomingDecisionKind.AllowNoOp, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_revert_to_legacy_is_denied()
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("revert-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_force_is_denied()
|
||||
{
|
||||
var decision = Evaluate(force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("force-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_with_flag_off_is_denied()
|
||||
{
|
||||
// The kill switch: turning the admin-config flag off must stop tenant-admin flips
|
||||
// immediately, while the GA path below stays open (operator lever survives).
|
||||
var decision = Evaluate(selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("self-service-disabled", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_with_flag_off_still_requires_only_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_without_probe_requires_probe()
|
||||
{
|
||||
var decision = Evaluate();
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_successful_probe_is_allowed()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeOk);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_failed_probe_is_denied()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeFail);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-failed", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_transient_probe_is_denied_as_retryable()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeTransient);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-transient", decision.ReasonCode);
|
||||
Assert.Equal(503, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_flip_without_force_also_requires_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_force_skips_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_revert_to_legacy_needs_no_probe()
|
||||
{
|
||||
// The legacy app is consented by construction — probing it would be meaningless.
|
||||
var decision = Evaluate(isGlobalAdmin: true, currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
|
||||
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
|
||||
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
|
||||
/// </summary>
|
||||
public class AppHomingServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
private const string Actor = "admin@contoso.com";
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<GraphTokenService> _graphTokenMock;
|
||||
private readonly Mock<IGraphFeatureDetector> _detectorMock;
|
||||
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
|
||||
private readonly List<OpsEventEntry> _savedOpsEvents = new();
|
||||
private readonly EntraAppRegistry _registry;
|
||||
private readonly AppHomingService _sut;
|
||||
|
||||
public AppHomingServiceTests() : this(legacyConfigured: true) { }
|
||||
|
||||
private AppHomingServiceTests(bool legacyConfigured)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
};
|
||||
if (legacyConfigured)
|
||||
{
|
||||
configValues["EntraId:LegacyClientId"] = LegacyId;
|
||||
configValues["EntraId:LegacyClientSecret"] = "legacy-secret";
|
||||
}
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
|
||||
_registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_adminConfigMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_graphTokenMock = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
configuration,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
_detectorMock = new Mock<IGraphFeatureDetector>();
|
||||
_maintenanceMock = new Mock<IMaintenanceRepository>();
|
||||
_maintenanceMock
|
||||
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new AppHomingService(
|
||||
NullLogger<AppHomingService>.Instance,
|
||||
_adminConfigMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_registry,
|
||||
_graphTokenMock.Object,
|
||||
_detectorMock.Object,
|
||||
_maintenanceMock.Object,
|
||||
opsService,
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
|
||||
|
||||
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
|
||||
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
|
||||
var config = LegacyHomedConfig();
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
SetupProbe(GraphTokenResult.Success("tok"));
|
||||
}
|
||||
|
||||
private static TenantConfiguration LegacyHomedConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.HomedAppClientId = null;
|
||||
return config;
|
||||
}
|
||||
|
||||
private void SetupProbe(GraphTokenResult result) =>
|
||||
_graphTokenMock
|
||||
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_eligible_for_legacy_homed_tenant_with_flag_on()
|
||||
{
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_legacy_pair_unconfigured()
|
||||
{
|
||||
var sut = new AppHomingServiceTests(legacyConfigured: false)._sut;
|
||||
Assert.False(await sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_for_primary_homed_tenant()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_does_not_block_on_entra_app_roles()
|
||||
{
|
||||
// Deliberate: app-role tenants may flip (operator informs them personally); the
|
||||
// reminder trail is asserted in the flip test below.
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
|
||||
{
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
|
||||
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
|
||||
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
|
||||
d != null && d.ContainsKey("HomedAppClientId"))), Times.Once);
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.PermanentFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_transient_probe()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.TransientFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_for_primary_homed_tenant_and_never_probes()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(config, Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(It.IsAny<string>(),
|
||||
It.IsAny<EntraAppCredentials>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── FlipAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_of_entra_app_roles_tenant_emits_reminder_ops_event()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_noops_when_already_at_target()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
|
||||
{
|
||||
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
|
||||
// the fresh read returning null is a hard stop.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_back_to_legacy_persists_null_homing()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
|
||||
|
||||
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -44,7 +45,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
{ CallBase = false };
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
@@ -76,6 +77,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
@@ -87,11 +89,15 @@ public class AuthFunctionSideEffectTests
|
||||
previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
mcpUserMock.Object);
|
||||
mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
@@ -126,12 +132,89 @@ public class AuthFunctionSideEffectTests
|
||||
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
|
||||
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
"preview_signup", "New Tenant 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);
|
||||
// Signup enqueues the delayed auto-approve unconditionally — the worker is the
|
||||
// decision point (flag check at processing time).
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.Is<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
|
||||
e => e.TenantId == TenantId && e.SignupUpn == Upn),
|
||||
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
// incident 2026-07-31: the login right after an app-homing flip carries the
|
||||
// changed audience; blind-saving the stale cached entity reverted the flip.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip()
|
||||
{
|
||||
// Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
cached.HomedAppClientId = null;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = LegacyAppId;
|
||||
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
|
||||
Assert.NotNull(fresh.LastAuthClientIdSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite()
|
||||
{
|
||||
// Another instance already recorded the new client id — the fresh re-check must
|
||||
// suppress the redundant write even though the cached view still looks outdated.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead()
|
||||
{
|
||||
// Steady state (same app as last time) must stay a zero-I/O no-op.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,7 +239,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -168,7 +251,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -179,7 +262,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -196,7 +279,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// SaveConfig should still have been called before the fire-and-forget
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -217,7 +300,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Null(config.DisabledReason);
|
||||
Assert.Null(config.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,7 +313,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -241,7 +324,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -254,7 +337,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -223,11 +223,11 @@ public class AuthFunctionTests
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate 2: Preview Gate
|
||||
// Gate 2: Activation Gate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PreviewGate_NotApproved_Returns403PrivatePreview()
|
||||
public void PreviewGate_NotApproved_Returns403PendingActivation()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
|
||||
@@ -238,7 +238,7 @@ public class AuthFunctionTests
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("PrivatePreview", (string)body.error);
|
||||
Assert.Equal("PendingActivation", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+32
@@ -203,6 +203,38 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
|
||||
}
|
||||
|
||||
// ── Dual app-reg window (C4A8 move): primary=new app, legacy=old app via LegacyClientId ──
|
||||
// The middleware folds EntraId:LegacyClientId into the additional-id set via
|
||||
// CombineAdditionalClientIdSources, so both apps' tokens validate in bare AND api:// form.
|
||||
|
||||
private const string NewAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string OldAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
[Theory]
|
||||
[InlineData(NewAppId)]
|
||||
[InlineData("api://" + NewAppId)]
|
||||
[InlineData(OldAppId)]
|
||||
[InlineData("api://" + OldAppId)]
|
||||
public void DualAppWindow_accepts_both_apps_tokens_in_both_audience_forms(string audience)
|
||||
{
|
||||
var clientIds = AuthenticationMiddleware.ResolveConfiguredClientIds(
|
||||
NewAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, null),
|
||||
out _);
|
||||
|
||||
Assert.NotNull(ValidateWith(Mint(RsaCreds, audience: audience), clientIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombineAdditionalClientIdSources_joins_and_skips_empty_sources()
|
||||
{
|
||||
Assert.Equal($"{OldAppId},{SecondaryClientId}",
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, SecondaryClientId));
|
||||
Assert.Equal(OldAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources($" {OldAppId} ", null, ""));
|
||||
Assert.Null(AuthenticationMiddleware.CombineAdditionalClientIdSources(null, "", " "));
|
||||
}
|
||||
|
||||
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
|
||||
|
||||
[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>
|
||||
|
||||
@@ -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,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
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.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The universal pre-write snapshot hook in <see cref="TableConfigRepository"/>:
|
||||
/// every overwriting save of a TenantConfiguration/AdminConfiguration row must first
|
||||
/// snapshot the stored row into ConfigurationBackups — except row creations and
|
||||
/// noise-only changes — and a backup-storage failure must never break the save (fail-soft).
|
||||
/// </summary>
|
||||
public class ConfigBackupHookTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
|
||||
public Harness(TableEntity? stored)
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
}
|
||||
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
Backup.Object,
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantConfiguration StoredConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors production: every real writer round-trips the STORED row (read-modify-write),
|
||||
/// so time-stamped fields like OnboardedAt match the stored values exactly. Constructing
|
||||
/// a second CreateDefault instead would fabricate an OnboardedAt diff that cannot occur.
|
||||
/// </summary>
|
||||
private static TenantConfiguration RoundtripClone(TenantConfiguration stored)
|
||||
=> TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
[Fact]
|
||||
public async Task RealChange_SnapshotsStoredRow_ThenSaves_ThenPrunes()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
incoming.UpdatedBy = "ga@operator.example";
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config row itself
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal(TenantId, snapshot!.PartitionKey);
|
||||
Assert.Equal("portal-put", snapshot.Source);
|
||||
Assert.Equal("test change", snapshot.Reason);
|
||||
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
|
||||
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
|
||||
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
|
||||
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
|
||||
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_NoExistingRow_NoSnapshot()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
// The auth-side-effect write: only LastAuthClientId/Since + bookkeeping move.
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
incoming.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
incoming.UpdatedBy = "System (auth)";
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
|
||||
|
||||
Assert.True(saved); // the save itself still goes through
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOpSave_IdenticalContent_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupStorageFailure_IsFailSoft_SaveStillSucceeds()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "backup table throttled"));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config write went through regardless
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
await harness.Sut.SaveTenantConfigurationAsync(incoming);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("unknown", snapshot!.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminConfigSave_SnapshotsUnderGlobalConfigPartition()
|
||||
{
|
||||
var stored = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
};
|
||||
var harness = new Harness(TableConfigRepository.ConvertToAdminTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
|
||||
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
|
||||
Assert.Equal("admin-config", snapshot.Source);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync("GlobalConfig", Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"),
|
||||
/// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics.
|
||||
/// </summary>
|
||||
public class ConfigBackupRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigBackupRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
public List<(string Pk, string Rk)> Deletes { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.DeleteEntityAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
|
||||
{
|
||||
Deletes.Add((pk, rk));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
Sut = new TableConfigBackupRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
NullLogger<TableConfigBackupRepository>.Instance);
|
||||
}
|
||||
|
||||
public void SetupQuery(params TableEntity[] rows)
|
||||
{
|
||||
var page = Page<TableEntity>.FromValues(rows, continuationToken: null, Mock.Of<Response>());
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
|
||||
}
|
||||
}
|
||||
|
||||
private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new()
|
||||
{
|
||||
PartitionKey = TenantId,
|
||||
RowKey = rowKey,
|
||||
TenantId = TenantId,
|
||||
EntityJson = "{\"DomainName\":\"contoso.com\"}",
|
||||
ChangedBy = "ga@operator.example",
|
||||
Source = "mcp-patch",
|
||||
Reason = "retention bump",
|
||||
DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}",
|
||||
BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_StoreAndMap_PreservesEveryField()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var entry = Entry();
|
||||
|
||||
await harness.Sut.UpsertAsync(entry);
|
||||
var stored = Assert.Single(harness.Upserts);
|
||||
|
||||
// Feed the stored entity back through the read path (TryGetAsync → Map).
|
||||
harness.Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey);
|
||||
|
||||
Assert.NotNull(mapped);
|
||||
Assert.Equal(entry.PartitionKey, mapped!.PartitionKey);
|
||||
Assert.Equal(entry.RowKey, mapped.RowKey);
|
||||
Assert.Equal(entry.TenantId, mapped.TenantId);
|
||||
Assert.Equal(entry.EntityJson, mapped.EntityJson);
|
||||
Assert.Equal(entry.ChangedBy, mapped.ChangedBy);
|
||||
Assert.Equal(entry.Source, mapped.Source);
|
||||
Assert.Equal(entry.Reason, mapped.Reason);
|
||||
Assert.Equal(entry.DiffJson, mapped.DiffJson);
|
||||
Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_LaterTimestamps_SortFirst()
|
||||
{
|
||||
var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc));
|
||||
var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
// Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so
|
||||
// Azure's RowKey-ascending partition scan returns newest-first.
|
||||
Assert.True(string.CompareOrdinal(later, earlier) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_SameTick_DistinctViaGuidSuffix()
|
||||
{
|
||||
var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
|
||||
Assert.NotEqual(
|
||||
TableConfigBackupRepository.BuildRowKey(at),
|
||||
TableConfigBackupRepository.BuildRowKey(at));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder()
|
||||
{
|
||||
var harness = new Harness();
|
||||
// Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must
|
||||
// delete rows 3 and 4, never the two newest.
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"),
|
||||
new TableEntity(TenantId, "0003_old"),
|
||||
new TableEntity(TenantId, "0004_oldest"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_WithinKeep_DeletesNothing()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(0, deleted);
|
||||
Assert.Empty(harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByPartition_CapsAtMax()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001"),
|
||||
new TableEntity(TenantId, "0002"),
|
||||
new TableEntity(TenantId, "0003"));
|
||||
|
||||
var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2);
|
||||
|
||||
Assert.Equal(2, listed.Count);
|
||||
Assert.Equal("0001", listed[0].RowKey);
|
||||
}
|
||||
}
|
||||
@@ -192,4 +192,37 @@ public class ConfigRedactionTests
|
||||
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
|
||||
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
|
||||
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
|
||||
|
||||
// ── ?view=redacted opt-in (MCP get_tenant_config): forces redaction even for GA ──
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_OwnTenantAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsTenantAdmin = true, TenantId = TenantA, TargetTenantId = TenantA },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_NoParam_False()
|
||||
// Without the opt-in the GA clearance stands — the portal Settings UI depends on it.
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
""));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_OtherViewValue_False()
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_ReaderWithoutClearance_AlwaysTrue_RegardlessOfParam()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalReader = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(16, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins)
|
||||
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DelegatedAdminEditionGateTests
|
||||
return new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ public class DelegatedAdminEditionGateTests
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class DelegatedAdminServiceTests
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution matrix for the dual app-registration window (see EntraAppRegistry):
|
||||
/// legacy unconfigured ⇒ always primary (code deploy is a no-op before the config swap);
|
||||
/// null homing ⇒ legacy; known ids ⇒ that app; unknown ⇒ primary fallback.
|
||||
/// </summary>
|
||||
public class EntraAppRegistryTests
|
||||
{
|
||||
private const string PrimaryId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string LegacyId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
private static EntraAppRegistry Build(Dictionary<string, string?> settings) =>
|
||||
new(new ConfigurationBuilder().AddInMemoryCollection(settings).Build(),
|
||||
NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static EntraAppRegistry DualApp() => Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = LegacyId,
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
private static TenantConfiguration Tenant(string? homedAppClientId) => new()
|
||||
{
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
HomedAppClientId = homedAppClientId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LegacyUnconfigured_AlwaysResolvesPrimary_EvenForNullHoming()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(Tenant(null)).ClientId);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(null).ClientId);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(null));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal(LegacyId, resolved.ClientId);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingConfigRow_ResolvesLegacy_SameAsNullHoming()
|
||||
{
|
||||
Assert.True(DualApp().ResolveForTenant(null).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrimaryHoming_ResolvesPrimary_CaseAndBraceInsensitive()
|
||||
{
|
||||
var registry = DualApp();
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId)).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId.ToUpperInvariant())).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant("{" + PrimaryId + "}")).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(LegacyId));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownHoming_FallsBackToPrimary()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant("99999999-9999-9999-9999-999999999999"));
|
||||
Assert.False(resolved.IsLegacy);
|
||||
Assert.Equal(PrimaryId, resolved.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedLegacyClientId_TreatedAsUnconfigured()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:LegacyClientId"] = "not-a-guid",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PrimaryId, true)]
|
||||
[InlineData("api://" + PrimaryId, true)]
|
||||
[InlineData("API://" + PrimaryId, true)]
|
||||
[InlineData(LegacyId, false)]
|
||||
[InlineData("api://" + LegacyId, false)]
|
||||
[InlineData("https://graph.microsoft.com", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void IsPrimary_MatchesBareAndApiPrefixedAudiences(string? audience, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DualApp().IsPrimary(audience));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api://" + LegacyId, LegacyId)]
|
||||
[InlineData(LegacyId, LegacyId)]
|
||||
[InlineData("API://886AB5E2-6144-442C-80CC-9B28E0667731", PrimaryId)]
|
||||
[InlineData("https://graph.microsoft.com", null)] // non-GUID audience must never persist as provenance
|
||||
[InlineData("api://not-a-guid", null)]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(" ", null)]
|
||||
public void NormalizeAudience_StripsApiPrefixAndRequiresGuid(string? audience, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, EntraAppRegistry.NormalizeAudience(audience));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the edition-resolution matrix and the per-edition entitlement values of
|
||||
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
|
||||
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
|
||||
/// null/empty and unknown values all resolve to Community without any data migration.
|
||||
/// "pro" / the legacy stored value "enterprise" (or an active trial) yields Pro — the legacy
|
||||
/// stored tier "free", null/empty and unknown values all resolve to Community without any
|
||||
/// data migration.
|
||||
/// </summary>
|
||||
public class FeatureEntitlementCatalogTests
|
||||
{
|
||||
@@ -16,31 +17,34 @@ public class FeatureEntitlementCatalogTests
|
||||
// ── ResolveEdition matrix ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("enterprise")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("Pro")]
|
||||
[InlineData("PRO")]
|
||||
[InlineData(" pro ")]
|
||||
[InlineData("enterprise")] // legacy stored value — must stay readable as Pro
|
||||
[InlineData("Enterprise")]
|
||||
[InlineData("ENTERPRISE")]
|
||||
[InlineData(" enterprise ")]
|
||||
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
|
||||
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("community")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("premium")] // unknown value → fail-closed
|
||||
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
|
||||
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
|
||||
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
|
||||
}
|
||||
|
||||
@@ -59,11 +63,28 @@ public class FeatureEntitlementCatalogTests
|
||||
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")]
|
||||
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
// ── IsPermanentProTier ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", true)]
|
||||
[InlineData(" PRO ", true)]
|
||||
[InlineData("enterprise", true)] // legacy stored value
|
||||
[InlineData("free", false)]
|
||||
[InlineData("community", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPermanentProTier_Matrix(string? tier, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FeatureEntitlementCatalog.IsPermanentProTier(tier));
|
||||
}
|
||||
|
||||
// ── Entitlement values ───────────────────────────────────────────────────────
|
||||
@@ -76,20 +97,24 @@ public class FeatureEntitlementCatalogTests
|
||||
Assert.Null(e.UserRateLimitPerMinute);
|
||||
Assert.Null(e.DeviceRateLimitPerMinute);
|
||||
Assert.False(e.DelegatedAdminAllowed);
|
||||
Assert.False(e.BootstrapIncluded);
|
||||
Assert.False(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("community", e.McpUsagePlanName);
|
||||
Assert.Equal(100, e.McpDailyRequestLimit);
|
||||
Assert.Equal(3000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enterprise_Entitlements_MatchMatrix()
|
||||
public void Pro_Entitlements_MatchMatrix()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
|
||||
Assert.Equal(365, e.RetentionCapDays);
|
||||
Assert.Equal(150, e.UserRateLimitPerMinute);
|
||||
Assert.Equal(150, e.DeviceRateLimitPerMinute);
|
||||
Assert.True(e.DelegatedAdminAllowed);
|
||||
Assert.Equal("enterprise", e.McpUsagePlanName);
|
||||
Assert.True(e.BootstrapIncluded);
|
||||
Assert.True(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("pro", e.McpUsagePlanName);
|
||||
Assert.Equal(1000, e.McpDailyRequestLimit);
|
||||
Assert.Equal(20000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// — and that forces a deliberate code review of whether the new field is non-sensitive.
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
|
||||
// self-service migration banner).
|
||||
"appHomingFunnelActive",
|
||||
"bootstrapTokenEnabled",
|
||||
// Non-sensitive by review: bool only ("is a contact address set"), never the
|
||||
// address itself (admin-gated full config only).
|
||||
"contactEmailSet",
|
||||
"diagnosticsUploadConfigured",
|
||||
"edition",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
@@ -54,11 +60,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
|
||||
BootstrapTokenEnabled = true,
|
||||
ValidateAutopilotDevice = true,
|
||||
ShowScriptOutput = false,
|
||||
EnableSoftwareInventoryAnalyzer = true,
|
||||
EnableIntegrityBypassAnalyzer = false,
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
};
|
||||
|
||||
@@ -72,6 +80,55 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
// ── Effective feature gates (plan-derived) ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
|
||||
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
|
||||
{
|
||||
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
|
||||
// effective value must be false.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "free",
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro",
|
||||
UnrestrictedModeEnabled = false,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
|
||||
{
|
||||
@@ -159,12 +216,14 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_EnterpriseTier_NotTrial()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
|
||||
public void Payload_PermanentProTier_NotTrial(string storedTier)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
|
||||
@@ -178,7 +237,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var expiry = Now.AddDays(10);
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.True(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
|
||||
@@ -193,4 +252,27 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
|
||||
}
|
||||
|
||||
// ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("it-ops@contoso.com", true)]
|
||||
public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { ContactEmail = stored });
|
||||
|
||||
Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-28
@@ -327,17 +327,23 @@ public class GraphFeatureDetectorTests
|
||||
var handler = new SwappableTokenHandler(
|
||||
BuildJwt(Array.Empty<Claim>(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tokenConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var tokenService = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build());
|
||||
tokenConfig,
|
||||
new EntraAppRegistry(tokenConfig, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
|
||||
var detector = new GraphFeatureDetector(
|
||||
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
|
||||
@@ -454,6 +460,28 @@ public class GraphFeatureDetectorTests
|
||||
|
||||
// ── Test seam: token service stub + detector factory ──────────────────────────────
|
||||
|
||||
/// <summary>Minimal EntraId config for the GraphTokenService base ctor (no legacy app).</summary>
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration StubEntraConfig() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Dual app-reg dependencies for the GraphTokenService base ctor. No legacy app configured
|
||||
/// ⇒ the registry always resolves primary and the tenant-config lookup is never consulted.
|
||||
/// </summary>
|
||||
private static EntraAppRegistry StubAppRegistry() =>
|
||||
new(StubEntraConfig(), NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static AutopilotMonitor.Functions.Services.TenantConfigurationService StubTenantConfigService() =>
|
||||
new(Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// Subclass that bypasses the real AAD HTTP call so tests can drive the detector with
|
||||
/// canned <see cref="GraphTokenResult"/> values. Base ctor dependencies are satisfied
|
||||
@@ -469,13 +497,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -506,13 +530,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_firstToken = firstToken;
|
||||
_laterToken = laterToken;
|
||||
@@ -549,13 +569,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_onInvalidate = onInvalidate;
|
||||
}
|
||||
|
||||
@@ -33,15 +33,28 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the service with the dual app-reg dependencies. No legacy app is configured in
|
||||
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
|
||||
/// lookup is never consulted for the decision — a null-returning repo is fine.
|
||||
/// </summary>
|
||||
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
|
||||
new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
|
||||
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
|
||||
|
||||
@@ -148,11 +161,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
// R1 starts; its POST parks in flight (holding the gate, before writing to the cache).
|
||||
var inflight = svc.GetAccessTokenAsync(TenantA);
|
||||
@@ -188,11 +197,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => svc.GetAccessTokenAsync(TenantA)).ToArray();
|
||||
|
||||
@@ -209,6 +214,54 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, handler.Entered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenForAppAsync_caches_under_the_supplied_app_without_cross_app_poisoning()
|
||||
{
|
||||
// Dual app-reg window: the homing-flip consent probe mints under EXPLICIT primary
|
||||
// credentials for a still-legacy-homed tenant. Contract: the probe token caches under the
|
||||
// primary key, a second probe is served from cache, and the tenant's normal (legacy-
|
||||
// resolved) path still mints its own token — never the probe's.
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, "{\"access_token\":\"tok\",\"expires_in\":3599}");
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = "bbbbbbbb-0000-0000-0000-000000000002",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
})
|
||||
.Build();
|
||||
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
registry,
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal("tok", probe1.AccessToken);
|
||||
Assert.Equal("tok", probe2.AccessToken);
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA)); // second probe served from the primary-keyed cache
|
||||
|
||||
// Normal path resolves LEGACY for this tenant (no homing stored) — must be a cache miss
|
||||
// against the probe's primary-keyed entry and mint its own token.
|
||||
var homed = await svc.GetAccessTokenAsync(TenantA);
|
||||
Assert.Equal("tok", homed.AccessToken);
|
||||
Assert.Equal(2, TokenPosts(handler, TenantA));
|
||||
|
||||
// InvalidateTenant drops BOTH apps' entries — the next probe re-mints fresh.
|
||||
svc.InvalidateTenant(TenantA);
|
||||
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal(3, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -22,9 +23,11 @@ public class HealthCheckServicePoisonQueueTests
|
||||
[Fact]
|
||||
public async Task Check_AllPoisonQueuesEmpty_ReportsHealthy()
|
||||
{
|
||||
// Existing-but-drained poison queues: enumerated, probed, all zero.
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
foreach (var q in MaintenanceService.MonitoredPoisonQueues)
|
||||
probe.Counts[q] = 0;
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -32,10 +35,40 @@ public class HealthCheckServicePoisonQueueTests
|
||||
Assert.Equal("Poison Queues", check.Name);
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Equal(MaintenanceService.MonitoredPoisonQueues.Length, check.Details!.Count);
|
||||
Assert.Equal(3, check.Details!.Count);
|
||||
Assert.All(check.Details.Values, v => Assert.Equal("0 messages", v));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NoPoisonQueuesExist_ReportsHealthy()
|
||||
{
|
||||
// Steady state on a healthy system: poison queues are created lazily on
|
||||
// first poison-move, so an empty enumeration means nothing ever failed.
|
||||
var svc = BuildService(new FakePoisonQueueProbe());
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Empty(check.Details!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnumerationFails_ReportsWarningWithError()
|
||||
{
|
||||
// A failed listing must never read as an all-clear.
|
||||
var probe = new FakePoisonQueueProbe
|
||||
{
|
||||
ListException = new InvalidOperationException("auth denied")
|
||||
};
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("warning", check.Status);
|
||||
Assert.Contains("enumeration failed", check.Message);
|
||||
Assert.Contains("auth denied", (string)check.Details!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SingleQueueWithBacklog_ReportsWarning()
|
||||
{
|
||||
@@ -155,6 +188,19 @@ public class HealthCheckServicePoisonQueueTests
|
||||
{
|
||||
public Dictionary<string, long> Counts { get; } = new();
|
||||
public Dictionary<string, Exception> ExceptionFor { get; } = new();
|
||||
public Exception? ListException { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> ListPoisonQueuesAsync(CancellationToken ct)
|
||||
{
|
||||
if (ListException is not null)
|
||||
throw ListException;
|
||||
IReadOnlyList<string> names = Counts.Keys
|
||||
.Concat(ExceptionFor.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(names);
|
||||
}
|
||||
|
||||
public Task<long> GetApproximateMessageCountAsync(string queueName, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -10,7 +9,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// of the parts that decide whether an operator gets paged, without standing up
|
||||
/// the full MaintenanceService dependency graph. Full-service integration is
|
||||
/// exercised by the live timer + ops event tables — what we guard here is the
|
||||
/// classifier math, the dedup-key extractor, and the watch-list inventory.
|
||||
/// classifier math and the dedup-key extractor. The watched queues themselves are
|
||||
/// enumerated dynamically at runtime (every existing <c>-poison</c> queue), so
|
||||
/// there is no static inventory left to guard.
|
||||
/// </summary>
|
||||
public class MaintenanceServicePoisonQueueTests
|
||||
{
|
||||
@@ -116,36 +117,6 @@ public class MaintenanceServicePoisonQueueTests
|
||||
Assert.Equal(string.Empty, MaintenanceService.ExtractQueueName(detailsJson));
|
||||
}
|
||||
|
||||
// --- Watch-list inventory ---
|
||||
|
||||
[Fact]
|
||||
public void MonitoredPoisonQueues_CoversAllProducerQueues()
|
||||
{
|
||||
// Guard against silently adding a new producer queue without a poison entry.
|
||||
// If a new queue is introduced, MonitoredPoisonQueues + this assertion must move together.
|
||||
Assert.Equal(6, MaintenanceService.MonitoredPoisonQueues.Length);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.VulnerabilityCorrelate + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// PR1 critical-table backup queue (plan §Wave5 #2).
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.CriticalTableBackupPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// Self-hosted poll-loop workers (no QueueTrigger) — see SessionDeletionWorker / TenantOffboardingWorker.
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.SessionDeletion + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TenantOffboardingPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AreCalibratedForStrictMode()
|
||||
{
|
||||
|
||||
@@ -131,14 +131,14 @@ public class McpQuotaServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
|
||||
public async Task Check_ProEdition_UsesProFallbackLimits()
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("enterprise", d.Plan);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(20000, d.MonthlyLimit);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class McpUserServiceDelegatedTests
|
||||
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
|
||||
+6
-2
@@ -180,10 +180,14 @@ public class OffboardingMarkerCleanupFunctionTests
|
||||
Assert.Equal(0, result.TenantConfigsSpared);
|
||||
Assert.Equal(0, result.TenantConfigSweepRetries);
|
||||
|
||||
// The sweep ran with the right table + tenant id.
|
||||
Assert.Single(fixture.SafeWipe.WipeCalls);
|
||||
// The sweep ran with the right tables + tenant id: the tombstone itself, plus the
|
||||
// tenant's config-backup partition (a save against the lingering tombstone may have
|
||||
// re-created snapshots after the cascade's bulk wipe).
|
||||
Assert.Equal(2, fixture.SafeWipe.WipeCalls.Count);
|
||||
Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].TenantId);
|
||||
Assert.Equal(Constants.TableNames.ConfigurationBackups, fixture.SafeWipe.WipeCalls[1].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[1].TenantId);
|
||||
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -279,6 +279,20 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
Assert.Contains(Constants.TableNames.TenantGroups, harness.SafeWipeProbe.PropertyOnlyWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_ExactPartitionWipe_CoversConfigurationBackups()
|
||||
{
|
||||
// Pre-write config snapshots (PK=tenantId) must die with the tenant — the row
|
||||
// contents include webhook URLs and the contact address. The bulk wipe runs
|
||||
// before the final TenantConfiguration delete, so no snapshot can be re-created
|
||||
// afterwards by the cascade itself.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
// ── Harness (copied minimal — only what these tests need) ───────────────────
|
||||
|
||||
private sealed class Harness
|
||||
@@ -425,12 +439,14 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public int WipeCallCount { get; private set; }
|
||||
/// <summary>Table names passed to the property-only (Variant C) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> PropertyOnlyWipes { get; } = new();
|
||||
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> ExactPartitionWipes { get; } = new();
|
||||
public CountingSafeWipeService() : base(
|
||||
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
NullLogger<BlobStorageService>.Instance, usesManagedIdentity: false),
|
||||
NullLogger<SafeWipeService>.Instance) { }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
|
||||
@@ -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,76 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PlanManagementFunction.EvaluateTrialStart"/> — the self-service
|
||||
/// trial verdict, including the Pro-requires-contact gate. The check ORDER is semantic
|
||||
/// and pinned here: terminal conditions (consumed / already Pro) must win over the
|
||||
/// contact-address prompt, and the contact gate exists only at this entry point (never
|
||||
/// as a runtime lockout for existing Pro tenants).
|
||||
/// </summary>
|
||||
public class PlanManagementTrialGateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Contact = "it-ops@contoso.com";
|
||||
|
||||
[Fact]
|
||||
public void Allowed_WhenCommunityWithContactAndUnconsumedTrial()
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = Contact };
|
||||
|
||||
Assert.Null(PlanManagementFunction.EvaluateTrialStart(config, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Denied_ContactEmailRequired_WhenNoAddressStored(string? contact)
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = contact };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactEmailRequired", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_TrialAlreadyConsumed_WinsOverMissingContact()
|
||||
{
|
||||
// A consumed trial is terminal — prompting for a contact address would be pointless.
|
||||
var config = new TenantConfiguration { TrialConsumed = true, ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("TrialAlreadyConsumed", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_WinsOverMissingContact()
|
||||
{
|
||||
// Existing Pro tenants without a contact address are nagged via the dashboard
|
||||
// banner, never blocked — the verdict must name the Pro state, not the address.
|
||||
var config = new TenantConfiguration { PlanTier = "pro", ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_ForActiveTrialEdition()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TrialExpiresUtc = Now.AddDays(5),
|
||||
TrialConsumed = false, // hypothetical shape — edition resolution alone must deny
|
||||
ContactEmail = Contact,
|
||||
};
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
@@ -605,6 +605,87 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
|
||||
{
|
||||
const string upn = "ga@vendor.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantB}/fields"),
|
||||
("GET", $"/api/config/{TenantB}/backups"),
|
||||
("POST", $"/api/config/{TenantB}/revert"),
|
||||
})
|
||||
{
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, upn));
|
||||
Assert.True(result.Allowed, $"{method} {path} must admit a Global Admin");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFieldsSchema_LiteralRoute_WinsOverTenantIdTemplate()
|
||||
{
|
||||
// "fields-schema" must never be swallowed as a {tenantId} of the MemberRead config GET —
|
||||
// FindPolicy's literal-over-template precedence is what this feature's route relies on
|
||||
// (same mechanism as config/all).
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/fields-schema");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("config/fields-schema", entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldsSchema_GlobalAdminAllowed_ReaderAndTenantAdminForbidden()
|
||||
{
|
||||
var ga = BuildHarness();
|
||||
ga.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
Assert.True((await ga.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "ga@vendor.example"))).Allowed);
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
Assert.False((await reader.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "reader@vendor.example"))).Allowed);
|
||||
|
||||
var tenantAdmin = BuildHarness();
|
||||
tenantAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
Assert.False((await tenantAdmin.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "admin@contoso.com"))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
{
|
||||
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
|
||||
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantA}/fields"),
|
||||
("GET", $"/api/config/{TenantA}/backups"),
|
||||
("POST", $"/api/config/{TenantA}/revert"),
|
||||
})
|
||||
{
|
||||
var ownAdmin = BuildHarness();
|
||||
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
var readerResult = await reader.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "reader@vendor.example"));
|
||||
Assert.False(readerResult.Allowed, $"{method} {path} must deny a read-only Global Reader");
|
||||
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
|
||||
{
|
||||
@@ -672,6 +753,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed); // the delegated read itself is allowed…
|
||||
// …but no config row was persisted for the MSP's home tenant A (nor any tenant).
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the static helpers <c>PreviewWhitelistFunction.PickRequesterUpn</c>
|
||||
/// and <c>PreviewWhitelistFunction.IsRealUserUpn</c>.
|
||||
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
|
||||
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
|
||||
/// <para>
|
||||
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
|
||||
/// 10 tenants' TenantAdmins rows by writing the sentinel string
|
||||
@@ -31,7 +31,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("cadm_user@xx3t8.onmicrosoft.com")]
|
||||
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -46,7 +46,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
// The shape-based prefix check is exactly the defense against the corruption
|
||||
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
|
||||
// is rejected without anyone needing to remember to update this method.
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -57,7 +57,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("just-a-string")]
|
||||
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
|
||||
{
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -73,7 +73,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("onboarder@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,7 +85,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +97,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,8 +112,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
Assert.Equal("real.requester@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(PreviewWhitelistFunction.PickRequesterUpn(cfg)));
|
||||
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,8 +127,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
var picked = PreviewWhitelistFunction.PickRequesterUpn(cfg);
|
||||
var picked = TenantApprovalService.PickRequesterUpn(cfg);
|
||||
Assert.Equal("System (Global Rate Limit Sync)", picked);
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(picked));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -11,43 +9,24 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gate-coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — the const-false arm-flag short-circuits before any client
|
||||
/// construction. These tests instead guard the disarmed-by-default contract:
|
||||
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — every test drives one of the no-op gates. Pins:
|
||||
/// <list type="bullet">
|
||||
/// <item>The arm constant defaults to <c>false</c>, so an accidental merge cannot ship
|
||||
/// the placeholder template.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws under disarmed state —
|
||||
/// not even when the API key is empty or the recipient is null. The handler's
|
||||
/// fail-soft try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>The disarmed code path logs a debug-level "feature disarmed" line so operators
|
||||
/// have a positive signal in app insights that "the build is intentionally not
|
||||
/// sending farewell emails", as opposed to a silent regression.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
|
||||
/// is empty and not when the recipient is missing. The handler's fail-soft
|
||||
/// try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ResendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void OffboardFarewellEmailArmed_DefaultsToFalse()
|
||||
{
|
||||
// Guard the disarmed-by-design contract. Flipping this constant to true is the
|
||||
// explicit "scharf schalten" action the user does after finalising the template.
|
||||
// If this test fails, somebody flipped the flag — make sure the template, feedback
|
||||
// form, and unsubscribe story are all signed off before letting the change land.
|
||||
var field = typeof(ResendEmailService).GetField(
|
||||
"OffboardFarewellEmailArmed",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
var armed = (bool)field!.GetRawConstantValue()!;
|
||||
Assert.False(armed,
|
||||
"OffboardFarewellEmailArmed must default to false until the offboarding farewell template + feedback-form story is signed off.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_DoesNotThrow_AndLogsDisarmedDebugLine()
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
@@ -56,31 +35,11 @@ public sealed class ResendEmailServiceTests
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyApiKey_StillNoOps()
|
||||
{
|
||||
// Even with no API key configured the disarmed early-return fires FIRST — so the
|
||||
// missing-key debug line must NOT show up; only the disarmed line does. This pins
|
||||
// the gate ordering: disarmed > missing-key > missing-email.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyRecipient_StillNoOps()
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
@@ -91,11 +50,58 @@ public sealed class ResendEmailServiceTests
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RevokeEnforcementTests
|
||||
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
|
||||
new(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the merge collision guard in <see cref="AnalyzeRuleService.GetAllRulesForTenantAsync"/>
|
||||
/// and <see cref="GatherRuleService.GetAllRulesForTenantAsync"/>: a tenant-partition row whose
|
||||
/// RuleId collides with a merged global rule (legacy debris from the pre-global-partition
|
||||
/// seeding era, e.g. tenant 5ca2b350's ANALYZE-ID-002 copy from 2026-03) must be skipped —
|
||||
/// the global definition wins. Without the guard the same RuleId appears twice in the merged
|
||||
/// list and every ToDictionary(r => r.RuleId) consumer throws (prod: ArgumentException in
|
||||
/// AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync, killing rule notifications).
|
||||
/// </summary>
|
||||
public class RuleMergeCollisionGuardTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInAnalyzeRules.GetAll().First().RuleId;
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
// The exact downstream consumer that crashed in prod must work again.
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInGatherRules.GetAll().First().RuleId;
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static AnalyzeRule StaleTenantAnalyzeCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule StaleTenantGatherCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Category = "test",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\X",
|
||||
Trigger = "startup",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
/// <summary>DB state = exactly the shipped catalog (clean seed diff), plus the given tenant rows.</summary>
|
||||
private static Mock<IRuleRepository> CreateAnalyzeRepo(List<AnalyzeRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(BuiltInAnalyzeRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
|
||||
private static Mock<IRuleRepository> CreateGatherRepo(List<GatherRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(BuiltInGatherRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteGatherRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user