mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33d85c01d | |||
| 4e73ba8900 | |||
| 4df52b3da9 | |||
| 844d86410c | |||
| 9514e1033d | |||
| b2236bf65f | |||
| 31535b07e0 | |||
| 066b6e6f04 | |||
| a1e2b76a9f | |||
| 29e5b8251d | |||
| 02168a5c55 | |||
| a6b6661320 | |||
| 0c72666a7a | |||
| 9023321db9 | |||
| 7ef097529c | |||
| e7df69efe4 | |||
| 2c7efa2733 | |||
| 2742549827 | |||
| 3c45f5cb09 | |||
| d5d1d7bfbd | |||
| f4e2088b69 | |||
| 55425058a8 | |||
| eec0d17607 | |||
| 43f5304ba4 | |||
| b21472dd8c | |||
| e3991d55be | |||
| 864d1ed31e | |||
| 76ef1e53ca | |||
| 7dd298f04e | |||
| 66452621c6 | |||
| 0b23d93957 | |||
| 4281a6d81b | |||
| fe919a51aa | |||
| 0dcf0eb503 | |||
| e2fb99d5d8 | |||
| a3ee613b60 | |||
| d2197c6a57 | |||
| e5997bc50e | |||
| 75138016ce | |||
| fc4772efb0 | |||
| 0bde428fa0 | |||
| 16ca3a059c | |||
| 1bfc80e6db | |||
| fa705a2ef6 | |||
| 81f807e124 | |||
| a4f7c88785 | |||
| 54259f070f | |||
| 26cef3ab06 | |||
| 00ad230ffe | |||
| 3d920a8113 | |||
| f6eac1964a | |||
| e1b5b9d163 | |||
| 094833f831 | |||
| 3579ce0add | |||
| 0188860d1c | |||
| 871a1ac059 | |||
| 298a182fbb | |||
| e71223507c | |||
| 02187f0f3c | |||
| 3e2f62daeb | |||
| c8b8a8dbb4 | |||
| 9e4c5f11c5 | |||
| b16e72c10e | |||
| 5cb9b0b30b | |||
| c925001a8a | |||
| e494334720 | |||
| 1554540431 | |||
| fef09690d2 | |||
| 3a975fa74f |
+1
-1
Submodule .claude/commands updated: e8b8c08c1b...e57da4e431
@@ -40,6 +40,23 @@ jobs:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# The customer docs repo is the single source of the "What's new" panel: the prebuild
|
||||
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
|
||||
# public/whats-new.json and dates every bullet via git blame — hence the full history.
|
||||
# Public repo, no token. Editing the docs does NOT update the live panel until this
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: whats-new-docs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve docs commit
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
@@ -51,7 +68,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
env:
|
||||
@@ -68,9 +85,12 @@ jobs:
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
@@ -136,7 +156,8 @@ jobs:
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
|
||||
# docs revision the live What's new panel was generated from (as for the MCP corpus).
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -145,6 +166,7 @@ jobs:
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
|
||||
@@ -145,6 +145,10 @@ internal/docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs
|
||||
# checkout on every build and served at /whats-new.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/whats-new.json
|
||||
|
||||
|
||||
# Local IME installer scratch (MSI + extracted payload per version, for grepping
|
||||
# in binaries). Operator-only, never committed.
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
# <img src=".github/assets/logo.svg" height="28" alt="" /> Autopilot Monitor
|
||||
# <img src=".github/assets/logo.svg" height="28" alt="Autopilot Monitor logo" /> Autopilot Monitor
|
||||
|
||||
[](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.
|
||||
Autopilot Monitor is an open-source, real-time monitoring and troubleshooting platform for **Windows Autopilot** and **Microsoft Intune** enrollment. It records enrollment events, application installations, policies, device state, and diagnostics while provisioning is still running, instead of collecting logs by hand after a failure. Administrators get live enrollment tracking, Enrollment Status Page (ESP) visibility, detailed session timelines, automated issue detection, on-demand diagnostics, fleet health analytics, and AI-assisted troubleshooting through a hosted Model Context Protocol (MCP) server.
|
||||
|
||||
The platform supports classic Windows Autopilot and modern Intune provisioning scenarios, including **Windows Autopilot Device Preparation**.
|
||||
|
||||
## Availability
|
||||
|
||||
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=".github/assets/SessionList.png" width="45%" />
|
||||
<img src=".github/assets/FleetHealth.png" width="45%" />
|
||||
<img src=".github/assets/SessionList.png" width="45%" alt="Autopilot Monitor dashboard showing live Windows Autopilot and Intune enrollment sessions" />
|
||||
<img src=".github/assets/FleetHealth.png" width="45%" alt="Autopilot Monitor fleet health dashboard with Windows Autopilot enrollment analytics and success metrics" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src=".github/assets/SessionDetails.png" width="45%" />
|
||||
<img src=".github/assets/SessionTimeline.png" width="45%" />
|
||||
<img src=".github/assets/SessionDetails.png" width="45%" alt="Autopilot Monitor session details showing device information, diagnostics, apps, policies, and enrollment status" />
|
||||
<img src=".github/assets/SessionTimeline.png" width="45%" alt="Windows Autopilot enrollment timeline showing ESP phases, application installs, policies, events, and errors" />
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
## Key Features
|
||||
|
||||
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
|
||||
- **Real-time Windows Autopilot monitoring** — follow device provisioning while enrollment is still running
|
||||
- **Detailed enrollment timelines** — correlate Autopilot, ESP, Intune Management Extension, apps, policies, device state, and system events
|
||||
- **Enrollment Status Page visibility** — see which phase, application, or policy is delaying provisioning
|
||||
- **Automated issue detection** — define custom analysis rules that identify known failure patterns
|
||||
- **Targeted evidence collection** — gather registry values, event log entries, files, log-parser matches, and allow-listed commands when specific conditions are detected
|
||||
- **On-demand diagnostics** — request diagnostics packages from enrolled devices remotely
|
||||
- **Fleet health analytics** — success rates, deployment duration, device models, applications, errors, and recurring issues
|
||||
- **Session reporting** — inspect and share detailed enrollment information for individual devices
|
||||
- **Windows Autopilot Device Preparation support** — monitor Device Preparation deployments alongside classic Autopilot scenarios
|
||||
- **Centralized agent configuration** — control diagnostic behavior, automatic reboots, timezone adjustment, and other monitoring settings from the web dashboard
|
||||
- **AI-assisted troubleshooting** — query enrollment data through the built-in Model Context Protocol server
|
||||
|
||||
- **Bootstrap Script** — PowerShell script deployed via Intune that starts monitoring early in the enrollment process
|
||||
- **Monitoring Agent** — Lightweight .NET application that collects telemetry and evidence during enrollment
|
||||
## How it works
|
||||
|
||||
Autopilot Monitor consists of several components working together throughout Windows enrollment:
|
||||
|
||||
- **Bootstrap Script** — PowerShell script deployed through Microsoft Intune that starts monitoring early in the provisioning process
|
||||
- **Monitoring Agent** — lightweight .NET application that collects telemetry and evidence during enrollment
|
||||
- **Backend API** — Azure Functions-based ingestion and processing pipeline
|
||||
- **Web Dashboard** — Next.js application for real-time monitoring and fleet analytics
|
||||
- **MCP Server** — Model Context Protocol server that lets AI assistants query and troubleshoot enrollments in natural language
|
||||
- **Web Dashboard** — Next.js application for real-time monitoring, troubleshooting, and fleet analytics
|
||||
- **MCP Server** — Model Context Protocol server that lets AI assistants query Autopilot Monitor data in natural language
|
||||
|
||||
## Windows Autopilot and Intune Troubleshooting
|
||||
|
||||
Autopilot Monitor helps investigate common Windows provisioning and enrollment problems such as:
|
||||
|
||||
- Windows Autopilot deployments that fail or appear stuck
|
||||
- Enrollment Status Page (ESP) delays and unusually long deployment phases
|
||||
- Win32 application installation failures and slow application downloads
|
||||
- Intune Management Extension problems
|
||||
- Microsoft Entra join, Hybrid Join, and device registration issues
|
||||
- policies that block or delay provisioning
|
||||
- inconsistent behavior across hardware models and recurring failures affecting multiple devices
|
||||
|
||||
Because telemetry is collected during enrollment, many problems can be investigated without reproducing the issue or manually collecting logs from the affected device.
|
||||
|
||||
## AI Integration (MCP)
|
||||
|
||||
@@ -35,13 +65,19 @@ Autopilot Monitor ships a hosted **[Model Context Protocol](https://modelcontext
|
||||
|
||||
- *"Show me all failed enrollments from the last 24 hours and group them by failure reason."*
|
||||
- *"Why did session X fail, and which app install caused the delay?"*
|
||||
- *"Which devices had unusually long ESP application phases this week?"*
|
||||
- *"Which device models have the highest Autopilot failure rate?"*
|
||||
- *"Which devices in my fleet are affected by CVE-2024-30078?"*
|
||||
|
||||
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and no credentials are stored. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
|
||||
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and the MCP server stores no credentials. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)** — deployment, configuration, monitoring, diagnostics, troubleshooting, analysis and gather rules, integrations, and AI-assisted troubleshooting.
|
||||
|
||||
## Open Source
|
||||
|
||||
Autopilot Monitor is developed openly on GitHub. The source is published so that customers and security reviewers can see what runs on their devices and with their data: the repository contains the device agent, shared library, backend, web dashboard, and MCP server.
|
||||
|
||||
## Feedback
|
||||
|
||||
|
||||
+1
-1
Submodule internal updated: 54efd666b0...41d3f15126
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -93,7 +93,7 @@
|
||||
"steps": [
|
||||
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
|
||||
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-018",
|
||||
"title": "App Return Code Requested a Reboot During Enrollment",
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "in",
|
||||
"value": "SoftReboot,HardReboot",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "equals",
|
||||
"value": "HardReboot",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"condition": "exists",
|
||||
"weight": 25
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the app's return-code mapping",
|
||||
"steps": [
|
||||
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
|
||||
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
|
||||
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
|
||||
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Take the app out of the ESP critical path",
|
||||
"steps": [
|
||||
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
|
||||
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
|
||||
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/app-management/deployment/add-win32#step-2-program"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"return-code",
|
||||
"soft-reboot",
|
||||
"hard-reboot",
|
||||
"esp",
|
||||
"second-sign-in"
|
||||
]
|
||||
}
|
||||
+99
-2
@@ -615,7 +615,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -703,7 +703,7 @@
|
||||
"steps": [
|
||||
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
|
||||
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1034,6 +1034,103 @@
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-018",
|
||||
"title": "App Return Code Requested a Reboot During Enrollment",
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "in",
|
||||
"value": "SoftReboot,HardReboot",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "equals",
|
||||
"value": "HardReboot",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"condition": "exists",
|
||||
"weight": 25
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the app's return-code mapping",
|
||||
"steps": [
|
||||
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
|
||||
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
|
||||
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
|
||||
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Take the app out of the ESP critical path",
|
||||
"steps": [
|
||||
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
|
||||
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
|
||||
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/app-management/deployment/add-win32#step-2-program"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"return-code",
|
||||
"soft-reboot",
|
||||
"hard-reboot",
|
||||
"esp",
|
||||
"second-sign-in"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
|
||||
+11
@@ -339,6 +339,17 @@
|
||||
"description": "Captures the installer exit code (lpExitCode) for the current app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-CLASS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as (?<exitCodeClass>Success|SoftReboot|HardReboot|Retry|Failed)$",
|
||||
"action": "captureExitCodeClass",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Captures the admin-defined return-code class of the installer exit code for the current app ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs, enum AppReturnCodeType - literal identical from IME 1.50 through 1.105). Stored as exitCodeClass on the app and emitted on its terminal events; the last value wins, so a Retry loop that ends in Success reports Success. Anchored on both ends on purpose: the no-mapping warning ('... defined as <n>, fall into default now.') and an unparsable numeric type print a raw number and must not match. Companion of IME-EXITCODE (the number, logged just before) and IME-EXITCODE-MAPPED-FAILED (the Failed state transition), which still owns the error path.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -282,5 +282,25 @@
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
],
|
||||
"imeLogDerivedEventTypes": [
|
||||
"app_install_started",
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
"do_telemetry",
|
||||
"script_started",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
"script_timeout_suspected",
|
||||
"esp_phase_changed",
|
||||
"ime_agent_version",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ime_process_exited",
|
||||
"ime_user_token_acquired"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE-CLASS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as (?<exitCodeClass>Success|SoftReboot|HardReboot|Retry|Failed)$",
|
||||
"action": "captureExitCodeClass",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Captures the admin-defined return-code class of the installer exit code for the current app ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs, enum AppReturnCodeType - literal identical from IME 1.50 through 1.105). Stored as exitCodeClass on the app and emitted on its terminal events; the last value wins, so a Retry loop that ends in Success reports Success. Anchored on both ends on purpose: the no-mapping warning ('... defined as <n>, fall into default now.') and an unparsable numeric type print a raw number and must not match. Companion of IME-EXITCODE (the number, logged just before) and IME-EXITCODE-MAPPED-FAILED (the Failed state transition), which still owns the error path.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -114,6 +114,11 @@
|
||||
"description": "Emit behavior: \"always\" (default — emit on every collection) or \"on_change\" (poll on the trigger cadence but emit only when the collected result changes; the first in-scope result always emits).",
|
||||
"enum": ["always", "on_change"]
|
||||
},
|
||||
"enrichErrorCodes": {
|
||||
"type": "boolean",
|
||||
"description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).",
|
||||
"default": false
|
||||
},
|
||||
"outputEventType": {
|
||||
"type": "string",
|
||||
"description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")."
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"blockedFilePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"blockedCommandPatterns": { "$ref": "#/$defs/stringList" },
|
||||
"blockedInterimTriggerEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"imeLogDerivedEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"maxCommandLength": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"captureAppTypeWinGet",
|
||||
"captureAppTypeMsi",
|
||||
"captureAttemptNumber",
|
||||
"captureDetectionResult"
|
||||
"captureDetectionResult",
|
||||
"captureExitCodeClass"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -142,6 +142,7 @@ if (fs.existsSync(guardrailsPath)) {
|
||||
assertStringList(guardrails[key], key);
|
||||
}
|
||||
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
|
||||
assertStringList(guardrails.imeLogDerivedEventTypes ?? [], 'imeLogDerivedEventTypes');
|
||||
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
|
||||
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Error-code catalog generator
|
||||
|
||||
`generate.mjs` rebuilds `src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json`
|
||||
(schemaVersion 2) from primary sources and refreshes the web copy through
|
||||
`src/Web/autopilot-monitor-web/scripts/sync-error-codes.js`. The catalog is never edited by
|
||||
hand: a wrong or missing entry is fixed in the generator (a source, a pin, a family rule) and
|
||||
regenerated, so the next regeneration cannot lose the fix.
|
||||
|
||||
```
|
||||
IME_ENUMS_JSON=<path> node scripts/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
```
|
||||
|
||||
- `IME_ENUMS_JSON` — enum extract of one Intune Management Extension build (`imeVersion`,
|
||||
`customErrorCodes`, `enforcementStates`, `msiRetriableErrorCodes`, `deliveryOptimization`).
|
||||
Produced by operator tooling; the generator only reads the JSON.
|
||||
- `--cache-dir DIR` — keeps the fetched pages as files for offline reruns.
|
||||
- `--check` — exits 1 when the committed catalog differs from the generator output.
|
||||
|
||||
## Entry shape
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| key | lowercase 8-digit hex (`0x80070005`) or a decimal MSI exit code (`1603`). Decimal keys exist only for `0`, `1601`–`1654` and `3010`: exit codes of other installers are author-defined. Win32 codes live only as `0x8007xxxx`; a `HRESULT_FROM_WIN32` value whose low word is in the MSI range resolves to the decimal entry at lookup time (`derivedFromWin32`). |
|
||||
| `description` | one line, taken from the source (see rules below) |
|
||||
| `confidence` | `high` = MS Learn table/page or the IME build named in `source`; `medium` = paraphrase of an enum member name or of an analysis rule; `low` = carried over from the v1 catalog without a primary source |
|
||||
| `source` | `msdoc:<learn.microsoft.com URL>` · `ime:<version>` · `winerror.h` · `rule:<RULE-ID>` · `legacy-catalog-v1` |
|
||||
| `category` | `msi` `win32` `com` `appx` `windows-update` `cbs` `intune-win32` `intune-mobile` `mdm-enrollment` `autopilot` `entra-join` `delivery-optimization` |
|
||||
| `symbol` | optional: the `winerror.h`/WU/AppX symbol, or the IME enum member name |
|
||||
| `imeRetriesDuringEsp` | optional: one of the MSI return codes the IME retries automatically during the Autopilot ESP |
|
||||
|
||||
`enforcementStates` is a second map: IME app enforcement state number → `{ name, description }`.
|
||||
|
||||
## Sources and family rules
|
||||
|
||||
Families are processed in this order; the first family that yields a key wins and later
|
||||
duplicates are reported on stderr.
|
||||
|
||||
| Family | Source | Rule |
|
||||
|---|---|---|
|
||||
| win32 | [System Error Codes](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes) (per-range pages), [WinHTTP error messages](https://learn.microsoft.com/en-us/windows/win32/winhttp/error-messages) | Seed = codes seen in the field (`WIN32_FIELD_SEEN`) ∪ the IME MSI-retry list ∪ every `0x8007xxxx` code another family names, minus the MSI decimal range and the AppX page's own range. Symbol and text verbatim. A seed code the reference does not list fails the run. |
|
||||
| msi | [Windows Installer error codes](https://learn.microsoft.com/en-us/windows/win32/msi/error-codes) | Rows in the MSI decimal range only; verbatim. |
|
||||
| com | `winerror.h` | Hand-listed HRESULTs that are not Win32-derived (`S_OK`, `E_FAIL`, `E_UNEXPECTED`, HTTP/RPC status HRESULTs the Delivery Optimization client names). |
|
||||
| appx | [Troubleshooting packaging, deployment, and query of Windows apps](https://learn.microsoft.com/en-us/windows/win32/appxpkg/troubleshooting) | Whole table; first sentence of the cell. Authority for its own `0x80073Cxx`/`0x80073Dxx` range. |
|
||||
| windows-update | [Windows Update error codes by component](https://learn.microsoft.com/en-us/windows/deployment/update/windows-update-error-reference) | Sections an enrollment can meet (agent core, handlers, download manager, protocol talker, data store, drivers, MSI handler, agent setup, AU client); verbatim. |
|
||||
| windows-update / cbs | [Windows Update common errors](https://learn.microsoft.com/en-us/windows/deployment/update/windows-update-errors) | One table per single-code heading; symbol = text before `;` in the Message cell, description = text after it, else the Description cell. `0x800F…` → `cbs`. |
|
||||
| intune-mobile / intune-win32 | [Intune app installation error codes](https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/app-install-error-codes) | iOS/iPadOS and Windows tables (Android skipped); description = the message cell. |
|
||||
| mdm-enrollment / autopilot / entra-join | [Windows enrollment errors](https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/troubleshoot-windows-enrollment-errors), [Autopilot known issues](https://learn.microsoft.com/en-us/autopilot/known-issues) | Prose pages: one-sentence paraphrase listed in `PROSE_ENTRIES`; the generator asserts the code appears on the page. Codes only our analysis rules name carry `rule:` and `medium`. |
|
||||
| intune-win32 (`0x87D3xxxx`) | IME `CustomErrorCodes` enum | Member name as symbol, humanised as description, `medium`. Two known sign/digit defects in the enum source are corrected by `IME_ENUM_FIXUPS`; a build that fixes them makes the run fail so the fixup is removed. |
|
||||
| delivery-optimization (`0x80D0xxxx`) | IME `DeliveryOptimizationConstants` | Member name as symbol, humanised, `medium`. |
|
||||
| legacy | v1 catalog | Entries no primary source covers, `low`. |
|
||||
|
||||
Description rules: whole sentences up to 200 characters, cross-reference sentences
|
||||
("For more information, see …") dropped, no trailing period. `DESCRIPTION_PINS` holds the
|
||||
few wordings consumers depend on (each says the same as the source row it replaces).
|
||||
|
||||
## Regeneration checklist
|
||||
|
||||
1. Run the generator (with `--cache-dir` when iterating).
|
||||
2. Read the stderr summary: entry count per family, dropped duplicates.
|
||||
3. `git diff` the catalog; a removed key needs a reason (the C#, web and MCP tests pin the
|
||||
keys the product relies on).
|
||||
4. Spot-check ten random entries per family against the source page.
|
||||
5. Build and run the Backend, Web and MCP test suites.
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Error-code catalog generator (schemaVersion 2).
|
||||
*
|
||||
* Rebuilds src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json from primary
|
||||
* sources and writes the web copy through the web sync script. Every entry carries the
|
||||
* source it was taken from; nothing is typed in from memory.
|
||||
*
|
||||
* node scripts/error-codes/generate.mjs [--cache-dir DIR] [--check]
|
||||
*
|
||||
* IME_ENUMS_JSON required — path to the enum extract of one Intune Management Extension
|
||||
* build (customErrorCodes, enforcementStates, msiRetriableErrorCodes,
|
||||
* deliveryOptimization). Produced by operator tooling; the generator has
|
||||
* no notion of where the enums come from.
|
||||
* --cache-dir DIR read/write the fetched MS Learn pages as files in DIR (offline reruns).
|
||||
* --check do not write; exit 1 when the committed catalog differs from the output.
|
||||
*
|
||||
* Families, in precedence order (the first family that yields a key wins; later duplicates
|
||||
* are reported and dropped):
|
||||
* win32 MS "System Error Codes" + WinHTTP error page, keyed 0x8007xxxx
|
||||
* msi MS "Windows Installer error codes", decimal keys 0 / 1601-1654 / 3010
|
||||
* com winerror.h HRESULTs that are not Win32-derived (S_OK, E_FAIL, ...)
|
||||
* appx MS "Troubleshooting packaging, deployment, and query of Windows apps"
|
||||
* windows-update MS "Windows Update error codes by component" (reference tables)
|
||||
* cbs / windows-update MS "Windows Update common errors and mitigation" (per-code tables)
|
||||
* intune-* MS "Intune app installation error codes" (iOS + Windows tables)
|
||||
* mdm-enrollment/ MS enrollment troubleshooting + Autopilot known-issues pages (prose:
|
||||
* autopilot/entra-join one-sentence paraphrase, presence-checked against the page)
|
||||
* intune-win32 IME CustomErrorCodes enum (0x87D3xxxx), member name paraphrased
|
||||
* delivery-optimization IME DeliveryOptimizationConstants (0x80D0xxxx), member name paraphrased
|
||||
* legacy v1 entries without a primary source, kept at confidence "low"
|
||||
*
|
||||
* See README.md next to this file for the source list and the description rules.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, "..", "..");
|
||||
const TARGET = path.join(REPO_ROOT, "src", "Shared", "AutopilotMonitor.Shared", "Resources", "error-codes.json");
|
||||
const WEB_SYNC = path.join(REPO_ROOT, "src", "Web", "autopilot-monitor-web", "scripts", "sync-error-codes.js");
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cacheDir = args.includes("--cache-dir") ? args[args.indexOf("--cache-dir") + 1] : undefined;
|
||||
const checkOnly = args.includes("--check");
|
||||
const imeEnumsPath = process.env.IME_ENUMS_JSON;
|
||||
if (!imeEnumsPath || !existsSync(imeEnumsPath)) {
|
||||
console.error("IME_ENUMS_JSON must point to the IME enum extract (see README.md).");
|
||||
process.exit(2);
|
||||
}
|
||||
const ime = JSON.parse(readFileSync(imeEnumsPath, "utf8"));
|
||||
if (!/^\d+(\.\d+)+$/.test(String(ime.imeVersion ?? ""))) {
|
||||
console.error("IME enum extract has no imeVersion.");
|
||||
process.exit(2);
|
||||
}
|
||||
const IME_SOURCE = `ime:${ime.imeVersion}`;
|
||||
|
||||
// ── Vocabulary (mirrored by the C#, web and MCP tests) ───────────────────────
|
||||
|
||||
export const CATEGORIES = [
|
||||
"msi", "win32", "com", "appx", "windows-update", "cbs", "intune-win32", "intune-mobile",
|
||||
"mdm-enrollment", "autopilot", "entra-join", "delivery-optimization",
|
||||
];
|
||||
export const CONFIDENCES = ["high", "medium", "low"];
|
||||
export const SOURCE_PATTERN = /^(msdoc:https:\/\/learn\.microsoft\.com\/[^\s]+|ime:\d+(\.\d+)+|winerror\.h|rule:[A-Z]+-[A-Z]+-\d{3}|legacy-catalog-v1)$/;
|
||||
export const KEY_PATTERN = /^(0x[0-9a-f]{8}|\d+)$/;
|
||||
// winerror-style (ERROR_INSTALL_FAILURE) or an enum member (UnzipError); never a phrase.
|
||||
const SYMBOL_PATTERN = /^[A-Za-z][A-Za-z0-9_]{2,}$/;
|
||||
const MAX_DESCRIPTION = 200;
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const URL = {
|
||||
msi: "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes",
|
||||
appx: "https://learn.microsoft.com/en-us/windows/win32/appxpkg/troubleshooting",
|
||||
wuReference: "https://learn.microsoft.com/en-us/windows/deployment/update/windows-update-error-reference",
|
||||
wuErrors: "https://learn.microsoft.com/en-us/windows/deployment/update/windows-update-errors",
|
||||
intuneApps: "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/app-install-error-codes",
|
||||
enrollErrors: "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/troubleshoot-windows-enrollment-errors",
|
||||
knownIssues: "https://learn.microsoft.com/en-us/autopilot/known-issues",
|
||||
winhttp: "https://learn.microsoft.com/en-us/windows/win32/winhttp/error-messages",
|
||||
win32: (range) => `https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--${range}-`,
|
||||
};
|
||||
const WIN32_RANGES = [[0, 499], [500, 999], [1000, 1299], [1300, 1699], [1700, 3999], [4000, 5999], [6000, 8199], [8200, 8999], [9000, 11999], [12000, 15999]];
|
||||
const WINHTTP_RANGE = [12000, 12200];
|
||||
|
||||
const msdoc = (url) => `msdoc:${url}`;
|
||||
|
||||
const pageCache = new Map();
|
||||
async function fetchPage(url) {
|
||||
if (pageCache.has(url)) return pageCache.get(url);
|
||||
const cacheFile = cacheDir ? path.join(cacheDir, url.replace(/^https?:\/\//, "").replace(/[^a-z0-9.-]+/gi, "_") + ".html") : null;
|
||||
let html;
|
||||
if (cacheFile && existsSync(cacheFile)) {
|
||||
html = readFileSync(cacheFile, "utf8");
|
||||
} else {
|
||||
const res = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 (error-code catalog generator)" } });
|
||||
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
|
||||
html = await res.text();
|
||||
if (cacheFile) {
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
writeFileSync(cacheFile, html, "utf8");
|
||||
}
|
||||
}
|
||||
pageCache.set(url, html);
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── HTML helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'" };
|
||||
|
||||
/**
|
||||
* Cell markup → plain text. Tags are stripped until none remain (a nested "<scr<b>ipt>" cannot
|
||||
* survive), and entities are decoded in ONE pass so "&lt;" yields "<", never "<".
|
||||
*/
|
||||
const decode = (s) => {
|
||||
let text = s.replace(/<br\s*\/?>/gi, " ").replace(/<\/(p|li|div)>/gi, " ");
|
||||
let previous;
|
||||
do {
|
||||
previous = text;
|
||||
text = text.replace(/<[^>]*>/g, "");
|
||||
} while (text !== previous);
|
||||
return text
|
||||
.replace(/&(nbsp|amp|lt|gt|quot|#39|#x27);/g, (_, name) => ENTITIES[name])
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
/** Every <table> of a page as rows of decoded cell strings, in document order. */
|
||||
function parseTables(html) {
|
||||
return [...html.matchAll(/<table[\s\S]*?<\/table>/gi)].map((t) =>
|
||||
[...t[0].matchAll(/<tr[\s\S]*?<\/tr>/gi)]
|
||||
.map((r) => [...r[0].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((c) => decode(c[1])))
|
||||
.filter((cells) => cells.length > 0));
|
||||
}
|
||||
|
||||
/** h2/h3 headings and the table that follows each, in order (the "one table per code" pages). */
|
||||
function parseHeadedTables(html) {
|
||||
const out = [];
|
||||
const seq = [...html.matchAll(/<h([23])[^>]*>([\s\S]*?)<\/h\1>|<table[\s\S]*?<\/table>/gi)];
|
||||
let heading = null;
|
||||
for (const m of seq) {
|
||||
if (m[0].startsWith("<h")) { heading = decode(m[2]); continue; }
|
||||
if (!heading) continue;
|
||||
const rows = [...m[0].matchAll(/<tr[\s\S]*?<\/tr>/gi)]
|
||||
.map((r) => [...r[0].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((c) => decode(c[1])));
|
||||
out.push({ heading, rows });
|
||||
heading = null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* winerror.h-style reference pages: `<strong>SYMBOL</strong>` followed by a paragraph with the
|
||||
* decimal value (optionally "(0x…)") and a paragraph with the text. Shape shared by the
|
||||
* System Error Codes pages and the WinHTTP error page.
|
||||
*/
|
||||
function parseSymbolPage(html) {
|
||||
const map = new Map();
|
||||
const re = /<strong>([A-Z][A-Z0-9_]+)<\/strong><\/p>(?:\s|<\/?(?:dt|dd|dl)>)*<p>(\d+)(?:\s*\(0x[0-9A-Fa-f]+\))?<\/p>(?:\s|<\/?(?:dt|dd|dl)>)*<p>([\s\S]*?)<\/p>/g;
|
||||
for (const m of html.matchAll(re)) {
|
||||
const value = Number(m[2]);
|
||||
if (!map.has(value)) map.set(value, { symbol: m[1], text: decode(m[3]) });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Description shaping ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One short line per code: whole sentences from the source text up to MAX_DESCRIPTION
|
||||
* characters (at least one sentence; a single over-long sentence is cut with an ellipsis).
|
||||
* Cross-reference sentences ("For more information, see …", "See …") are dropped. Pages whose
|
||||
* cell continues with troubleshooting prose pass `firstSentence` and keep only the first one.
|
||||
*/
|
||||
export function shapeDescription(text, { firstSentence = false } = {}) {
|
||||
const clean = String(text).replace(/\s+/g, " ").trim();
|
||||
const sentences = clean.split(/(?<=[.!?])\s+(?=[A-Z0-9("'])/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !/^(For (more )?information|See |Refer to)/i.test(s));
|
||||
const kept = [];
|
||||
let length = 0;
|
||||
for (const s of sentences) {
|
||||
if (kept.length > 0 && (firstSentence || length + 1 + s.length > MAX_DESCRIPTION)) break;
|
||||
kept.push(s);
|
||||
length += (kept.length > 1 ? 1 : 0) + s.length;
|
||||
}
|
||||
let result = kept.join(" ") || clean;
|
||||
if (result.length > MAX_DESCRIPTION + 20) result = result.slice(0, MAX_DESCRIPTION).trimEnd() + "…";
|
||||
result = result.replace(/\.$/, "");
|
||||
return result.charAt(0).toUpperCase() + result.slice(1);
|
||||
}
|
||||
|
||||
/** "InvalidDetectionRuleUnknownDetectionType" → "Invalid detection rule: unknown detection type". */
|
||||
export function humanizeMember(name) {
|
||||
const PREFIXES = ["InvalidDetectionRule", "InvalidRequirementRule", "AppLogUpload", "NotAttempted", "InProgress", "SuccessBut", "Success", "Error", "ResumeJobFailure"];
|
||||
const ACRONYMS = { CDN: "CDN", DO: "DO", SAS: "SAS", MSI: "MSI", VPP: "VPP", IOS: "iOS", TPM: "TPM", MDM: "MDM", HTTP: "HTTP", URL: "URL", COM: "COM", APK: "APK", CP: "CP", ID: "ID" };
|
||||
const words = (s) => s
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.split(" ")
|
||||
.map((w) => ACRONYMS[w.toUpperCase()] ?? w.toLowerCase());
|
||||
const prefix = PREFIXES.find((p) => name.startsWith(p) && name.length > p.length);
|
||||
if (prefix) {
|
||||
const head = words(prefix);
|
||||
const tail = words(name.slice(prefix.length));
|
||||
return capitalize(head.join(" ")) + ": " + tail.join(" ");
|
||||
}
|
||||
return capitalize(words(name).join(" "));
|
||||
}
|
||||
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
// ── Key helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const hexKey = (n) => "0x" + ((n >>> 0).toString(16)).padStart(8, "0");
|
||||
const fromSigned = (n) => hexKey(n < 0 ? n + 0x100000000 : n);
|
||||
const normalizeHex = (raw) => hexKey(parseInt(raw.replace(/^0x/i, ""), 16));
|
||||
const isFacility7 = (key) => key.startsWith("0x8007");
|
||||
const MSI_DECIMAL = (n) => n === 0 || (n >= 1601 && n <= 1654) || n === 3010;
|
||||
|
||||
// ── Families ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Win32 codes that the field has shown as 0x8007xxxx HRESULTs (beyond what other families name). */
|
||||
const WIN32_FIELD_SEEN = [
|
||||
1, 2, 3, 4, 5, 6, 8, 13, 14, 23, 32, 50, 53, 64, 67, 87, 112, 120, 122, 258, 314, 995, 1053, 1058, 1067,
|
||||
1114, 1168, 1203, 1219, 1220, 1222, 1223, 1231, 1232, 1235, 1259, 1311, 1312, 1326, 1327, 1355, 1385,
|
||||
1392, 1450, 1460, 1717, 1722, 1726, 1780, 1784, 1789, 1908, 1909, 3017, 8239,
|
||||
12002, 12007, 12029, 12030, 12152, 12175, 14081, 14107,
|
||||
];
|
||||
|
||||
/** HRESULTs that are not Win32-derived; text as in winerror.h. */
|
||||
const COM_ENTRIES = [
|
||||
["0x00000000", "S_OK", "Success"],
|
||||
["0x80004001", "E_NOTIMPL", "Not implemented"],
|
||||
["0x80004002", "E_NOINTERFACE", "No such interface supported"],
|
||||
["0x80004003", "E_POINTER", "Invalid pointer"],
|
||||
["0x80004004", "E_ABORT", "Operation aborted"],
|
||||
["0x80004005", "E_FAIL", "Unspecified error"],
|
||||
["0x8000ffff", "E_UNEXPECTED", "Catastrophic failure"],
|
||||
["0x80010108", "RPC_E_DISCONNECTED", "The object invoked has disconnected from its clients"],
|
||||
["0x80020005", "DISP_E_TYPEMISMATCH", "Type mismatch"],
|
||||
["0x80190193", "HTTP_E_STATUS_FORBIDDEN", "Forbidden (403)"],
|
||||
["0x80190194", "HTTP_E_STATUS_NOT_FOUND", "Not found (404)"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Codes documented on prose pages (no table to parse). The generator asserts that the code
|
||||
* appears on the named page; the description is a one-sentence paraphrase of that page.
|
||||
*/
|
||||
const PROSE_ENTRIES = [
|
||||
{ key: "0x80180014", category: "mdm-enrollment", page: URL.enrollErrors,
|
||||
description: "Your organization does not support this version of Windows: MDM enrollment is disabled or restricted in the tenant, or an Autopilot device record still exists on redeploy" },
|
||||
{ key: "0x80180022", category: "autopilot", page: URL.enrollErrors,
|
||||
description: "Autopilot device enrollment failed: the device runs Windows Home edition (Pro or higher is required)" },
|
||||
{ key: "0x8018002b", category: "mdm-enrollment", page: URL.enrollErrors,
|
||||
description: "Automatic MDM enrollment failed: the user's UPN has an unverified or non-routable domain, or the MDM user scope is set to None" },
|
||||
{ key: "0x801c03ea", category: "entra-join", page: URL.enrollErrors,
|
||||
description: "Registering the device for mobile management failed: the TPM supports 2.0 but has not been upgraded to 2.0, or the device is in two groups with different Autopilot profiles" },
|
||||
{ key: "0x801c03f3", category: "entra-join", page: URL.knownIssues,
|
||||
description: "Microsoft Entra ID has no device object for the device being deployed (the object was deleted); remove the device from Entra ID, Intune and Autopilot and re-register it" },
|
||||
{ key: "0x81039001", category: "autopilot", page: URL.knownIssues, symbol: "E_AUTOPILOT_CLIENT_TPM_MAX_ATTESTATION_RETRY_EXCEEDED",
|
||||
description: "TPM attestation failed intermittently during self-deploying or pre-provisioning (Securing your hardware); a subsequent provisioning attempt often succeeds" },
|
||||
{ key: "0x81039023", category: "autopilot", page: URL.knownIssues,
|
||||
description: "TPM attestation failed on Windows 11 during self-deploying or pre-provisioning; fixed by the May 2022 cumulative update (KB5013943) or later" },
|
||||
{ key: "0x81039024", category: "autopilot", page: URL.knownIssues,
|
||||
description: "TPM attestation failed because known vulnerabilities were detected in the TPM; update the TPM firmware from the PC manufacturer" },
|
||||
{ key: "0xc1036501", category: "mdm-enrollment", page: URL.knownIssues,
|
||||
description: "The device cannot do an automatic MDM enrollment because there are multiple MDM configurations in Microsoft Entra ID" },
|
||||
// Named only in our own analysis rule, no MS page found: confidence medium.
|
||||
{ key: "0x80180018", category: "mdm-enrollment", source: "rule:ANALYZE-ENRL-004", confidence: "medium",
|
||||
description: "MDM enrollment refused for the user: missing Intune, EMS or Microsoft 365 license, or the device enrollment limit was exceeded" },
|
||||
];
|
||||
|
||||
/** v1 entries that no primary source covers — kept, marked low. */
|
||||
const LEGACY_ENTRIES = [
|
||||
["0x87d00213", "intune-win32", "App not applicable"],
|
||||
["0x87d00215", "intune-win32", "App dependency failed"],
|
||||
["0x87d00216", "intune-win32", "App supersedence conflict"],
|
||||
["0x87d00324", "intune-win32", "App installation failed"],
|
||||
["0x87d00607", "intune-win32", "App download failed"],
|
||||
["0x87d00651", "intune-win32", "Detection rules not met after install"],
|
||||
["0x87d13b88", "intune-mobile", "License assignment failed with token expired (VPP)"],
|
||||
["0x87d1fde8", "intune-win32", "Remediation failed"],
|
||||
["0x800f0991", "cbs", "A payload file is missing from the component store", "PSFX_E_MISSING_PAYLOAD_FILE"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Wording pins: consumers (tests, rule explanations) depend on these exact phrases. Each pin
|
||||
* says the same thing as the source row it replaces; the confidence stays with the source.
|
||||
*/
|
||||
const DESCRIPTION_PINS = {
|
||||
"0x87d1041c": { description: "Application not detected after installation completed successfully" },
|
||||
// The MS row describes one dated incident ("The July cumulative update failed…"); the code itself is generic.
|
||||
"0x800f0922": { description: "Windows servicing: a package installer failed during update installation", confidence: "medium" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Known defects in the IME enum source (sign / digit typos). The fixup applies the intended
|
||||
* value and FAILS when the source no longer shows the defect, so a fixed build drops the fixup.
|
||||
*/
|
||||
const IME_ENUM_FIXUPS = {
|
||||
InvalidRequirementRuleUnknownRequirementType: { seen: 2016215026, intended: -2016215026 },
|
||||
AppLogUploadSasUrlExpired: { seen: -216214830, intended: -2016214830 },
|
||||
};
|
||||
|
||||
// ── Build ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const entries = new Map(); // key → entry
|
||||
const dropped = []; // { key, family, reason }
|
||||
function add(family, key, entry) {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`${family}: bad key ${key}`);
|
||||
if (entries.has(key)) { dropped.push({ key, family, reason: `already defined by ${entries.get(key)._family}` }); return; }
|
||||
const description = shapeDescription(entry.description, { firstSentence: entry.firstSentence });
|
||||
if (!description) throw new Error(`${family}: empty description for ${key}`);
|
||||
const out = { description, confidence: entry.confidence, source: entry.source, category: entry.category };
|
||||
// Symbol cells may be wrapped with <br> ("ERROR_INSTALL_OPEN_ PACKAGE_FAILED"); the token has no spaces.
|
||||
const symbol = entry.symbol ? String(entry.symbol).replace(/\s+/g, "") : "";
|
||||
if (symbol && SYMBOL_PATTERN.test(symbol)) out.symbol = symbol;
|
||||
if (entry.imeRetriesDuringEsp) out.imeRetriesDuringEsp = true;
|
||||
const pin = DESCRIPTION_PINS[key];
|
||||
if (pin) { out.description = pin.description; if (pin.confidence) out.confidence = pin.confidence; }
|
||||
Object.defineProperty(out, "_family", { value: family, enumerable: false });
|
||||
entries.set(key, out);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const retriable = new Set(ime.msiRetriableErrorCodes.map(Number));
|
||||
|
||||
// Families other than Win32 are collected first so every 0x8007xxxx code they name can be
|
||||
// resolved against the Win32 reference, which then takes precedence.
|
||||
const pending = [];
|
||||
const queue = (family, key, entry) => pending.push({ family, key, entry });
|
||||
|
||||
// msi — decimal keys, MSI range only (other rows are plain Win32 codes and live as 0x8007xxxx).
|
||||
{
|
||||
const [table] = parseTables(await fetchPage(URL.msi));
|
||||
for (const [symbol, value, description] of table.slice(1)) {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || !MSI_DECIMAL(n)) continue;
|
||||
queue("msi", String(n), { symbol, description, confidence: "high", source: msdoc(URL.msi), category: "msi", imeRetriesDuringEsp: retriable.has(n) });
|
||||
}
|
||||
}
|
||||
|
||||
// com
|
||||
for (const [key, symbol, description] of COM_ENTRIES) queue("com", key, { symbol, description, confidence: "high", source: "winerror.h", category: "com" });
|
||||
|
||||
// appx
|
||||
{
|
||||
const [table] = parseTables(await fetchPage(URL.appx));
|
||||
for (const [symbol, value, description] of table.slice(1)) {
|
||||
if (!/^0x[0-9a-f]{8}$/i.test(value)) continue;
|
||||
queue("appx", normalizeHex(value), { symbol, description, firstSentence: true, confidence: "high", source: msdoc(URL.appx), category: "appx" });
|
||||
}
|
||||
}
|
||||
|
||||
// windows-update reference (Error code | Message | Description), the components an
|
||||
// enrollment can meet: agent core, handlers, download manager, protocol talker (HTTP),
|
||||
// data store, drivers, MSI handler, agent setup, AU client. Inventory / expression
|
||||
// evaluator / reporter / redirector are WSUS-internal and left out.
|
||||
const WU_SECTIONS = /^(Automatic Update Errors|Windows Update UI errors|Protocol Talker errors|Other Protocol Talker errors|Download Manager errors|Update Handler errors|Data Store errors|Driver Util errors|Windows Update error codes|Windows Update success codes|Windows Installer minor errors|Windows Update Agent update and setup errors)$/;
|
||||
for (const { heading, rows: table } of parseHeadedTables(await fetchPage(URL.wuReference))) {
|
||||
if (!WU_SECTIONS.test(heading)) continue;
|
||||
for (const [value, message, description] of table.slice(1)) {
|
||||
if (!/^0x[0-9a-f]{8}$/i.test(value)) continue;
|
||||
const key = normalizeHex(value);
|
||||
queue("windows-update", key, { symbol: message, description, confidence: "high", source: msdoc(URL.wuReference), category: categoryForWuKey(key) });
|
||||
}
|
||||
}
|
||||
|
||||
// windows-update common errors (one table per heading; Message | Description | Mitigation)
|
||||
for (const { heading, rows } of parseHeadedTables(await fetchPage(URL.wuErrors))) {
|
||||
// A heading that lumps several codes into one table ("0x80072EFD or 0x80072EFE or 0x80D02002")
|
||||
// describes a symptom, not the codes; only single-code sections are taken.
|
||||
const codes = [...heading.matchAll(/0x[0-9a-f]{8}/gi)].map((m) => normalizeHex(m[0]));
|
||||
if (codes.length !== 1 || rows.length < 2) continue;
|
||||
const [message, description] = rows[1];
|
||||
const [symbolPart, ...textParts] = message.split(";");
|
||||
const symbol = symbolPart.trim();
|
||||
const text = textParts.join(";").trim() || (description && description !== "NA" ? description : "");
|
||||
if (!text) continue;
|
||||
for (const key of codes) queue("windows-update", key, { symbol, description: text, firstSentence: true, confidence: "high", source: msdoc(URL.wuErrors), category: categoryForWuKey(key) });
|
||||
}
|
||||
|
||||
// intune — skip the Android table (heading order: Android, iOS/iPadOS, Other)
|
||||
{
|
||||
const tables = parseTables(await fetchPage(URL.intuneApps));
|
||||
for (const table of tables.slice(1)) {
|
||||
for (const [value, , message, description] of table.slice(1)) {
|
||||
if (!/^0x[0-9a-f]{8}$/i.test(value)) continue;
|
||||
const key = normalizeHex(value);
|
||||
const text = /^\(client error\)$/i.test(message) ? description : message;
|
||||
const category = key === "0x87d1041c" || key === "0x87d103e8" ? "intune-win32" : key.startsWith("0x87d1") ? "intune-mobile" : "appx";
|
||||
queue("intune", key, { description: text, firstSentence: true, confidence: "high", source: msdoc(URL.intuneApps), category });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prose pages
|
||||
for (const e of PROSE_ENTRIES) {
|
||||
if (e.page) {
|
||||
const html = (await fetchPage(e.page)).toLowerCase();
|
||||
if (!html.includes(e.key.toLowerCase())) throw new Error(`prose: ${e.key} not found on ${e.page}`);
|
||||
}
|
||||
queue("prose", e.key, { symbol: e.symbol, description: e.description, confidence: e.confidence ?? "high", source: e.source ?? msdoc(e.page), category: e.category });
|
||||
}
|
||||
|
||||
// IME CustomErrorCodes (0x87D3xxxx)
|
||||
for (const [name, rawValue] of Object.entries(ime.customErrorCodes)) {
|
||||
if (name === "Undefined") continue;
|
||||
let value = Number(rawValue);
|
||||
const fix = IME_ENUM_FIXUPS[name];
|
||||
if (fix) {
|
||||
if (value !== fix.seen) throw new Error(`IME fixup for ${name} is stale: source shows ${value}, fixup expects ${fix.seen}`);
|
||||
value = fix.intended;
|
||||
}
|
||||
queue("ime-custom", fromSigned(value), { symbol: name, description: humanizeMember(name), confidence: "medium", source: IME_SOURCE, category: "intune-win32" });
|
||||
}
|
||||
for (const missing of Object.keys(IME_ENUM_FIXUPS).filter((n) => !(n in ime.customErrorCodes))) {
|
||||
throw new Error(`IME fixup names ${missing}, which the enum extract does not contain`);
|
||||
}
|
||||
|
||||
// IME Delivery Optimization constants
|
||||
const doc = ime.deliveryOptimization;
|
||||
for (const group of [doc.errorCodesDoSvc, doc.transientErrorCodesDoSvc]) {
|
||||
for (const [name, value] of Object.entries(group)) {
|
||||
queue("ime-do", fromSigned(Number(value)), { symbol: name, description: humanizeMember(name), confidence: "medium", source: IME_SOURCE, category: "delivery-optimization" });
|
||||
}
|
||||
}
|
||||
const doWindowsCodes = Object.values(doc.windowsErrorCodes).map((v) => fromSigned(Number(v)));
|
||||
|
||||
// legacy
|
||||
for (const [key, category, description, symbol] of LEGACY_ENTRIES) queue("legacy", key, { symbol, description, confidence: "low", source: "legacy-catalog-v1", category });
|
||||
|
||||
// win32 — seed: field list ∪ retriable ∪ every facility-7 code any family named, minus the MSI decimal range.
|
||||
const win32Seed = new Set(WIN32_FIELD_SEEN);
|
||||
for (const n of retriable) win32Seed.add(n);
|
||||
// The AppX page is the authority for its own facility-7 range (0x80073Cxx/0x80073Dxx): its rows
|
||||
// carry the packaging-specific wording, so those codes are not routed through the Win32 reference.
|
||||
const appxKeys = new Set(pending.filter((p) => p.family === "appx").map((p) => p.key));
|
||||
for (const key of [...pending.map((p) => p.key), ...doWindowsCodes]) {
|
||||
if (isFacility7(key) && !appxKeys.has(key)) win32Seed.add(parseInt(key.slice(6), 16));
|
||||
}
|
||||
for (const n of [...win32Seed]) if (MSI_DECIMAL(n)) win32Seed.delete(n);
|
||||
|
||||
const win32Pages = new Map();
|
||||
const resolveWin32 = async (n) => {
|
||||
const useWinHttp = n >= WINHTTP_RANGE[0] && n <= WINHTTP_RANGE[1];
|
||||
const range = WIN32_RANGES.find(([lo, hi]) => n >= lo && n <= hi);
|
||||
const candidates = useWinHttp ? [URL.winhttp] : [];
|
||||
if (range) candidates.push(URL.win32(`${range[0]}-${range[1]}`));
|
||||
for (const url of candidates) {
|
||||
if (!win32Pages.has(url)) win32Pages.set(url, parseSymbolPage(await fetchPage(url)));
|
||||
const hit = win32Pages.get(url).get(n);
|
||||
if (hit) return { ...hit, url };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// A code another family names but the Win32 reference does not list (the AppX page carries
|
||||
// 0x80073Dxx symbols the System Error Codes pages omit) stays with that family; a code from
|
||||
// the explicit seed must resolve.
|
||||
const mustResolve = new Set([...WIN32_FIELD_SEEN, ...retriable]);
|
||||
const unresolved = [];
|
||||
for (const n of [...win32Seed].sort((a, b) => a - b)) {
|
||||
const hit = await resolveWin32(n);
|
||||
if (!hit) { if (mustResolve.has(n)) unresolved.push(n); continue; }
|
||||
add("win32", hexKey(0x80070000 + n), { symbol: hit.symbol, description: hit.text, confidence: "high", source: msdoc(hit.url), category: "win32", imeRetriesDuringEsp: retriable.has(n) });
|
||||
}
|
||||
if (unresolved.length > 0) throw new Error(`win32: no reference entry for ${unresolved.join(", ")}`);
|
||||
|
||||
for (const p of pending) add(p.family, p.key, p.entry);
|
||||
|
||||
// enforcement states
|
||||
const enforcementStates = {};
|
||||
for (const [name, value] of Object.entries(ime.enforcementStates)) {
|
||||
enforcementStates[String(value)] = { name, description: humanizeMember(name) };
|
||||
}
|
||||
|
||||
validate(entries, enforcementStates);
|
||||
|
||||
// ── Emit ──
|
||||
const keys = [...entries.keys()].sort((a, b) => {
|
||||
const ah = a.startsWith("0x"), bh = b.startsWith("0x");
|
||||
if (ah !== bh) return ah ? 1 : -1;
|
||||
return ah ? a.localeCompare(b) : Number(a) - Number(b);
|
||||
});
|
||||
const stateKeys = Object.keys(enforcementStates).sort((a, b) => Number(a) - Number(b));
|
||||
const header = {
|
||||
schemaVersion: 2,
|
||||
description:
|
||||
"Single-source catalog of Windows / MSI / Intune error codes, generated by scripts/error-codes/generate.mjs from primary sources — " +
|
||||
"never edit by hand. Consumed by the backend (ErrorCodeCatalog.cs, ErrorCodeEnricher.cs), the web app (utils/errorCodeMap.ts via the " +
|
||||
"sync-error-codes prebuild step) and the MCP server (lookup_error_code). Keys are lowercase 8-digit hex ('0x80070005') or decimal MSI exit " +
|
||||
"codes ('1603'; only 0, 1601-1654, 3010 — other decimal exit codes are installer-defined). Win32 codes live only as 0x8007xxxx entries; " +
|
||||
"HRESULT_FROM_WIN32 values in the MSI range resolve to the decimal entry at lookup time. confidence: high = MS Learn or the IME build named " +
|
||||
"in source; medium = paraphrase of an enum member name or of an analysis rule; low = carried over from the v1 catalog without a primary " +
|
||||
"source. category is one of: " + CATEGORIES.join(", ") + ". imeRetriesDuringEsp marks the MSI return codes the IME retries automatically " +
|
||||
"during the Autopilot ESP. enforcementStates maps the IME app enforcement state numbers to their names.",
|
||||
};
|
||||
const lines = [];
|
||||
lines.push("{");
|
||||
lines.push(` "schemaVersion": ${header.schemaVersion},`);
|
||||
lines.push(` "description": ${JSON.stringify(header.description)},`);
|
||||
lines.push(` "entries": {`);
|
||||
keys.forEach((k, i) => lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(entries.get(k))}${i < keys.length - 1 ? "," : ""}`));
|
||||
lines.push(` },`);
|
||||
lines.push(` "enforcementStates": {`);
|
||||
stateKeys.forEach((k, i) => lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(enforcementStates[k])}${i < stateKeys.length - 1 ? "," : ""}`));
|
||||
lines.push(` }`);
|
||||
lines.push("}");
|
||||
const output = lines.join("\n") + "\n";
|
||||
|
||||
const byFamily = {};
|
||||
for (const e of entries.values()) byFamily[e._family] = (byFamily[e._family] ?? 0) + 1;
|
||||
console.error(`entries: ${entries.size} (${Object.entries(byFamily).map(([f, n]) => `${f} ${n}`).join(", ")}), enforcementStates: ${stateKeys.length}`);
|
||||
if (dropped.length > 0) console.error(`dropped duplicates: ${dropped.map((d) => `${d.key}←${d.family} (${d.reason})`).join("; ")}`);
|
||||
|
||||
if (checkOnly) {
|
||||
const current = existsSync(TARGET) ? readFileSync(TARGET, "utf8") : "";
|
||||
if (current !== output) { console.error("catalog differs from generator output"); process.exit(1); }
|
||||
console.error("catalog is up to date");
|
||||
return;
|
||||
}
|
||||
writeFileSync(TARGET, output, "utf8");
|
||||
console.error(`wrote ${path.relative(REPO_ROOT, TARGET)}`);
|
||||
execFileSync(process.execPath, [WEB_SYNC], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
function categoryForWuKey(key) {
|
||||
if (key.startsWith("0x800f")) return "cbs";
|
||||
if (key.startsWith("0x80d0")) return "delivery-optimization";
|
||||
return "windows-update";
|
||||
}
|
||||
|
||||
function validate(map, states) {
|
||||
const symbols = new Map();
|
||||
for (const [key, e] of map) {
|
||||
if (!KEY_PATTERN.test(key)) throw new Error(`bad key ${key}`);
|
||||
if (!e.description) throw new Error(`${key}: empty description`);
|
||||
if (e.description.length > MAX_DESCRIPTION + 21) throw new Error(`${key}: description too long`);
|
||||
if (!CONFIDENCES.includes(e.confidence)) throw new Error(`${key}: bad confidence ${e.confidence}`);
|
||||
if (!CATEGORIES.includes(e.category)) throw new Error(`${key}: bad category ${e.category}`);
|
||||
if (!SOURCE_PATTERN.test(e.source)) throw new Error(`${key}: bad source ${e.source}`);
|
||||
if (e.symbol) {
|
||||
if (symbols.has(e.symbol)) throw new Error(`symbol ${e.symbol} on both ${symbols.get(e.symbol)} and ${key}`);
|
||||
symbols.set(e.symbol, key);
|
||||
}
|
||||
}
|
||||
const names = new Set();
|
||||
for (const [k, s] of Object.entries(states)) {
|
||||
if (!/^-?\d+$/.test(k)) throw new Error(`enforcement state key ${k}`);
|
||||
if (names.has(s.name)) throw new Error(`enforcement state name ${s.name} twice`);
|
||||
names.add(s.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((err) => { console.error(err.stack ?? String(err)); process.exit(1); });
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRule.EnrichErrorCodes"/> is an opt-in the backend honours at response time:
|
||||
/// the agent stamps <c>enrichErrorCodes: true</c> into the event data of a rule that set it and
|
||||
/// nothing at all otherwise (the default payload stays as it was).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorEnrichErrorCodesTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorEnrichErrorCodesTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private EnrollmentEvent SingleEvent()
|
||||
{
|
||||
lock (_eventsGate) return Assert.Single(_events);
|
||||
}
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
lock (_eventsGate) if (_events.Count >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
lock (_eventsGate) return _events.Count >= count;
|
||||
}
|
||||
|
||||
private static GatherRule StartupRule(bool enrichErrorCodes) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-EEC-001",
|
||||
Title = "enrich test",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
EnrichErrorCodes = enrichErrorCodes,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void StampRuleMarkers_AddsMarkerOnlyWhenOptedIn()
|
||||
{
|
||||
var optedIn = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: true), optedIn);
|
||||
Assert.Equal(true, optedIn[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
|
||||
var defaultRule = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: false), defaultRule);
|
||||
Assert.False(defaultRule.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
Assert.Single(defaultRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_StampsMarker_WhenRuleOptsIn()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: true) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
var evt = SingleEvent();
|
||||
Assert.Equal(GatherRuleExecutor.SourceName, evt.Source);
|
||||
Assert.Equal(true, evt.Data[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_LeavesPayloadUnchanged_ByDefault()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: false) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
Assert.False(SingleEvent().Data.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -311,6 +311,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
"[Win32App] Admin did NOT set mapping for lpExitCode: 1603 of app: " + Guid1,
|
||||
new[] { "exitCode=1603", "id=" + Guid1 }
|
||||
};
|
||||
// Win32AppInstaller.cs "[Win32App] lpExitCode is defined as {0}", enum AppReturnCodeType
|
||||
// (identical 1.50–1.105).
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-EXITCODE-CLASS",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot",
|
||||
new[] { "exitCodeClass=SoftReboot" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-GRS-SKIP",
|
||||
@@ -361,6 +369,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Success")]
|
||||
[InlineData("SoftReboot")]
|
||||
[InlineData("HardReboot")]
|
||||
[InlineData("Retry")]
|
||||
[InlineData("Failed")]
|
||||
public void IME_EXITCODE_CLASS_captures_every_AppReturnCodeType_member(string member)
|
||||
{
|
||||
var m = Compile(Get("IME-EXITCODE-CLASS")).Match("[Win32App] lpExitCode is defined as " + member);
|
||||
Assert.True(m.Success);
|
||||
Assert.Equal(member, m.Groups["exitCodeClass"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IME_EXITCODE_CLASS_ignores_the_no_mapping_warning_and_raw_numeric_types()
|
||||
{
|
||||
// The default branch logs "... defined as {0}, fall into default now." and an admin
|
||||
// type outside the enum prints as a raw number; neither is a class the app carries.
|
||||
var regex = Compile(Get("IME-EXITCODE-CLASS"));
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as 5, fall into default now.");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as SoftReboot, fall into default now.");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as 5");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode 3010");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PS_SCRIPT_CONTEXT_no_longer_fires_on_Win32App_detection_script_lines()
|
||||
{
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Return-code class of the installer exit code (IME "[Win32App] lpExitCode is defined as
|
||||
/// {AppReturnCodeType}"): captured by the shipped IME-EXITCODE-CLASS pattern into
|
||||
/// <see cref="AppPackageState.ExitCodeClass"/>, last value wins across a Retry loop, the
|
||||
/// no-mapping/default-branch variants are ignored, and the value survives the agent
|
||||
/// restart that a SoftReboot/HardReboot app causes (persistence round trip).
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerExitCodeClassTests
|
||||
{
|
||||
private const string AppId = "5c95bf94-1cf4-4629-88d1-3f616e7a405c";
|
||||
|
||||
// ---------------------------------------------------------------- AppPackageState
|
||||
|
||||
[Fact]
|
||||
public void UpdateExitCodeClass_last_value_wins_and_ignores_empty()
|
||||
{
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
Assert.Null(pkg.ExitCodeClass);
|
||||
|
||||
Assert.True(pkg.UpdateExitCodeClass("Retry"));
|
||||
Assert.True(pkg.UpdateExitCodeClass(" Success "));
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
Assert.False(pkg.UpdateExitCodeClass("Success"));
|
||||
Assert.False(pkg.UpdateExitCodeClass(""));
|
||||
Assert.False(pkg.UpdateExitCodeClass(null));
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEventData_carries_the_class_only_when_known()
|
||||
{
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
Assert.False(pkg.ToEventData().ContainsKey("exitCodeClass"));
|
||||
|
||||
pkg.UpdateExitCode("3010");
|
||||
pkg.UpdateExitCodeClass("SoftReboot");
|
||||
var data = pkg.ToEventData();
|
||||
Assert.Equal("3010", data["exitCode"]);
|
||||
Assert.Equal("SoftReboot", data["exitCodeClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restore_round_trips_the_class()
|
||||
{
|
||||
var pkg = AppPackageState.Restore(
|
||||
AppId, 0, "Contoso VPN",
|
||||
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
|
||||
new HashSet<string>(),
|
||||
AppInstallationState.Installed, true, 100, 0, 0,
|
||||
exitCode: "3010", exitCodeClass: "SoftReboot");
|
||||
|
||||
Assert.Equal("SoftReboot", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shipped pack, end to end
|
||||
|
||||
private static string FindRulesPatternDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException($"Could not locate rules/ime-log-patterns walking up from {AppContext.BaseDirectory}");
|
||||
}
|
||||
|
||||
/// <summary>The shipped pack, exactly as combine.js embeds it (sorted by file name).</summary>
|
||||
private static List<ImeLogPattern> ShippedPack()
|
||||
=> Directory.GetFiles(FindRulesPatternDir(), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: ShippedPack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the CMTrace entries below are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, Now.AddSeconds(-60 + i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
[Fact]
|
||||
public async Task Shipped_pattern_captures_the_class_for_the_current_app()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, // activates the currentPhase pack (IME-EXITCODE, IME-EXITCODE-CLASS)
|
||||
CurrentAppLine, // IME-SET-CURRENT-4 → current app
|
||||
"[Win32App] lpExitCode 3010",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId);
|
||||
Assert.NotNull(pkg);
|
||||
Assert.Equal("3010", pkg!.ExitCode);
|
||||
Assert.Equal("SoftReboot", pkg.ExitCodeClass);
|
||||
Assert.NotEqual(AppInstallationState.Error, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_loop_that_ends_in_success_reports_the_last_class()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1618",
|
||||
"[Win32App] lpExitCode is defined as Retry",
|
||||
"[Win32App] lpExitCode 1618",
|
||||
"[Win32App] lpExitCode is defined as Retry",
|
||||
"[Win32App] lpExitCode 0",
|
||||
"[Win32App] lpExitCode is defined as Success");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId)!;
|
||||
Assert.Equal("0", pkg.ExitCode);
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_class_still_drives_the_error_transition_and_carries_the_class()
|
||||
{
|
||||
// IME-EXITCODE-CLASS and IME-EXITCODE-MAPPED-FAILED both match the Failed line: the
|
||||
// class is captured and the existing error path stays intact.
|
||||
using var h = new Harness();
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
h.Tracker.PackageStates.Add(pkg);
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1603",
|
||||
"[Win32App] lpExitCode is defined as Failed");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, pkg.InstallationState);
|
||||
Assert.Equal("Failed", pkg.ExitCodeClass);
|
||||
Assert.Equal("IME-EXITCODE-MAPPED-FAILED", pkg.ErrorPatternId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_branch_and_raw_numeric_types_leave_the_class_unset()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 5",
|
||||
"[Win32App] lpExitCode is defined as 5",
|
||||
"[Win32App] lpExitCode is defined as 5, fall into default now.",
|
||||
"[Win32App] Setting enforcementState as: Error with lpExitCode: 5 without mapping");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId)!;
|
||||
Assert.Equal("5", pkg.ExitCode);
|
||||
Assert.Null(pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Class_survives_the_state_round_trip_of_an_agent_restart()
|
||||
{
|
||||
// A SoftReboot/HardReboot app completes only after the reboot; the restarted agent
|
||||
// must still know the class when the post-reboot Success line arrives.
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1641",
|
||||
"[Win32App] lpExitCode is defined as HardReboot");
|
||||
await h.Pass();
|
||||
h.Tracker.SaveStateForTest();
|
||||
|
||||
using var restarted = h.Build();
|
||||
restarted.LoadStateForTest();
|
||||
|
||||
var pkg = restarted.GetAllKnownPackageStates().Single(p => p.Id == AppId);
|
||||
Assert.Equal("1641", pkg.ExitCode);
|
||||
Assert.Equal("HardReboot", pkg.ExitCodeClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -903,5 +903,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_records_packaging_counters_by_reason_and_kind_without_paths()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "small.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-1.json"), new string('x', 64));
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-2.json"), new string('y', 64));
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var service = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }));
|
||||
service.Budget = new DiagnosticsBudget { MaxSingleFileBytes = 16, MaxTotalUncompressedBytes = 1L << 30, MaxFileCount = 5000 };
|
||||
|
||||
Assert.Null(service.LastPackaging);
|
||||
var bytes = service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.True(stats!.Truncated);
|
||||
Assert.Equal(2, stats.SkippedFiles);
|
||||
Assert.Equal(2, stats.SkippedByReason["size"]);
|
||||
Assert.Single(stats.SkippedByReason);
|
||||
Assert.Equal(1, stats.ProblemsByKind["path-guard"]);
|
||||
Assert.Single(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1, "the small file must have been included");
|
||||
Assert.True(stats.IncludedBytes >= 2);
|
||||
|
||||
// The ZIP still carries the per-file list; the counters never do.
|
||||
Assert.NotNull(ReadZipEntryText(bytes, "_TRUNCATED.txt"));
|
||||
foreach (var key in stats.SkippedByReason.Keys.Concat(stats.ProblemsByKind.Keys))
|
||||
{
|
||||
Assert.DoesNotContain("\\", key);
|
||||
Assert.DoesNotContain(":", key);
|
||||
Assert.DoesNotContain("/", key);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_reports_a_clean_build_as_not_truncated_with_empty_maps()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var service = rig.Build();
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.False(stats!.Truncated);
|
||||
Assert.Equal(0, stats.SkippedFiles);
|
||||
Assert.Empty(stats.SkippedByReason);
|
||||
Assert.Empty(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_counts_a_rejected_source_folder_as_a_problem()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var outside = new TempDirectory();
|
||||
File.WriteAllText(Path.Combine(outside.Path, "snapshot.json"), "{}");
|
||||
var link = Path.Combine(rig.Tmp.Path, "StateLink");
|
||||
NtfsLinks.CreateJunction(link, outside.Path);
|
||||
|
||||
try
|
||||
{
|
||||
var service = rig.Build(sectionFolderOverrides: new Dictionary<string, string> { ["AgentState"] = link });
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
// The section is walked once per search pattern (11 patterns) but the folder
|
||||
// failed once — the counter says so.
|
||||
Assert.Equal(1, service.LastPackaging!.ProblemsByKind["folder-rejected"]);
|
||||
Assert.False(service.LastPackaging.Truncated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -243,12 +243,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeHResult_KnownAndUnknown()
|
||||
public void ResolveHResultSymbol_ReadsTheSharedCatalog()
|
||||
{
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", WindowsUpdateTracker.DecodeHResult(0x80240022));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", WindowsUpdateTracker.DecodeHResult(0x800F0922));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.DecodeHResult(0x00000000));
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.DecodeHResult(0x8888DEAD));
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", WindowsUpdateTracker.ResolveHResultSymbol(0x80240022));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", WindowsUpdateTracker.ResolveHResultSymbol(0x800F0922));
|
||||
Assert.Equal("ERROR_TIMEOUT", WindowsUpdateTracker.ResolveHResultSymbol(0x800705B4));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.ResolveHResultSymbol(0x00000000));
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x8888DEAD));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHResultSymbol_FallsBackWhenTheCatalogHasNoSymbolOrThrows()
|
||||
{
|
||||
// An entry without a symbol (IME/Intune paraphrases) still yields the sentinel.
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x87D1041C));
|
||||
// A catalog load failure is decoration lost, never an event lost.
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x80240022, _ => throw new InvalidOperationException("catalog")));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.ResolveHResultSymbol(0, _ => throw new InvalidOperationException("catalog")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+10
-1
@@ -282,8 +282,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
errorPatternId: null,
|
||||
errorDetail: null,
|
||||
errorCode: null,
|
||||
exitCode: null,
|
||||
exitCode: "0",
|
||||
hresultFromWin32: null,
|
||||
exitCodeClass: "Success",
|
||||
appVersion: "11.2.1787.0",
|
||||
appType: "WinGet",
|
||||
attemptNumber: 1,
|
||||
@@ -293,6 +294,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal("app-xyz", info.Payload!["appId"]);
|
||||
// Installer outcome surfaces on the terminal event even when the app succeeded: the
|
||||
// return-code class is the only evidence of a SoftReboot/HardReboot mapping and the
|
||||
// number says which code was mapped (IME-EXITCODE-CLASS, ANALYZE-APP-018).
|
||||
Assert.Equal("Success", info.Payload["exitCodeClass"]);
|
||||
Assert.Equal("0", info.Payload["exitCode"]);
|
||||
Assert.Equal("Company Portal", info.Payload["appName"]);
|
||||
Assert.Equal("Installed", info.Payload["state"]);
|
||||
Assert.Equal("Install", info.Payload["intent"]);
|
||||
@@ -331,6 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
errorCode: "60001",
|
||||
exitCode: "60001",
|
||||
hresultFromWin32: "-2146964895",
|
||||
exitCodeClass: "Failed",
|
||||
attemptNumber: 1,
|
||||
detectionResult: "NotDetected");
|
||||
|
||||
@@ -340,6 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("true", info.Payload!["isError"]);
|
||||
Assert.Equal("IME-ERROR-UNMAPPED-EXIT", info.Payload["errorPatternId"]);
|
||||
Assert.Equal("60001", info.Payload["errorCode"]);
|
||||
Assert.Equal("60001", info.Payload["exitCode"]);
|
||||
Assert.Equal("Failed", info.Payload["exitCodeClass"]);
|
||||
Assert.Equal("-2146964895", info.Payload["hresultFromWin32"]);
|
||||
Assert.Equal(EventSeverity.Error.ToString(), info.Payload[SignalPayloadKeys.Severity]);
|
||||
// Codex review follow-up (P1): an unrelated ErrorPatternId (here a regular
|
||||
|
||||
+81
@@ -1701,5 +1701,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
rig.PromotionCalls[0].failureType);
|
||||
Assert.Null(rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters on the diag events
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_uploaded_carries_packaging_counters_when_the_packager_reported_them()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = "diag-blob.zip",
|
||||
Packaging = new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = 412,
|
||||
IncludedBytes = 123456,
|
||||
SkippedFiles = 3,
|
||||
SkippedByReason = new Dictionary<string, int> { ["size"] = 2, ["total"] = 1 },
|
||||
ProblemsByKind = new Dictionary<string, int> { ["copy"] = 1 },
|
||||
},
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.Equal(true, uploaded!["truncated"]);
|
||||
Assert.Equal(412, uploaded["includedFiles"]);
|
||||
Assert.Equal(123456L, uploaded["includedBytes"]);
|
||||
Assert.Equal(3, uploaded["skippedFiles"]);
|
||||
var byReason = Assert.IsType<Dictionary<string, int>>(uploaded["skippedByReason"]);
|
||||
Assert.Equal(2, byReason["size"]);
|
||||
Assert.Equal(1, byReason["total"]);
|
||||
var problems = Assert.IsType<Dictionary<string, int>>(uploaded["problemsByKind"]);
|
||||
Assert.Equal(1, problems["copy"]);
|
||||
// Counters only — no key or value carries a file path.
|
||||
foreach (var kv in uploaded)
|
||||
Assert.False(kv.Value is string s && (s.Contains("\\") || s.Contains("/")) && kv.Key != "sasUrlPrefix",
|
||||
$"payload key {kv.Key} looks like a path: {kv.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_upload_failed_after_a_build_carries_the_counters_too()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
ErrorCode = "upload_5xx",
|
||||
Packaging = new DiagnosticsPackagingStats { IncludedFiles = 40, IncludedBytes = 1000 },
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
var failed = rig.DataOf("diagnostics_upload_failed");
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("upload_5xx", (string)failed!["errorCode"]);
|
||||
Assert.Equal(false, failed["truncated"]);
|
||||
Assert.Equal(40, failed["includedFiles"]);
|
||||
Assert.Empty(Assert.IsType<Dictionary<string, int>>(failed["skippedByReason"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_events_omit_the_counters_when_no_package_was_built()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.False(uploaded!.ContainsKey("truncated"));
|
||||
Assert.False(uploaded.ContainsKey("skippedByReason"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Installer result codes (captured from lpExitCode / hResultFromWin32 log lines)
|
||||
public string ExitCode { get; private set; }
|
||||
public string HResultFromWin32 { get; private set; }
|
||||
/// <summary>
|
||||
/// Admin-defined return-code class of the last installer exit code: Success, SoftReboot,
|
||||
/// HardReboot, Retry or Failed (IME's AppReturnCodeType, captured from the
|
||||
/// "lpExitCode is defined as X" line by IME-EXITCODE-CLASS). Last value wins, so a Retry
|
||||
/// loop that ends in Success reports Success.
|
||||
/// </summary>
|
||||
public string ExitCodeClass { get; private set; }
|
||||
|
||||
// App metadata (captured from IME log patterns — used by App Dashboard / reports)
|
||||
/// <summary>Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.</summary>
|
||||
@@ -167,6 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
int? progressPercent, long bytesDownloaded, long bytesTotal,
|
||||
string errorPatternId = null, string errorDetail = null, string errorCode = null,
|
||||
string exitCode = null, string hresultFromWin32 = null,
|
||||
string exitCodeClass = null,
|
||||
long doFileSize = 0, long doTotalBytesDownloaded = 0,
|
||||
long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
|
||||
long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
|
||||
@@ -195,6 +203,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ErrorCode = errorCode,
|
||||
ExitCode = exitCode,
|
||||
HResultFromWin32 = hresultFromWin32,
|
||||
ExitCodeClass = exitCodeClass,
|
||||
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
|
||||
DoFileSize = doFileSize,
|
||||
DoTotalBytesDownloaded = doTotalBytesDownloaded,
|
||||
@@ -302,6 +311,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HResultFromWin32 = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the return-code class (Success/SoftReboot/HardReboot/Retry/Failed) IME assigned
|
||||
/// to the last exit code. Last value wins; empty values are ignored.
|
||||
/// </summary>
|
||||
public bool UpdateExitCodeClass(string exitCodeClass)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exitCodeClass)) return false;
|
||||
var trimmed = exitCodeClass.Trim();
|
||||
if (ExitCodeClass == trimmed) return false;
|
||||
ExitCodeClass = trimmed;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the product version detected for this app (e.g. "11.2.1787.0").
|
||||
/// Only applies non-empty values so a later detection without version cannot wipe it.
|
||||
@@ -547,6 +569,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
data["exitCode"] = ExitCode;
|
||||
if (!string.IsNullOrEmpty(HResultFromWin32))
|
||||
data["hresultFromWin32"] = HResultFromWin32;
|
||||
if (!string.IsNullOrEmpty(ExitCodeClass))
|
||||
data["exitCodeClass"] = ExitCodeClass;
|
||||
|
||||
// App metadata fields (emitted whenever available so ingest can merge them).
|
||||
if (!string.IsNullOrEmpty(AppVersion))
|
||||
|
||||
+10
-1
@@ -491,7 +491,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"imeshutdown", "espphasedetected", "setcurrentapp",
|
||||
"updatestateinstalled", "updatestatedownloading", "updatestateinstalling",
|
||||
"updatestateskipped", "updatestateerror", "updatestatepostponed",
|
||||
"captureexitcode", "capturehresult", "captureappversion",
|
||||
"captureexitcode", "capturehresult", "captureexitcodeclass", "captureappversion",
|
||||
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
@@ -715,6 +715,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal);
|
||||
break;
|
||||
|
||||
case "captureexitcodeclass":
|
||||
// Return-code class of the exit code captured just before (IME logs the
|
||||
// number, then "lpExitCode is defined as <class>"). Same current-app
|
||||
// addressing as captureExitCode; last value wins across a Retry loop.
|
||||
var exitCodeClassVal = match.Groups["exitCodeClass"]?.Value;
|
||||
if (!string.IsNullOrEmpty(exitCodeClassVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCodeClass(exitCodeClassVal);
|
||||
break;
|
||||
|
||||
case "captureappversion":
|
||||
var appVersionVal = match.Groups["appVersion"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal))
|
||||
|
||||
@@ -1241,6 +1241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ErrorCode = p.ErrorCode,
|
||||
ExitCode = p.ExitCode,
|
||||
HResultFromWin32 = p.HResultFromWin32,
|
||||
ExitCodeClass = p.ExitCodeClass,
|
||||
DoFileSize = p.DoFileSize,
|
||||
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
|
||||
DoBytesFromPeers = p.DoBytesFromPeers,
|
||||
@@ -1270,6 +1271,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
|
||||
errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
|
||||
exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
|
||||
exitCodeClass: p.ExitCodeClass,
|
||||
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
|
||||
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
|
||||
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
|
||||
|
||||
+3
@@ -190,6 +190,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Installer result codes
|
||||
public string ExitCode { get; set; }
|
||||
public string HResultFromWin32 { get; set; }
|
||||
// Return-code class of the last exit code (null on state files written before the field
|
||||
// existed) - must survive the reboot a SoftReboot/HardReboot app asks for.
|
||||
public string ExitCodeClass { get; set; }
|
||||
|
||||
// Delivery Optimization telemetry
|
||||
public long DoFileSize { get; set; }
|
||||
|
||||
+18
-28
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
@@ -303,7 +304,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out hresultHex))
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = DecodeHResult(hresultValue);
|
||||
data["hresultSymbol"] = ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
@@ -462,36 +463,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps common Windows Update HRESULTs to their symbolic name. Not exhaustive — the full
|
||||
/// catalog lives at
|
||||
/// https://learn.microsoft.com/windows/deployment/update/windows-update-error-reference.
|
||||
/// Unknown codes return "WU_E_UNKNOWN" so the raw <c>hresult</c> hex still carries the value.
|
||||
/// Symbolic name of a WU HRESULT from the shared error-code catalog (WU, CBS, Win32 and
|
||||
/// COM families). <c>S_OK</c> for zero, <c>WU_E_UNKNOWN</c> when the catalog has no symbol,
|
||||
/// so the raw <c>hresult</c> hex still carries the value. A catalog load failure is
|
||||
/// swallowed the same way: decoding is decoration and must never cost the event.
|
||||
/// </summary>
|
||||
internal static string DecodeHResult(uint hresult)
|
||||
internal static string ResolveHResultSymbol(uint hresult)
|
||||
=> ResolveHResultSymbol(hresult, code => ErrorCodeCatalog.TryLookup(code));
|
||||
|
||||
internal static string ResolveHResultSymbol(uint hresult, Func<string, ErrorCodeEntry> lookup)
|
||||
{
|
||||
switch (hresult)
|
||||
if (hresult == 0) return "S_OK";
|
||||
try
|
||||
{
|
||||
case 0x00000000: return "S_OK";
|
||||
// WU agent codes (0x8024xxxx)
|
||||
case 0x80240022: return "WU_E_ALL_UPDATES_FAILED";
|
||||
case 0x8024200B: return "WU_E_UH_INSTALLERFAILURE";
|
||||
case 0x80240020: return "WU_E_NO_INTERACTIVE_USER";
|
||||
case 0x80240016: return "WU_E_INSTALL_NOT_ALLOWED";
|
||||
case 0x8024000B: return "WU_E_CALL_CANCELLED";
|
||||
case 0x8024000C: return "WU_E_NOOP";
|
||||
case 0x80240FFF: return "WU_E_UNEXPECTED";
|
||||
case 0x80248007: return "WU_E_DS_NODATA";
|
||||
case 0x8024402C: return "WU_E_PT_WINHTTP_NAME_NOT_RESOLVED";
|
||||
// Generic Win32 / HRESULT
|
||||
case 0x80070005: return "E_ACCESSDENIED";
|
||||
case 0x80070002: return "ERROR_FILE_NOT_FOUND";
|
||||
case 0x80070003: return "ERROR_PATH_NOT_FOUND";
|
||||
case 0x800705B4: return "ERROR_TIMEOUT";
|
||||
// CBS / servicing (LCU install failures)
|
||||
case 0x800F0831: return "CBS_E_STORE_CORRUPTION";
|
||||
case 0x800F0922: return "CBS_E_INSTALLERS_FAILED";
|
||||
case 0x800F0991: return "PSFX_E_MISSING_PAYLOAD_FILE";
|
||||
default: return "WU_E_UNKNOWN";
|
||||
var symbol = lookup("0x" + hresult.ToString("x8", CultureInfo.InvariantCulture))?.Symbol;
|
||||
return string.IsNullOrEmpty(symbol) ? "WU_E_UNKNOWN" : symbol;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "WU_E_UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-1
@@ -65,6 +65,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
public static DiagnosticsUploadResult SkippedBy(string gate) =>
|
||||
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
|
||||
|
||||
/// <summary>
|
||||
/// Counters of the archive build that produced (or preceded) this result; null when no
|
||||
/// package was built (configuration gate, build exception). Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> / <c>diagnostics_upload_failed</c> events so a truncated or
|
||||
/// partially failed package is visible without opening the ZIP.
|
||||
/// </summary>
|
||||
public DiagnosticsPackagingStats Packaging { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the packager included, skipped and failed on — as bounded counters only. File paths
|
||||
/// stay inside the ZIP (<c>package-manifest.txt</c>, <c>_TRUNCATED.txt</c>): they can carry
|
||||
/// user names, and a skip list has no upper bound while this payload must stay small no
|
||||
/// matter how many files a cap rejected.
|
||||
/// </summary>
|
||||
public sealed class DiagnosticsPackagingStats
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>();
|
||||
|
||||
public int IncludedFiles { get; set; }
|
||||
public long IncludedBytes { get; set; }
|
||||
|
||||
/// <summary>Files rejected by a cap or a reparse-point check (the <c>_TRUNCATED.txt</c> list).</summary>
|
||||
public int SkippedFiles { get; set; }
|
||||
|
||||
/// <summary>True when at least one file was skipped — the same condition that writes <c>_TRUNCATED.txt</c>.</summary>
|
||||
public bool Truncated => SkippedFiles > 0;
|
||||
|
||||
/// <summary>Skip count per reason: <c>size</c>, <c>count</c>, <c>total</c>, <c>reparse</c>, <c>resolved-outside-folder</c>.</summary>
|
||||
public IReadOnlyDictionary<string, int> SkippedByReason { get; set; } = Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Packaging problems that are not cap skips, per kind: <c>path-guard</c> (configured path
|
||||
/// refused), <c>no-user-session</c> (user-profile token without a signed-in user),
|
||||
/// <c>folder-rejected</c>, <c>enumerate</c>, <c>open</c>, <c>copy</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> ProblemsByKind { get; set; } = Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,6 +172,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Skipped.Add(new SkipRecord(path, reason, size));
|
||||
|
||||
public bool HasSkips => Skipped.Count > 0;
|
||||
|
||||
// Non-cap problems (guard refusals, enumeration/open/copy failures) by kind — the
|
||||
// manifest names the files, the event carries only these counts.
|
||||
public Dictionary<string, int> Problems { get; } = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _problemKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Counts one problem of <paramref name="kind"/>. A section is walked once per search
|
||||
/// pattern, so a folder-level failure would otherwise be counted once per pattern —
|
||||
/// pass the folder as <paramref name="dedupeKey"/> to count it once.
|
||||
/// </summary>
|
||||
public void RecordProblem(string kind, string dedupeKey = null)
|
||||
{
|
||||
if (dedupeKey != null && !_problemKeys.Add(kind + "|" + dedupeKey)) return;
|
||||
Problems.TryGetValue(kind, out var n);
|
||||
Problems[kind] = n + 1;
|
||||
}
|
||||
|
||||
public DiagnosticsPackagingStats ToStats()
|
||||
{
|
||||
var byReason = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var s in Skipped)
|
||||
{
|
||||
byReason.TryGetValue(s.Reason, out var n);
|
||||
byReason[s.Reason] = n + 1;
|
||||
}
|
||||
return new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = FileCount,
|
||||
IncludedBytes = TotalBytes,
|
||||
SkippedFiles = Skipped.Count,
|
||||
SkippedByReason = byReason,
|
||||
ProblemsByKind = new Dictionary<string, int>(Problems, StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct SkipRecord
|
||||
@@ -262,6 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip";
|
||||
|
||||
var zipBytes = BuildArchiveBytes(enrollmentSucceeded);
|
||||
var packaging = LastPackaging;
|
||||
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
@@ -276,7 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend";
|
||||
_logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}");
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode };
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode, Packaging = packaging };
|
||||
}
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
@@ -305,6 +379,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +388,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
ErrorCode = uploadErrorCode,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -351,6 +427,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
/// <summary>Counters of the most recent <see cref="BuildArchiveBytes"/>; null before the first build.</summary>
|
||||
internal DiagnosticsPackagingStats LastPackaging { get; private set; }
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
@@ -391,6 +470,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
tracker.RecordProblem("path-guard");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
@@ -398,6 +478,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -427,6 +508,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
}
|
||||
|
||||
LastPackaging = tracker.ToStats();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -498,6 +580,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
|
||||
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -584,6 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics source folder rejected ({folderRejection}): {sourceFolder}");
|
||||
ManifestLine($"FOLDER REJECTED ({folderRejection}): {sourceFolder} (pattern '{searchPattern}')");
|
||||
tracker.RecordProblem("folder-rejected", sourceFolder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -597,6 +681,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
tracker.RecordProblem("enumerate", sourceFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -681,6 +766,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
tracker.RecordProblem("copy");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -720,6 +806,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
default:
|
||||
_logger.Warning($"Failed to open {file}: {rejection}");
|
||||
ManifestLine($"FAILED (open): {file}: {rejection}");
|
||||
tracker.RecordProblem("open");
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
|
||||
+2
@@ -278,6 +278,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
@@ -347,6 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
|
||||
+13
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
|
||||
/// gather output from re-triggering on_event rules.
|
||||
/// </summary>
|
||||
public const string SourceName = "GatherRuleExecutor";
|
||||
public const string SourceName = Constants.EventSources.GatherRuleExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on on_event executions per rule and session. on_event has no natural
|
||||
@@ -496,6 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
StampRuleMarkers(rule, result);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : Constants.EventTypes.GatherResult;
|
||||
|
||||
@@ -537,6 +538,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// ===== Phase scope (ActivePhases / ActiveFromPhase) =====
|
||||
|
||||
/// <summary>
|
||||
/// Injects the per-rule opt-in markers the backend reads at response time. Called after
|
||||
/// the on_change hash is taken (the markers are rule config, not collected data) by the
|
||||
/// executor and by collectors that emit directly (logparser).
|
||||
/// </summary>
|
||||
internal static void StampRuleMarkers(GatherRule rule, Dictionary<string, object> data)
|
||||
{
|
||||
if (rule.EnrichErrorCodes)
|
||||
data[Constants.GatherRuleDataKeys.EnrichErrorCodes] = true;
|
||||
}
|
||||
|
||||
private static bool HasPhaseScope(GatherRule rule)
|
||||
{
|
||||
return (rule.ActivePhases != null && rule.ActivePhases.Count > 0)
|
||||
|
||||
@@ -1628,6 +1628,15 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (app.AttemptNumber > 0) data["attemptNumber"] = app.AttemptNumber.ToString(culture);
|
||||
if (!string.IsNullOrEmpty(app.DetectionResult)) data["detectionResult"] = app.DetectionResult!;
|
||||
|
||||
// Installer outcome of the last attempt (IME-EXITCODE + IME-EXITCODE-CLASS). The class
|
||||
// is the admin's return-code mapping (Success/SoftReboot/HardReboot/Retry/Failed) and
|
||||
// the only evidence that an app asked for a reboot; the number says which code was
|
||||
// mapped (3010 vs 1641 vs custom). Both are known from the terminal lpExitCode lines
|
||||
// only, so they surface on the terminal events - completed, postponed, skipped and
|
||||
// failed alike - never on progress events.
|
||||
if (!string.IsNullOrEmpty(app.ExitCodeClass)) data["exitCodeClass"] = app.ExitCodeClass!;
|
||||
if (IsCompletedState(newState) && !string.IsNullOrEmpty(app.ExitCode)) data["exitCode"] = app.ExitCode!;
|
||||
|
||||
// Plan §5 Fix 4c — per-app install-lifecycle timing. StartedAt appears on the
|
||||
// first Downloading/Installing/InProgress event; CompletedAt + DurationSeconds
|
||||
// light up on the terminal event. Values are omitted when not yet known so the
|
||||
@@ -1644,7 +1653,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(app.ErrorPatternId)) data["errorPatternId"] = app.ErrorPatternId!;
|
||||
if (!string.IsNullOrEmpty(app.ErrorDetail)) data["errorDetail"] = app.ErrorDetail!;
|
||||
if (!string.IsNullOrEmpty(app.ErrorCode)) data["errorCode"] = app.ErrorCode!;
|
||||
if (!string.IsNullOrEmpty(app.ExitCode)) data["exitCode"] = app.ExitCode!;
|
||||
if (!string.IsNullOrEmpty(app.HResultFromWin32)) data["hresultFromWin32"] = app.HResultFromWin32!;
|
||||
|
||||
// Likely-stuck / detection-failure / install-failure classification: when the
|
||||
|
||||
+42
-17
@@ -1007,6 +1007,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1015,17 +1026,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
},
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName})."
|
||||
+ (result.Packaging != null && result.Packaging.Truncated
|
||||
? $" Truncated: {result.Packaging.SkippedFiles} file(s) skipped by the packaging caps."
|
||||
: string.Empty),
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
@@ -1033,6 +1038,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
var errorCode = result?.ErrorCode ?? "null-result";
|
||||
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}.");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result?.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1042,17 +1054,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics upload failed: {errorCode}.",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
},
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the packager's counters to a diagnostics event payload — counts only, never a
|
||||
/// file path (paths live in the ZIP's manifest and may carry user names). Absent when no
|
||||
/// package was built, so consumers treat the keys as optional.
|
||||
/// </summary>
|
||||
private static void AppendPackagingStats(Dictionary<string, object> data, DiagnosticsPackagingStats packaging)
|
||||
{
|
||||
if (packaging == null) return;
|
||||
data["truncated"] = packaging.Truncated;
|
||||
data["includedFiles"] = packaging.IncludedFiles;
|
||||
data["includedBytes"] = packaging.IncludedBytes;
|
||||
data["skippedFiles"] = packaging.SkippedFiles;
|
||||
data["skippedByReason"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.SkippedByReason ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
data["problemsByKind"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.ProblemsByKind ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
// On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the derivation shared by the agent channel (GET agent/config) and the Global-Admin
|
||||
/// report route (GET config/{tenantId}/effective-agent-config): every rule the Tenant Config
|
||||
/// Report used to re-implement in the web now has exactly one implementation, and these
|
||||
/// facts are its contract.
|
||||
/// </summary>
|
||||
public class AgentConfigResolverTests
|
||||
{
|
||||
private const string TenantId = "11111111-2222-3333-4444-555555555555";
|
||||
private static readonly DateTime Now = new(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Tenant(Action<TenantConfiguration>? mutate = null)
|
||||
{
|
||||
var c = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.example", UpdatedBy = "ga@operator.example" };
|
||||
mutate?.Invoke(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static AdminConfiguration Admin(Action<AdminConfiguration>? mutate = null)
|
||||
{
|
||||
var a = new AdminConfiguration();
|
||||
mutate?.Invoke(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static AgentConfigResponse Build(TenantConfiguration tenant, AdminConfiguration? admin = null, int agentMajor = AgentConfigResolver.CurrentAgentMajor)
|
||||
=> AgentConfigResolver.Build(tenant, admin ?? Admin(), new List<GatherRule>(), new List<ImeLogPattern>(), agentMajor, Now).Response;
|
||||
|
||||
[Fact]
|
||||
public void Fresh_tenant_gets_the_agent_defaults()
|
||||
{
|
||||
var r = Build(Tenant());
|
||||
|
||||
Assert.Equal(AgentConfigResolver.ConfigVersion, r.ConfigVersion);
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.DefaultUploadIntervalSeconds, r.UploadIntervalSeconds);
|
||||
Assert.Equal(10, r.UploadIntervalSeconds); // the report showed 30 for a month — pinned
|
||||
Assert.True(r.SelfDestructOnComplete);
|
||||
Assert.False(r.KeepLogFile);
|
||||
Assert.True(r.EnableGeoLocation);
|
||||
Assert.Equal(5, r.MaxAuthFailures);
|
||||
Assert.Equal(0, r.AuthFailureTimeoutMinutes);
|
||||
Assert.Equal("Info", r.LogLevel);
|
||||
Assert.Equal(10, r.RebootDelaySeconds);
|
||||
Assert.Equal(60, r.EnrollmentSummaryTimeoutSeconds);
|
||||
Assert.Equal(120, r.EnrollmentSummaryLaunchRetrySeconds);
|
||||
Assert.Equal(100, r.MaxBatchSize);
|
||||
Assert.Equal("time.windows.com", r.NtpServer);
|
||||
Assert.Equal("Off", r.DiagnosticsUploadMode);
|
||||
Assert.False(r.DiagnosticsUploadEnabled);
|
||||
Assert.False(r.UnrestrictedMode);
|
||||
Assert.False(r.DeviceBlocked);
|
||||
Assert.False(r.DeviceKillSignal);
|
||||
Assert.Null(r.UnblockAt);
|
||||
Assert.Null(r.MigrateToApiBaseUrl);
|
||||
|
||||
Assert.Equal(360, r.Collectors.AgentMaxLifetimeMinutes);
|
||||
Assert.Equal(15, r.Collectors.CollectorIdleTimeoutMinutes);
|
||||
Assert.Equal(10, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes);
|
||||
Assert.Equal(new[] { 100, 1005, 1010 }, r.Collectors.ModernDeploymentHarmlessEventIds);
|
||||
|
||||
Assert.True(r.Analyzers.EnableLocalAdminAnalyzer);
|
||||
Assert.Empty(r.Analyzers.LocalAdminAllowedAccounts);
|
||||
Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer);
|
||||
Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer);
|
||||
Assert.False(r.Analyzers.EnableRealmJoinWatcher);
|
||||
Assert.False(r.Analyzers.KeepAwakeDuringUserEsp);
|
||||
Assert.True(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "CustomerSas", "Off", false)]
|
||||
[InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS
|
||||
[InlineData("https://acct.blob.core.windows.net/c?sv=1", "CustomerSas", "Off", true)] // SAS enables regardless of mode
|
||||
public void Diagnostics_upload_enabled_follows_sas_or_hosted_not_the_mode(string? sas, string destination, string mode, bool expected)
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.DiagnosticsBlobSasUrl = sas!;
|
||||
t.DiagnosticsUploadDestination = destination;
|
||||
t.DiagnosticsUploadMode = mode;
|
||||
}));
|
||||
Assert.Equal(expected, r.DiagnosticsUploadEnabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free", true, true, false)] // Community: entitlement missing
|
||||
[InlineData("pro", false, true, false)] // GA gate off
|
||||
[InlineData("pro", true, false, false)] // tenant toggle off
|
||||
[InlineData("pro", true, true, true)]
|
||||
public void Unrestricted_mode_needs_entitlement_and_gate_and_toggle(string planTier, bool gaGate, bool toggle, bool expected)
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.PlanTier = planTier;
|
||||
t.UnrestrictedModeEnabled = gaGate;
|
||||
t.UnrestrictedMode = toggle;
|
||||
}));
|
||||
Assert.Equal(expected, r.UnrestrictedMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operator_knobs_come_from_the_admin_configuration()
|
||||
{
|
||||
var admin = Admin(a =>
|
||||
{
|
||||
a.CollectorIdleTimeoutMinutes = 25;
|
||||
a.DesktopDetectorNoCandidateTimeoutMinutes = 7;
|
||||
a.AllowAgentDowngrade = true;
|
||||
a.ModernDeploymentHarmlessEventIdsJson = "[100, 42]";
|
||||
a.LatestAgentV2Sha256 = "zip-v2";
|
||||
a.LatestAgentV2ExeSha256 = "exe-v2";
|
||||
});
|
||||
|
||||
var r = Build(Tenant(), admin);
|
||||
|
||||
Assert.Equal(25, r.Collectors.CollectorIdleTimeoutMinutes);
|
||||
Assert.Equal(7, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes);
|
||||
Assert.True(r.AllowAgentDowngrade);
|
||||
Assert.Equal(new[] { 100, 42 }, r.Collectors.ModernDeploymentHarmlessEventIds);
|
||||
Assert.Equal("zip-v2", r.LatestAgentSha256);
|
||||
Assert.Equal("exe-v2", r.LatestAgentExeSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_major_gets_empty_hashes_the_current_major_gets_v2()
|
||||
{
|
||||
var admin = Admin(a => { a.LatestAgentV2Sha256 = "zip-v2"; a.LatestAgentV2ExeSha256 = "exe-v2"; });
|
||||
|
||||
var legacy = Build(Tenant(), admin, agentMajor: 1);
|
||||
Assert.Equal(string.Empty, legacy.LatestAgentSha256);
|
||||
|
||||
var current = Build(Tenant(), admin, agentMajor: AgentConfigResolver.CurrentAgentMajor);
|
||||
Assert.Equal("zip-v2", current.LatestAgentSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_overrides_win_over_defaults()
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.MaxBatchSize = 25;
|
||||
t.LogLevel = "Debug";
|
||||
t.NtpServer = "";
|
||||
t.LocalAdminAllowedAccountsJson = "[\"Admin\",\"Helpdesk\"]";
|
||||
t.AgentMaxLifetimeMinutes = 120;
|
||||
t.EnableConsoleBypassDetection = false;
|
||||
}));
|
||||
|
||||
Assert.Equal(25, r.MaxBatchSize);
|
||||
Assert.Equal("Debug", r.LogLevel);
|
||||
Assert.Equal("time.windows.com", r.NtpServer); // empty string falls back like null
|
||||
Assert.Equal(new[] { "Admin", "Helpdesk" }, r.Analyzers.LocalAdminAllowedAccounts);
|
||||
Assert.Equal(120, r.Collectors.AgentMaxLifetimeMinutes);
|
||||
Assert.False(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Migration_target_is_served_when_valid_and_reported_when_rejected()
|
||||
{
|
||||
const string Us = "https://autopilotmonitor-api-us.azurewebsites.net";
|
||||
var served = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = Us),
|
||||
new List<GatherRule>(), new List<ImeLogPattern>(), AgentConfigResolver.CurrentAgentMajor, Now);
|
||||
Assert.Equal(Us, served.Response.MigrateToApiBaseUrl);
|
||||
Assert.Null(served.RejectedMigrateCandidate);
|
||||
|
||||
var rejected = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = "https://evil.example"),
|
||||
new List<GatherRule>(), new List<ImeLogPattern>(), AgentConfigResolver.CurrentAgentMajor, Now);
|
||||
Assert.Null(rejected.Response.MigrateToApiBaseUrl);
|
||||
Assert.Equal("https://evil.example", rejected.RejectedMigrateCandidate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Catalogs_pass_through_unchanged()
|
||||
{
|
||||
var rules = new List<GatherRule> { new() { RuleId = "GATHER-TEST-001" } };
|
||||
var patterns = new List<ImeLogPattern> { new() { PatternId = "IME-TEST-001" } };
|
||||
var r = AgentConfigResolver.Build(Tenant(), Admin(), rules, patterns, AgentConfigResolver.CurrentAgentMajor, Now).Response;
|
||||
Assert.Same(rules, r.GatherRules);
|
||||
Assert.Same(patterns, r.ImeLogPatterns);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using System.Threading;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class AgentEfficiencyMetricsServiceTests
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
@@ -67,7 +68,7 @@ public class AgentEfficiencyMetricsServiceTests
|
||||
private static void SetupSessions(Mock<ISessionRepository> repo, List<SessionSummary> sessions)
|
||||
{
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
}
|
||||
|
||||
@@ -201,8 +202,8 @@ public class AgentEfficiencyMetricsServiceTests
|
||||
string? capturedTenant = null;
|
||||
IEnumerable<string>? capturedSelect = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((t, _, _, _, _, sel) => { capturedTenant = t; capturedSelect = sel; })
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?, CancellationToken>((t, _, _, _, _, sel, _) => { capturedTenant = t; capturedSelect = sel; })
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
|
||||
var result = await service.ComputeAsync(days: 45, limit: 100, tenantId: Tenant);
|
||||
@@ -220,7 +221,7 @@ public class AgentEfficiencyMetricsServiceTests
|
||||
var (service, repo) = CreateService();
|
||||
int callCount = 0;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => callCount++)
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Xunit;
|
||||
@@ -8,7 +8,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the endpoint-migration serving contract: the shared allowlist rules
|
||||
/// (<see cref="AgentEndpointMigrationRules"/>, used verbatim by the agent too) and the
|
||||
/// backend-side per-tenant resolution (<see cref="GetAgentConfigFunction.ResolveMigrateTarget"/> —
|
||||
/// backend-side per-tenant resolution (<see cref="AgentConfigResolver.ResolveMigrateTarget"/> —
|
||||
/// tenant override wins, empty override pins, invalid values are never served).
|
||||
/// </summary>
|
||||
public class AgentEndpointMigrationTests
|
||||
@@ -75,7 +75,7 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration();
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(rejected);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = UsUrl };
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,8 +97,8 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"{EuUrl}\"}}"
|
||||
};
|
||||
|
||||
Assert.Equal(EuUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(EuUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,9 +110,9 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"\"}}"
|
||||
};
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(rejected); // pinned, not rejected
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +123,7 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA.ToUpperInvariant()}\": \"{UsUrl}\"}}"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -131,7 +131,7 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = "https://attacker.example.com" };
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Equal("https://attacker.example.com", rejected);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = "{not json"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -155,6 +155,6 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateApiBaseUrl = "https://AutopilotMonitor-API-US.azurewebsites.net/"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +356,8 @@ public class ApplicationPrincipalTests
|
||||
var members = new StubTenantMemberRoleResolver();
|
||||
var sut = new McpUserService(
|
||||
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
return (sut, members);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ public class AuthFunctionSideEffectTests
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object))
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
|
||||
@@ -55,6 +55,76 @@ public class AuthFunctionTests
|
||||
Assert.True((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_SurfacesWhatsNewSeenMarks()
|
||||
{
|
||||
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
whatsNewSeenPlatformUtc: platformSeen,
|
||||
whatsNewSeenAgentUtc: agentSeen);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal(platformSeen, (DateTime)body.whatsNewSeenPlatformUtc);
|
||||
Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
[InlineData("agent", "agent")]
|
||||
public void TryNormalizeWhatsNewSeen_AcceptsKnownChannels(string input, string expected)
|
||||
{
|
||||
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = input, SeenUtc = seen },
|
||||
seen.AddMinutes(1),
|
||||
out var channel,
|
||||
out var seenUtc,
|
||||
out var error);
|
||||
|
||||
Assert.True(ok, error);
|
||||
Assert.Equal(expected, channel);
|
||||
Assert.Equal(seen, seenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeWhatsNewSeen_RejectsUnknownChannel()
|
||||
{
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = "other", SeenUtc = DateTime.UtcNow },
|
||||
DateTime.UtcNow,
|
||||
out _,
|
||||
out _,
|
||||
out var error);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("channel must be 'platform' or 'agent'", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeWhatsNewSeen_ClampsFarFutureTimestamp()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = "agent", SeenUtc = now.AddMinutes(6) },
|
||||
now,
|
||||
out _,
|
||||
out var seenUtc,
|
||||
out var error);
|
||||
|
||||
Assert.True(ok, error);
|
||||
Assert.Equal(now, seenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_OperatorUser_ReturnsCorrectRole()
|
||||
{
|
||||
|
||||
@@ -47,6 +47,12 @@ public class AutopilotDeviceValidatorTests
|
||||
new(status) { Content = new StringContent(body) };
|
||||
|
||||
private static (AutopilotDeviceValidator Sut, DelayingHandler Handler, MemoryCache Cache) BuildSut(params HttpResponseMessage?[] responses)
|
||||
{
|
||||
var built = BuildSutWithTokens(responses);
|
||||
return (built.Sut, built.Handler, built.Cache);
|
||||
}
|
||||
|
||||
private static (AutopilotDeviceValidator Sut, DelayingHandler Handler, MemoryCache Cache, Mock<GraphTokenService> Tokens) BuildSutWithTokens(params HttpResponseMessage?[] responses)
|
||||
{
|
||||
var handler = new DelayingHandler(responses);
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
@@ -71,10 +77,52 @@ public class AutopilotDeviceValidatorTests
|
||||
|
||||
var sut = new AutopilotDeviceValidator(
|
||||
NullLogger<AutopilotDeviceValidator>.Instance, factory.Object, cache, tokenService.Object);
|
||||
return (sut, handler, cache);
|
||||
return (sut, handler, cache, tokenService);
|
||||
}
|
||||
|
||||
private const string FoundBody = "{\"value\":[{\"id\":\"ap-1\",\"serialNumber\":\"PF3XKQ7\"}]}";
|
||||
private const string ForbiddenBody = "{\"error\":{\"code\":\"Authorization_RequestDenied\",\"message\":\"Application is not authorized to perform this operation.\"}}";
|
||||
|
||||
[Fact]
|
||||
public async Task StaleTokenForbidden_ThenSuccess_InvalidatesOnce_AndValidatesWithTheFreshToken()
|
||||
{
|
||||
// Field case 2026-09-02: a second instance held a roleless app-only token minted before the
|
||||
// tenant's consent; Graph answered 403 for 45 minutes. Attempt 1 must drop that token and
|
||||
// attempt 2 must succeed with the re-minted one — no 503 loop, no cached negative.
|
||||
var (sut, handler, cache, tokens) = BuildSutWithTokens(
|
||||
Json(ForbiddenBody, HttpStatusCode.Forbidden),
|
||||
Json(FoundBody));
|
||||
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("ap-1", result.AutopilotDeviceId);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
tokens.Verify(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
Assert.True(cache.TryGetValue($"autopilot-device-validation:{TenantId}:{Serial}", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForbiddenWithFreshToken_StaysTransient_WithLongerRetryAfter_NotCached()
|
||||
{
|
||||
// Consent genuinely missing (or not yet propagated): the agent must keep getting 503 (a 403
|
||||
// would make it shut down as "device not registered"), but with the permission-gap
|
||||
// Retry-After and nothing cached so a granted consent takes effect on the next call.
|
||||
var (sut, handler, cache, tokens) = BuildSutWithTokens(
|
||||
Json(ForbiddenBody, HttpStatusCode.Forbidden),
|
||||
Json(ForbiddenBody, HttpStatusCode.Forbidden));
|
||||
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Equal(GraphAuthFailure.PermissionMissingRetryAfterSeconds, result.RetryAfterSeconds);
|
||||
Assert.Contains("permission missing", result.ErrorMessage);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
Assert.False(cache.TryGetValue($"autopilot-device-validation:{TenantId}:{Serial}", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_defaults_sit_inside_the_agents_30s_client_timeout()
|
||||
|
||||
@@ -53,4 +53,94 @@ public class BuiltInRuleCatalogPolicyTests
|
||||
Assert.True(duplicates.Count == 0,
|
||||
$"Duplicate built-in gather rule IDs: {string.Join(", ", duplicates)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Built-in rules that assert an ABSENCE (required not_exists) of an event the agent derives
|
||||
/// from the IME logs, and deliberately do NOT carry the ime_tracker_degraded coverage gate.
|
||||
/// Every entry is a decision with a reason; adding one here is the way to ship such a rule.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> AbsenceWithoutCoverageGate =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ANALYZE-ID-004"] =
|
||||
"A hybrid session whose user never obtained an Entra token must not lose its finding because " +
|
||||
"the tracker skipped the remaining patterns on one line; the tracker state is shown next to " +
|
||||
"the finding in get_session_summary (coverage) instead (plan session-coverage-and-absence-audit).",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Required_not_exists_on_ime_derived_events_is_coverage_gated_or_documented()
|
||||
{
|
||||
// "Absence proves nothing": a required not_exists on an IME-log-derived event evaluates
|
||||
// to true both when the event never happened and when the IME log tracker skipped the
|
||||
// line that carried it (ime_tracker_degraded). A built-in rule that asserts such an
|
||||
// absence either suppresses itself on degraded sessions (precondition
|
||||
// ime_tracker_degraded not_exists) or is listed above with the reason it does not.
|
||||
// The IME-derived list is the guardrails.json mirror the MCP validate_rule lint reads,
|
||||
// so catalog and pre-flight judge by one list.
|
||||
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
|
||||
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
|
||||
var imeDerived = new HashSet<string>(
|
||||
json["imeLogDerivedEventTypes"]!.ToObject<string[]>()!, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("ime_user_token_acquired", imeDerived);
|
||||
|
||||
var offenders = new List<string>();
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
var assertsImeAbsence = rule.Conditions.Any(c =>
|
||||
c.Required
|
||||
&& string.Equals(c.Operator, "not_exists", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(c.DataField)
|
||||
&& !string.IsNullOrEmpty(c.EventType)
|
||||
&& imeDerived.Contains(c.EventType));
|
||||
if (!assertsImeAbsence) continue;
|
||||
|
||||
var gated = rule.Preconditions.Any(p =>
|
||||
string.Equals(p.EventType, "ime_tracker_degraded", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(p.Operator, "not_exists", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(p.DataField));
|
||||
if (gated || AbsenceWithoutCoverageGate.ContainsKey(rule.RuleId)) continue;
|
||||
offenders.Add(rule.RuleId);
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"Built-in rules assert the absence of an IME-log-derived event without the " +
|
||||
"ime_tracker_degraded coverage precondition and without a documented exception: " +
|
||||
string.Join(", ", offenders));
|
||||
|
||||
// The exception list must not outlive the rules it excuses.
|
||||
var catalogIds = new HashSet<string>(BuiltInAnalyzeRules.GetAll().Select(r => r.RuleId), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var excused in AbsenceWithoutCoverageGate.Keys)
|
||||
Assert.True(catalogIds.Contains(excused), $"Exception list names a rule that is not in the catalog: {excused}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_derived_list_names_only_real_event_types_and_the_tracker_event_exists()
|
||||
{
|
||||
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
|
||||
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
|
||||
var imeDerived = json["imeLogDerivedEventTypes"]!.ToObject<string[]>()!;
|
||||
|
||||
var known = new HashSet<string>(
|
||||
typeof(AutopilotMonitor.Shared.Constants.EventTypes)
|
||||
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var phantom = imeDerived.Where(t => !known.Contains(t)).ToList();
|
||||
Assert.True(phantom.Count == 0, $"imeLogDerivedEventTypes lists unknown event types: {string.Join(", ", phantom)}");
|
||||
Assert.Contains(AutopilotMonitor.Shared.Constants.EventTypes.ImeTrackerDegraded, known);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public class BuiltInRulesTests
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
"captureExitCode", "captureHResult",
|
||||
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
||||
"captureAttemptNumber", "captureDetectionResult"
|
||||
"captureAttemptNumber", "captureDetectionResult", "captureExitCodeClass"
|
||||
};
|
||||
|
||||
var patterns = BuiltInImeLogPatterns.GetAll();
|
||||
|
||||
@@ -224,17 +224,36 @@ public class CorporateIdentifierValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_graphPermissionDenied_isCached_notRetried()
|
||||
public async Task ValidateAsync_graphPermissionDenied_isCached_afterOneFreshTokenRetry()
|
||||
{
|
||||
// Definitive permission failures are negative-cached so an agent retry storm does not
|
||||
// hammer Graph while consent is being fixed; the in-call retry loop must not fire either.
|
||||
// hammer Graph while consent is being fixed. Attempt 1 may have used a token minted before
|
||||
// the consent (GraphAuthFailure), so exactly ONE retry with a fresh token precedes the
|
||||
// verdict; the cached verdict then answers the next call without Graph.
|
||||
var (sut, handler) = BuildSut(Json("""{"error":{"code":"Forbidden"}}""", HttpStatusCode.Forbidden));
|
||||
|
||||
await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
var first = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
var second = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(first.IsTransient);
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_staleTokenForbidden_thenFound_validatesWithTheFreshToken()
|
||||
{
|
||||
// Field case 2026-09-02 in this validator's shape: a stale roleless token gets 403, the
|
||||
// fresh one finds the identifier. Without the retry the stale token would have been cached
|
||||
// as "not permitted" for 5 minutes, every 5 minutes, until its 55-minute TTL.
|
||||
var (sut, handler) = BuildSut(
|
||||
Json("""{"error":{"code":"Forbidden"}}""", HttpStatusCode.Forbidden),
|
||||
Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -182,6 +182,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("PATCH", "/api/config/{0}/plan")]
|
||||
[InlineData("GET", "/api/config/{0}/effective-agent-config")]
|
||||
[InlineData("POST", "/api/preview/whitelist/{0}")]
|
||||
[InlineData("DELETE", "/api/preview/whitelist/{0}")]
|
||||
[InlineData("POST", "/api/preview/send-welcome-email/{0}")]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class EndpointNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_StripsApiPrefixReplacesGuidsAndKeyChars()
|
||||
{
|
||||
var normalized = EndpointNormalizer.Normalize("/api/Sessions/0f8fad5b-d9cb-469f-a165-70867728950e/events?x=1");
|
||||
Assert.Equal("sessions_{id}_events_x=1", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_RealToolName_PassesThroughUnchanged()
|
||||
{
|
||||
// Existing UserUsageLog rows are keyed by "<tool>:<endpoint>"; a real tool name must keep its key.
|
||||
Assert.Equal("get_session_events", EndpointNormalizer.ToolNameKey("get_session_events"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_NullOrEmpty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(null));
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_WithCrlfAndControlChars_StripsThem()
|
||||
{
|
||||
// The log-forging vector: a header that tries to inject a fake log line through the row key.
|
||||
var forged = $"get_metrics\r\n[McpQuota] FAKE{(char)0}{(char)27}{(char)0x2028}x";
|
||||
var key = EndpointNormalizer.ToolNameKey(forged);
|
||||
Assert.Equal("get_metrics[McpQuota] FAKEx", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_WithTableKeyIllegalChars_ReplacesThem()
|
||||
{
|
||||
// '/', '\', '#', '?' are rejected by Table Storage in keys — a header carrying them would make
|
||||
// every usage increment fail with 400, so they are replaced the same way the path is.
|
||||
Assert.Equal("a_b_c_d_e", EndpointNormalizer.ToolNameKey("a/b\\c#d?e"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_LongerThanCap_IsTruncated()
|
||||
{
|
||||
var key = EndpointNormalizer.ToolNameKey(new string('t', EndpointNormalizer.MaxToolNameLength + 10));
|
||||
Assert.Equal(EndpointNormalizer.MaxToolNameLength, key.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_OnlyControlChars_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey("\r\n "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ANALYZE-APP-018 — pins the firing behaviour of the reboot-return-code rule on the agent's
|
||||
/// <c>exitCodeClass</c> payload (IME-EXITCODE-CLASS: the admin-defined return-code class of the
|
||||
/// installer exit code, one of Success/SoftReboot/HardReboot/Retry/Failed):
|
||||
/// - SoftReboot or HardReboot on an app_install_completed event fires at base confidence,
|
||||
/// - HardReboot and an observed system_reboot_detected add confidence (ESP-005's twin),
|
||||
/// - Success, Retry and Failed never fire, and neither does a class on app_install_failed alone.
|
||||
/// The required condition uses the comma-separated <c>in</c> operator; the matched event's
|
||||
/// data fields feed the {{appName}} / {{exitCode}} / {{exitCodeClass}} interpolation.
|
||||
/// </summary>
|
||||
public class ExitCodeClassRuleTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "11111111-2222-3333-4444-555555555555";
|
||||
private const string RuleId = "ANALYZE-APP-018";
|
||||
|
||||
private static AnalyzeRule Rule() => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == RuleId);
|
||||
|
||||
[Fact]
|
||||
public void Rule_is_a_warning_that_never_fails_the_session()
|
||||
{
|
||||
var rule = Rule();
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("warning", rule.Severity);
|
||||
Assert.Equal("apps", rule.Category);
|
||||
Assert.False(rule.MarkSessionAsFailedDefault);
|
||||
Assert.Contains("whiteglove_sealed", rule.EvaluateOn ?? new List<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftReboot_on_a_completed_app_fires_at_base_confidence_without_an_observed_reboot()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppCompleted("Contoso VPN Client", exitCode: "3010", exitCodeClass: "SoftReboot"),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal(RuleId, result.RuleId);
|
||||
Assert.Equal("warning", result.Severity);
|
||||
Assert.Equal(60, result.ConfidenceScore);
|
||||
|
||||
// The evidence is persisted and interpolated at render time (portal, MCP
|
||||
// interpolate-rule-template.ts): {{exitCodeClass}} resolves through the condition's
|
||||
// field/value pair, {{appName}} and {{exitCode}} through the whitelisted payload keys
|
||||
// (AddDataFieldsToEvidence) — every token the rule text uses must be reachable here.
|
||||
var matched = AsDict(result.MatchedConditions["reboot_return_code"]);
|
||||
Assert.Equal("exitCodeClass", AsString(matched["field"]));
|
||||
Assert.Equal("SoftReboot", AsString(matched["value"]));
|
||||
Assert.Equal("Contoso VPN Client", AsString(matched["appName"]));
|
||||
Assert.Equal("3010", AsString(matched["exitCode"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HardReboot_with_an_observed_reboot_reaches_full_confidence()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppCompleted("Contoso Security Agent", exitCode: "1641", exitCodeClass: "HardReboot", state: "Postponed"),
|
||||
RebootDetected(),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal(100, result.ConfidenceScore);
|
||||
Assert.True(result.MatchedConditions.ContainsKey("hard_reboot_return_code"));
|
||||
Assert.True(result.MatchedConditions.ContainsKey("reboot_observed"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Success")]
|
||||
[InlineData("Retry")]
|
||||
[InlineData("Failed")]
|
||||
public async Task Other_return_code_classes_do_not_fire(string exitCodeClass)
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppCompleted("Contoso Reader", exitCode: "0", exitCodeClass: exitCodeClass),
|
||||
RebootDetected(),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_completed_app_without_the_class_does_not_fire_even_with_a_reboot_exit_code()
|
||||
{
|
||||
// Older agents emit exitCode only on failures and never the class; 3010 alone is not
|
||||
// evidence of the mapping the admin chose.
|
||||
var evt = AppCompleted("Legacy App", exitCode: "3010", exitCodeClass: null);
|
||||
var outcome = await RunAsync(new List<EnrollmentEvent> { evt, RebootDetected() });
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reboot_class_on_a_failed_app_alone_does_not_fire()
|
||||
{
|
||||
// The rule reads the terminal completed/postponed event; a failure carrying the class is
|
||||
// covered by the app-failure rules and must not double-report here.
|
||||
var failed = AppCompleted("Contoso Agent", exitCode: "1641", exitCodeClass: "HardReboot");
|
||||
failed.EventType = "app_install_failed";
|
||||
failed.Data!["state"] = "Error";
|
||||
failed.Data["isError"] = "true";
|
||||
|
||||
var outcome = await RunAsync(new List<EnrollmentEvent> { failed, RebootDetected() });
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
// ===== Event builders — mirror ImeLogTrackerAdapter.BuildAppStatePayload =====
|
||||
|
||||
private static int s_sequence = 10;
|
||||
|
||||
private static EnrollmentEvent AppCompleted(string appName, string exitCode, string? exitCodeClass, string state = "Installed")
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = Guid.NewGuid().ToString(),
|
||||
["appName"] = appName,
|
||||
["state"] = state,
|
||||
["intent"] = "Install",
|
||||
["targeted"] = "Device",
|
||||
["runAs"] = "System",
|
||||
["progressPercent"] = "100",
|
||||
["bytesDownloaded"] = "0",
|
||||
["bytesTotal"] = "0",
|
||||
["isError"] = "false",
|
||||
["isCompleted"] = "true",
|
||||
["exitCode"] = exitCode,
|
||||
};
|
||||
if (exitCodeClass != null) data["exitCodeClass"] = exitCodeClass;
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "app_install_completed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = s_sequence++,
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
|
||||
private static EnrollmentEvent RebootDetected() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "system_reboot_detected",
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(2),
|
||||
Sequence = s_sequence++,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["bootTime"] = DateTime.UtcNow.AddMinutes(1).ToString("o"),
|
||||
},
|
||||
};
|
||||
|
||||
private static Dictionary<string, object> AsDict(object o)
|
||||
{
|
||||
if (o is Dictionary<string, object> d) return d;
|
||||
throw new InvalidOperationException($"Expected Dictionary<string,object>, got {o?.GetType().Name ?? "null"}");
|
||||
}
|
||||
|
||||
private static string AsString(object o) => o?.ToString() ?? string.Empty;
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(List<EnrollmentEvent> events)
|
||||
{
|
||||
var rule = Rule();
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<ExitCodeClassRuleTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -147,11 +147,11 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(6, payload.ModelHealth.Count);
|
||||
Assert.Equal("Contoso Model7", payload.ModelHealth[0].Model);
|
||||
Assert.Equal("Contoso Model7", payload.ModelHealth[0].Label);
|
||||
Assert.Equal(7, payload.ModelHealth[0].Total);
|
||||
Assert.Equal(7, payload.ModelHealth[0].Succeeded);
|
||||
// The smallest (volume 1) is dropped by the top-6 cut.
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Contoso Model1");
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Label == "Contoso Model1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -182,7 +182,47 @@ public class FleetHealthPayloadTests
|
||||
new List<SessionSummary> { S(SessionStatus.Succeeded, manufacturer: "", model: "") },
|
||||
days: 7);
|
||||
|
||||
Assert.Equal("Unknown", Assert.Single(payload.ModelHealth).Model);
|
||||
var m = Assert.Single(payload.ModelHealth);
|
||||
Assert.Equal("Unknown", m.Label);
|
||||
Assert.Equal("", m.Manufacturer);
|
||||
Assert.Equal("", m.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModelBuckets_CarryManufacturerAndModelSeparately_OnAllThreeLists()
|
||||
{
|
||||
// The dashboard deep link addresses the two session fields as two qualified search
|
||||
// terms; a client must never have to split the label again.
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, durationSeconds: 600, manufacturer: " Contoso ", model: "EliteBook 840"),
|
||||
S(SessionStatus.Failed, manufacturer: "Contoso", model: "EliteBook 840"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
var health = Assert.Single(payload.ModelHealth);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (health.Manufacturer, health.Model, health.Label));
|
||||
Assert.Equal(2, health.Total);
|
||||
var slow = Assert.Single(payload.SlowestModels);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (slow.Manufacturer, slow.Model, slow.Label));
|
||||
var failing = Assert.Single(payload.TopFailingModels);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (failing.Manufacturer, failing.Model, failing.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModelBuckets_GroupOnThePair_NotOnTheLabel()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, manufacturer: "Contoso", model: "A B"),
|
||||
S(SessionStatus.Succeeded, manufacturer: "Contoso A", model: "B"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
Assert.Equal(2, payload.ModelHealth.Count);
|
||||
Assert.All(payload.ModelHealth, m => Assert.Equal("Contoso A B", m.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -199,10 +239,10 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(2, payload.SlowestModels.Count);
|
||||
Assert.Equal("Contoso Slow", payload.SlowestModels[0].Model);
|
||||
Assert.Equal("Contoso Slow", payload.SlowestModels[0].Label);
|
||||
Assert.Equal(30, payload.SlowestModels[0].AvgMinutes);
|
||||
Assert.Equal(1, payload.SlowestModels[0].Count);
|
||||
Assert.Equal("Contoso Fast", payload.SlowestModels[1].Model);
|
||||
Assert.Equal("Contoso Fast", payload.SlowestModels[1].Label);
|
||||
Assert.Equal(10, payload.SlowestModels[1].AvgMinutes);
|
||||
}
|
||||
|
||||
@@ -227,13 +267,13 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(2, payload.TopFailingModels.Count);
|
||||
Assert.Equal("Contoso B", payload.TopFailingModels[0].Model);
|
||||
Assert.Equal("Contoso B", payload.TopFailingModels[0].Label);
|
||||
Assert.Equal(2, payload.TopFailingModels[0].Failed);
|
||||
Assert.Equal(100, payload.TopFailingModels[0].FailureRate);
|
||||
Assert.Equal("Contoso A", payload.TopFailingModels[1].Model);
|
||||
Assert.Equal("Contoso A", payload.TopFailingModels[1].Label);
|
||||
Assert.Equal(4, payload.TopFailingModels[1].Total);
|
||||
Assert.Equal(50, payload.TopFailingModels[1].FailureRate);
|
||||
Assert.DoesNotContain(payload.TopFailingModels, m => m.Model == "Contoso C");
|
||||
Assert.DoesNotContain(payload.TopFailingModels, m => m.Label == "Contoso C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -129,6 +129,29 @@ public class GatherRuleScopeFieldsTests
|
||||
Assert.Null(mapped.ActivePhases);
|
||||
Assert.Null(mapped.ActiveFromPhase);
|
||||
Assert.Null(mapped.EmitMode);
|
||||
Assert.False(mapped.EnrichErrorCodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_EnrichErrorCodes_SurvivesStoreAndMap()
|
||||
{
|
||||
var (service, lastUpserted) = BuildStorageHarness();
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-ENRICH-001",
|
||||
Title = "MSI log parser",
|
||||
CollectorType = "logparser",
|
||||
Target = "C:\\Windows\\Temp\\msi.log",
|
||||
Trigger = "interval",
|
||||
IntervalSeconds = 60,
|
||||
OutputEventType = "gather_msi",
|
||||
EnrichErrorCodes = true,
|
||||
};
|
||||
|
||||
Assert.True(await service.StoreGatherRuleAsync(rule, "global"));
|
||||
var mapped = service.MapToGatherRule(lastUpserted()!);
|
||||
|
||||
Assert.True(mapped.EnrichErrorCodes);
|
||||
}
|
||||
|
||||
// ── ContentEquivalent ──────────────────────────────────────────────────
|
||||
@@ -175,6 +198,10 @@ public class GatherRuleScopeFieldsTests
|
||||
var emit = BaseRule();
|
||||
emit.EmitMode = "on_change";
|
||||
Assert.False(GatherRuleService.ContentEquivalent(baseline, emit));
|
||||
|
||||
var enrich = BaseRule();
|
||||
enrich.EnrichErrorCodes = true;
|
||||
Assert.False(GatherRuleService.ContentEquivalent(baseline, enrich));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -37,6 +37,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
|
||||
ActiveFromPhase = "AccountSetup",
|
||||
EmitMode = "on_change",
|
||||
EnrichErrorCodes = true,
|
||||
};
|
||||
|
||||
private static (GatherRuleService service, Mock<IRuleRepository> repo, List<GatherRule> stored) BuildService(
|
||||
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
Assert.Equal("Version", written.Parameters["valueName"]);
|
||||
Assert.Equal("AccountSetup", written.ActiveFromPhase);
|
||||
Assert.Equal("on_change", written.EmitMode);
|
||||
Assert.True(written.EnrichErrorCodes);
|
||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GetAgentConfigFunction.ParseAgentMajor"/> — the pure decision logic that picks
|
||||
/// Tests for <see cref="AgentConfigResolver.ParseAgentMajor"/> — the pure decision logic that picks
|
||||
/// the per-line hash-oracle field set based on the X-Agent-Version header value.
|
||||
/// </summary>
|
||||
public class GetAgentConfigFunctionTests
|
||||
@@ -29,7 +29,7 @@ public class GetAgentConfigFunctionTests
|
||||
[InlineData("vNEXT", 1)]
|
||||
public void ParseAgentMajor_ExtractsMajor(string? agentVersion, int expectedMajor)
|
||||
{
|
||||
var actual = GetAgentConfigFunction.ParseAgentMajor(agentVersion);
|
||||
var actual = AgentConfigResolver.ParseAgentMajor(agentVersion);
|
||||
Assert.Equal(expectedMajor, actual);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public class GetAgentConfigFunctionTests
|
||||
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
|
||||
};
|
||||
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
|
||||
var merged = AgentConfigResolver.MergeDiagnosticsLogPaths(global, tenant);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
@@ -63,7 +63,7 @@ public class GetAgentConfigFunctionTests
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
|
||||
{
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
|
||||
var merged = AgentConfigResolver.MergeDiagnosticsLogPaths(
|
||||
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
|
||||
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
|
||||
|
||||
@@ -90,7 +90,7 @@ public class GetAgentConfigFunctionTests
|
||||
public void ResolveDiagnosticsUploadEnabled_GatesOnSasOrHosted(
|
||||
string? diagnosticsBlobSasUrl, string? destination, bool expected)
|
||||
{
|
||||
var actual = GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination);
|
||||
var actual = AgentConfigResolver.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the shared 401/403 contract of the Graph-backed device validators: attempt 1 drops the
|
||||
/// tenant's cached token (it may predate the consent grant on this instance) and reports
|
||||
/// transient so the loop re-mints; attempt 2 and every other status leave the token alone.
|
||||
/// </summary>
|
||||
public class GraphAuthFailureTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static Mock<GraphTokenService> TokenService()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
var registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
return new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public void AuthFailure_OnFirstAttempt_InvalidatesOnce_AndAsksForRetry(HttpStatusCode status)
|
||||
{
|
||||
var tokens = TokenService();
|
||||
|
||||
var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 1);
|
||||
|
||||
Assert.True(retry);
|
||||
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
public void AuthFailure_OnSecondAttempt_LeavesTheFreshToken_AndDefersToTheValidator(HttpStatusCode status)
|
||||
{
|
||||
var tokens = TokenService();
|
||||
|
||||
var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 2);
|
||||
|
||||
Assert.False(retry);
|
||||
tokens.Verify(t => t.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.BadRequest)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.InternalServerError)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public void OtherStatuses_NeverTouchTheToken(HttpStatusCode status)
|
||||
{
|
||||
var tokens = TokenService();
|
||||
|
||||
var retry = GraphAuthFailure.TryRecoverStaleToken(tokens.Object, NullLogger.Instance, "Test", TenantId, status, attempt: 1);
|
||||
|
||||
Assert.False(retry);
|
||||
Assert.False(GraphAuthFailure.IsAuthFailure(status));
|
||||
tokens.Verify(t => t.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PermissionMissingRetryAfter_IsLongerThanTheDefault_ButInsideTheAgentsPatience()
|
||||
{
|
||||
// 30 s is the SecurityValidator default for a transient failure; a consent gap needs more
|
||||
// than that to close, and the agent's replay logic honours any Retry-After.
|
||||
Assert.True(GraphAuthFailure.PermissionMissingRetryAfterSeconds > 30);
|
||||
Assert.True(GraphAuthFailure.PermissionMissingRetryAfterSeconds <= 300);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData(null, 120, 120)]
|
||||
[InlineData(30, null, 30)]
|
||||
[InlineData(30, 120, 120)]
|
||||
[InlineData(120, 30, 120)]
|
||||
public void SecurityValidator_KeepsTheLargestRetryAfter(int? accumulated, int? next, int? expected)
|
||||
=> Assert.Equal(expected, SecurityValidator.MaxRetryAfter(accumulated, next));
|
||||
}
|
||||
@@ -77,17 +77,62 @@ public class HealthCheckServiceMcpServerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TimeoutOrConnectionFailure_ReportsWarningForColdStart()
|
||||
public async Task Check_Timeout_ReportsWarmingSoTheCallerCanRePoll()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new TaskCanceledException("timeout")));
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
// Failure to wake the scaled-to-zero container within the budget is a warning,
|
||||
// not a hard outage (a re-check may still succeed once it finishes warming).
|
||||
// A scaled-to-zero container that did not answer inside the budget is "warming",
|
||||
// NOT a fault: the caller re-checks and the activation we triggered keeps running.
|
||||
Assert.Equal("warming", check.Status);
|
||||
// The default budget is pinned here because it is set nowhere else in the repo —
|
||||
// the code default IS the live production value.
|
||||
Assert.Contains("3s", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Timeout_HonoursConfiguredBudget()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new TaskCanceledException("timeout")),
|
||||
extraConfig: new Dictionary<string, string?> { ["McpServerHealthTimeoutSeconds"] = "7" });
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
Assert.Equal("warming", check.Status);
|
||||
Assert.Contains("7s", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ConnectionFailure_ReportsWarningNotWarmingAndKeepsTheHostOut()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new System.Net.Http.HttpRequestException("no such host mcp.example.test")));
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
// Reaching the network and failing to connect is a real warning — distinct from
|
||||
// the expected cold-start path, so it must not be softened to "warming".
|
||||
Assert.Equal("warning", check.Status);
|
||||
Assert.Contains("could not be reached", check.Message);
|
||||
Assert.DoesNotContain("http", check.Message, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("mcp.example.test", check.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(12, false)]
|
||||
[InlineData(750, false)]
|
||||
[InlineData(751, true)]
|
||||
[InlineData(2400, true)]
|
||||
public void McpReachableMessage_marksOnlyAnswersAboveTheWarmThresholdAsColdStart(long elapsedMs, bool expectColdStart)
|
||||
{
|
||||
var message = HealthCheckService.McpReachableMessage(elapsedMs);
|
||||
|
||||
// Both arms keep the word "reachable" — the healthy-path assertions rely on it.
|
||||
Assert.Contains("reachable", message);
|
||||
Assert.Contains($"{elapsedMs}ms", message);
|
||||
Assert.Equal(expectColdStart, message.Contains("cold start"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,13 +146,21 @@ public class HealthCheckServiceMcpServerTests
|
||||
return BuildServiceWithFactory(new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static HealthCheckService BuildServiceWithFactory(System.Net.Http.IHttpClientFactory factory)
|
||||
private static HealthCheckService BuildServiceWithFactory(
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
Dictionary<string, string?>? extraConfig = null)
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["McpServerUrl"] = "https://mcp.example.test",
|
||||
};
|
||||
if (extraConfig != null)
|
||||
{
|
||||
foreach (var kv in extraConfig) settings[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["McpServerUrl"] = "https://mcp.example.test",
|
||||
})
|
||||
.AddInMemoryCollection(settings)
|
||||
.Build();
|
||||
|
||||
return new HealthCheckService(
|
||||
|
||||
@@ -28,6 +28,8 @@ public class InfrastructureWireParityTests
|
||||
var displayName = "Admin User";
|
||||
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
|
||||
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -46,7 +48,9 @@ public class InfrastructureWireParityTests
|
||||
hasMcpAccess = true,
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false
|
||||
unrestrictedModeEnabled = false,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -64,7 +68,9 @@ public class InfrastructureWireParityTests
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false
|
||||
UnrestrictedModeEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +84,8 @@ public class InfrastructureWireParityTests
|
||||
var displayName = "Reader User";
|
||||
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
|
||||
string? role = null;
|
||||
DateTime? whatsNewSeenPlatformUtc = null;
|
||||
DateTime? whatsNewSeenAgentUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -96,7 +104,9 @@ public class InfrastructureWireParityTests
|
||||
hasMcpAccess = true,
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false
|
||||
unrestrictedModeEnabled = false,
|
||||
whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -114,7 +124,9 @@ public class InfrastructureWireParityTests
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false
|
||||
UnrestrictedModeEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = null,
|
||||
WhatsNewSeenAgentUtc = null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -51,4 +51,14 @@ public class IngestStorageFailureClassificationTests
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_connection_failure_without_status_is_503_with_retry_after()
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(
|
||||
new RequestFailedException(0, "connection reset before a status arrived"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, status);
|
||||
Assert.Equal(IngestTelemetryFunction.StorageRetryAfterSeconds, retryAfter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ public class McpQuotaServiceTests
|
||||
cache ??= new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!);
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!, tenantConfigService: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
|
||||
@@ -50,7 +50,8 @@ public class McpUserServiceAllMembersTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void MemberOf(string tenantId, string role) =>
|
||||
|
||||
@@ -54,7 +54,8 @@ public class McpUserServiceDelegatedTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles);
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void SetPolicy(McpAccessPolicy policy) =>
|
||||
|
||||
@@ -59,7 +59,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void SetRow(bool enabled, string? plan = "pro") =>
|
||||
@@ -131,7 +132,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
var result = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
@@ -171,7 +173,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
SetRow(enabled: false);
|
||||
|
||||
var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant-level MCP switch (<see cref="TenantConfiguration.McpDisabled"/>, operator control) at the MCP
|
||||
/// front door: a closed HOME tenant denies every caller below the platform roles — members under AllMembers,
|
||||
/// delegated (MSP) admins and enabled McpUsers rows alike — with the operator's reason (or a neutral default),
|
||||
/// while Global Admin / Reader keep administering the tenant and other tenants are untouched. The read is
|
||||
/// side-effect-free: a tenant without a config row is not closed and no default row is ever persisted.
|
||||
/// </summary>
|
||||
public class McpUserServiceTenantMcpSwitchTests
|
||||
{
|
||||
private const string Upn = "operator@customer.example";
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly Mock<IConfigRepository> _configRepo = new();
|
||||
private readonly Mock<GlobalAdminService> _globalAdmin;
|
||||
private readonly Mock<DelegatedAdminService> _delegatedAdmin;
|
||||
private readonly StubTenantMemberRoleResolver _memberRoles = new();
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceTenantMcpSwitchTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
_globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
// No tenant has a config row unless a test closes one — the service must treat "no row" as open.
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
|
||||
var tenantConfig = new TenantConfigurationService(_configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles, tenantConfig);
|
||||
}
|
||||
|
||||
private void MemberOf(string tenantId, string role) =>
|
||||
_memberRoles.Verdict = (tid, upn, _) =>
|
||||
string.Equals(tid, tenantId, StringComparison.OrdinalIgnoreCase) && upn == Upn
|
||||
? new MemberRoleInfo { Role = role }
|
||||
: null;
|
||||
|
||||
private void CloseMcp(string tenantId, string? reason = null) =>
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, McpDisabled = true, McpDisabledReason = reason });
|
||||
|
||||
[Fact]
|
||||
public async Task Member_OfClosedHomeTenant_IsDenied_WithOperatorReason()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
|
||||
CloseMcp(HomeTenant, "No AI access by customer request");
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal("No AI access by customer request", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_OfClosedHomeTenant_WithoutReason_GetsNeutralDefault()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("disabled for your organization", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedAdmin_WhoseHomeTenantIsClosed_LosesMcp()
|
||||
{
|
||||
// Same reach as a suspension: the MSP's own organization said "no MCP" — its staff cannot route
|
||||
// around that by reading managed customers instead.
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(new DelegatedScope(new Dictionary<string, string>
|
||||
{
|
||||
[OtherTenant] = Constants.DelegatedRoles.DelegatedReader,
|
||||
}));
|
||||
CloseMcp(HomeTenant, "closed");
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal("closed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledMcpUsersRow_DoesNotOverride_ClosedHomeTenant()
|
||||
{
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = true });
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalAdmin_OfClosedHomeTenant_StillAllowed()
|
||||
{
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosedTenant_DoesNotAffect_AnotherTenantsMember()
|
||||
{
|
||||
MemberOf(OtherTenant, Constants.TenantRoles.Viewer);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, OtherTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("AllMembers", result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantWithoutConfigRow_IsOpen_AndNoRowIsPersisted()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Operator);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpDisabledMessage_PrefersOperatorReason_ElseDefault()
|
||||
{
|
||||
Assert.Equal("why", McpUserService.McpDisabledMessage(new TenantConfiguration { McpDisabled = true, McpDisabledReason = "why" }));
|
||||
Assert.Contains("Contact your administrator", McpUserService.McpDisabledMessage(new TenantConfiguration { McpDisabled = true, McpDisabledReason = " " }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant-plan PATCH is the one body read as a document (absent key ≠ explicit null). Its
|
||||
/// handler walks the keys by name; this pins that key set to <see cref="PatchTenantPlanRequest"/>,
|
||||
/// the type the web and MCP compile against — a property added to the DTO without a handler
|
||||
/// branch (or the reverse) fails here instead of silently never binding.
|
||||
/// </summary>
|
||||
public class PlanPatchKeyParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void HandlerKeysMatchTheWireDto()
|
||||
{
|
||||
var dtoKeys = typeof(PatchTenantPlanRequest)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name))
|
||||
.OrderBy(k => k, System.StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var handlerKeys = PlanManagementFunction.PlanPatchKeys.All
|
||||
.OrderBy(k => k, System.StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(dtoKeys, handlerKeys);
|
||||
Assert.Contains("trialExpiresUtc", handlerKeys); // the presence-sensitive key that motivated the document read
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using System.Threading;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -23,7 +24,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(EmptyPage());
|
||||
sessionRepo
|
||||
.Setup(r => r.GetSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
@@ -64,8 +65,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedDays = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, d, _, _, _, _) => capturedDays = d)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?, CancellationToken>((_, d, _, _, _, _, _) => capturedDays = d)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days, limit: 50);
|
||||
@@ -89,8 +90,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedPageSize = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, _, ps, _, _, _) => capturedPageSize = ps)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?, CancellationToken>((_, _, ps, _, _, _, _) => capturedPageSize = ps)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days: days, limit: limit);
|
||||
@@ -106,8 +107,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedDays = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, d, _, _, _, _) => capturedDays = d)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?, CancellationToken>((_, d, _, _, _, _, _) => capturedDays = d)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(0);
|
||||
@@ -140,7 +141,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
var (service, repo) = CreateService();
|
||||
int callCount = 0;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback(() => callCount++)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
@@ -160,7 +161,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 7).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days: 77, limit: 50);
|
||||
@@ -177,7 +178,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 25).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var fullFetchSessions = new List<string>();
|
||||
@@ -216,7 +217,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 21).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var session21 = sessions[20].SessionId;
|
||||
|
||||
@@ -1676,4 +1676,115 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Tenant MCP switch (TenantConfiguration.McpDisabled) — the per-request half ────────────
|
||||
// The MCP front door (McpUserService) already denies a caller whose home tenant is closed; this gate
|
||||
// is what makes the switch hold per request (front-door verdicts are cached) and what keeps a closed
|
||||
// tenant out of reach as a TARGET — a delegated read into it, or its rows in the MSP's aggregate.
|
||||
// It fires only for requests the MCP server forwards (X-Client-Source: mcp): the switch closes the AI
|
||||
// channel, not the tenant's own portal/API access.
|
||||
|
||||
private static void AsMcpDisabled(Harness h, string tenantId, string? reason = null) =>
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, McpDisabled = true, McpDisabledReason = reason });
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_HomeTenant_McpClient_IsForbidden_WithOperatorReason()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsMcpDisabled(h, TenantA, reason: "No AI access by customer request");
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.Equal("McpDisabled", result.ErrorCode);
|
||||
Assert.Equal("No AI access by customer request", result.ErrorMessage);
|
||||
Assert.Equal("McpDisabled", result.LogReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_HomeTenant_PortalClient_IsAllowed()
|
||||
{
|
||||
// The same caller without the MCP marker is a portal user — the tenant keeps its own data access.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsMcpDisabled(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_GlobalAdmin_McpClient_BypassesGate()
|
||||
{
|
||||
const string upn = "ga@platform.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
AsMcpDisabled(h, TenantA);
|
||||
AsMcpDisabled(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var cross = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_DelegatedReader_TargetClosed_IsForbidden()
|
||||
{
|
||||
// The customer said "no MCP": its MSP cannot read it through MCP either, even with a valid grant.
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsMcpDisabled(h, TenantB, reason: "closed");
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var portal = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(mcp.Allowed);
|
||||
Assert.Equal("McpDisabled", mcp.ErrorCode);
|
||||
Assert.Equal("closed", mcp.ErrorMessage);
|
||||
Assert.True(portal.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_DelegatedAggregate_McpClient_NarrowsBound_NeverToNull()
|
||||
{
|
||||
// The bounded fleet aggregate drops the closed tenant instead of failing: the published bound shrinks
|
||||
// to the open subset (here: empty) and must never become null — null means "all tenants" downstream.
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsMcpDisabled(h, TenantB);
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var portal = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(mcp.Allowed);
|
||||
Assert.NotNull(mcp.Context!.AllowedTenantIds);
|
||||
Assert.Empty(mcp.Context!.AllowedTenantIds!);
|
||||
Assert.True(mcp.Context!.IsDelegatedAggregate);
|
||||
Assert.Contains(TenantB.ToLowerInvariant(), portal.Context!.AllowedTenantIds!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_And_McpDisabled_SuspensionWins()
|
||||
{
|
||||
// Both flags set: the stronger, longer-standing verdict is reported so support reads the right cause.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantA))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantA, Disabled = true, McpDisabled = true });
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The stall classifier turns two runtime snapshots around a late timer tick into a cause.
|
||||
/// GC wins when its pause clock covers at least half the drift; queued work or thread injection
|
||||
/// means starvation; otherwise the drift stays unexplained rather than guessed.
|
||||
/// </summary>
|
||||
public class ProcessStallClassifierTests
|
||||
{
|
||||
private static ProcessSnapshot Snapshot(
|
||||
double gcPauseMs = 0, int gen2 = 0, int threads = 8, long pending = 0, long completed = 0)
|
||||
=> new(
|
||||
GcPause: TimeSpan.FromMilliseconds(gcPauseMs),
|
||||
Gen0: 0, Gen1: 0, Gen2: gen2,
|
||||
ThreadPoolThreads: threads,
|
||||
PendingWorkItems: pending,
|
||||
CompletedWorkItems: completed,
|
||||
WorkingSetBytes: 0, HeapSizeBytes: 0, CommittedBytes: 0,
|
||||
LastGcGeneration: 0, LastGcPause: TimeSpan.Zero, LastGcCompacted: false);
|
||||
|
||||
[Fact]
|
||||
public void Gc_pause_covering_half_the_drift_is_a_gc_stall()
|
||||
{
|
||||
var before = Snapshot(gcPauseMs: 1000, gen2: 3);
|
||||
var after = Snapshot(gcPauseMs: 6200, gen2: 4, pending: 12);
|
||||
|
||||
var report = ProcessStallClassifier.Classify(TimeSpan.FromSeconds(10), before, after);
|
||||
|
||||
Assert.Equal(StallCause.Gc, report.Cause);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(5200), report.GcPauseDelta);
|
||||
Assert.Equal(1, report.Gen2Delta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Queued_work_without_gc_is_thread_pool_starvation()
|
||||
{
|
||||
var before = Snapshot(gcPauseMs: 1000);
|
||||
var after = Snapshot(gcPauseMs: 1100, pending: 40);
|
||||
|
||||
var report = ProcessStallClassifier.Classify(TimeSpan.FromSeconds(8), before, after);
|
||||
|
||||
Assert.Equal(StallCause.ThreadPool, report.Cause);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thread_injection_without_gc_is_thread_pool_starvation()
|
||||
{
|
||||
var before = Snapshot(threads: 8);
|
||||
var after = Snapshot(threads: 11);
|
||||
|
||||
var report = ProcessStallClassifier.Classify(TimeSpan.FromSeconds(3), before, after);
|
||||
|
||||
Assert.Equal(StallCause.ThreadPool, report.Cause);
|
||||
Assert.Equal(3, report.ThreadPoolThreadsDelta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drift_neither_clock_explains_stays_unknown()
|
||||
{
|
||||
var before = Snapshot(gcPauseMs: 500, threads: 8, completed: 100);
|
||||
var after = Snapshot(gcPauseMs: 900, threads: 8, completed: 130);
|
||||
|
||||
var report = ProcessStallClassifier.Classify(TimeSpan.FromSeconds(5), before, after);
|
||||
|
||||
Assert.Equal(StallCause.Unknown, report.Cause);
|
||||
Assert.Equal(30, report.CompletedWorkItemsDelta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wrapped_pause_clock_never_yields_a_negative_delta()
|
||||
{
|
||||
var before = Snapshot(gcPauseMs: 900);
|
||||
var after = Snapshot(gcPauseMs: 100);
|
||||
|
||||
var report = ProcessStallClassifier.Classify(TimeSpan.FromSeconds(2), before, after);
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, report.GcPauseDelta);
|
||||
Assert.Equal(StallCause.Unknown, report.Cause);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
|
||||
/// <c>days</c> alone, each with its own default, cap and out-of-range rule. The baseline lists
|
||||
/// the sites that parse something other than a query parameter (a header, a stored cell, a
|
||||
/// version string); it only shrinks.
|
||||
/// version string); it only shrinks. The X-Agent-Version major parse moved to
|
||||
/// <c>Services/AgentConfigResolver.cs</c> (outside the scanned roots) with D-216.
|
||||
/// </summary>
|
||||
public class QueryParamGuardTests
|
||||
{
|
||||
@@ -19,29 +20,14 @@ public class QueryParamGuardTests
|
||||
/// <summary>Non-query parses, reviewed: file → why it is allowed.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
|
||||
["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
|
||||
// Content-Length header guard on body-taking routes (long.TryParse on the header value).
|
||||
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header",
|
||||
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -21,7 +22,8 @@ public class RateLimitResolverTests
|
||||
Assert.Equal(expected, RateLimitResolver.ResolveDeviceLimit(tenantOverride, globalDefault, entitlementFloor));
|
||||
}
|
||||
|
||||
// ── User path: GA → globalAdminDefault (override + floor never apply); else override ?? max(default, floor) ──
|
||||
// ── User path, integration surface (MCP server / integrations / app-only):
|
||||
// GA → globalAdminDefault (override + floor never apply); else override ?? max(default, floor) ──
|
||||
[Theory]
|
||||
[InlineData(false, null, 120, 600, null, 120)] // standard user, no override → global user default
|
||||
[InlineData(false, 300, 120, 600, null, 300)] // per-tenant user override wins over the global user default
|
||||
@@ -30,10 +32,26 @@ public class RateLimitResolverTests
|
||||
[InlineData(false, null, 200, 600, 150, 200)] // admin-raised default (200) must never be LOWERED by the floor
|
||||
[InlineData(false, 50, 120, 600, 150, 50)] // explicit override beats the floor
|
||||
[InlineData(true, null, 120, 600, 9999, 600)] // Global Admin: entitlement floor never applies
|
||||
public void ResolveUserLimit_respects_global_admin_override_and_floor(
|
||||
public void ResolveUserLimit_integration_respects_global_admin_override_and_floor(
|
||||
bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault, int? entitlementFloor, int expected)
|
||||
{
|
||||
const int portalDefault = 600;
|
||||
Assert.Equal(expected, RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin, tenantUserOverride, globalUserDefault, globalAdminDefault, entitlementFloor));
|
||||
ThrottleSurface.Integration, isGlobalAdmin, tenantUserOverride, globalUserDefault, portalDefault, globalAdminDefault, entitlementFloor));
|
||||
}
|
||||
|
||||
// ── User path, portal surface (public-client token): one global knob — no override, no floor;
|
||||
// Global Admins keep their own budget on every surface ──
|
||||
[Theory]
|
||||
[InlineData(false, null, 120, 600, 900, null, 600)] // standard user → portal default, not the integration default
|
||||
[InlineData(false, 300, 120, 600, 900, null, 600)] // per-tenant override is an integration-budget concept
|
||||
[InlineData(false, null, 120, 600, 900, 150, 600)] // edition floor is an integration-budget concept
|
||||
[InlineData(false, null, 120, 50, 900, 150, 50)] // an admin-lowered portal default is honoured as set (no floor lifts it)
|
||||
[InlineData(true, 300, 120, 600, 900, 9999, 900)] // Global Admin: GA budget, whatever the surface
|
||||
public void ResolveUserLimit_portal_is_one_global_knob(
|
||||
bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int portalUserDefault, int globalAdminDefault, int? entitlementFloor, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RateLimitResolver.ResolveUserLimit(
|
||||
ThrottleSurface.Portal, isGlobalAdmin, tenantUserOverride, globalUserDefault, portalUserDefault, globalAdminDefault, entitlementFloor));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The one body reader (D-207): camelCase and PascalCase both bind, string enums bind, an empty
|
||||
/// or malformed body is a 400 envelope (never a 500), the optional variant treats "no body" as
|
||||
/// null, the byte cap and the Newtonsoft branch behave the same way.
|
||||
/// </summary>
|
||||
public class RequestBodyTests
|
||||
{
|
||||
private const string CorrelationId = "corr-123";
|
||||
|
||||
[Fact]
|
||||
public async Task CamelCase_body_binds()
|
||||
{
|
||||
var req = Build("{\"name\":\"Group A\"}");
|
||||
var read = await req.ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(read.Error);
|
||||
Assert.Equal("Group A", read.Value!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PascalCase_body_binds_too()
|
||||
{
|
||||
// Superset of the two former paths: the worker serializer was case-sensitive, Newtonsoft was not.
|
||||
var req = Build("{\"Name\":\"Group A\"}");
|
||||
var read = await req.ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(read.Error);
|
||||
Assert.Equal("Group A", read.Value!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task String_enum_binds()
|
||||
{
|
||||
var req = Build("{\"tableName\":\"t\",\"partitionKey\":\"p\",\"rowKey\":\"r\",\"mode\":\"Commit\"}");
|
||||
var read = await req.ReadAsync<RestoreRowRequest>();
|
||||
Assert.Null(read.Error);
|
||||
Assert.Equal(RestoreRowMode.Commit, read.Value!.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Absent_nullable_key_stays_null_and_number_binds()
|
||||
{
|
||||
var req = Build("{\"tenantId\":\"t\",\"serialNumber\":\"s\",\"durationHours\":24}");
|
||||
var read = await req.ReadAsync<BlockDeviceRequest>();
|
||||
Assert.Null(read.Error);
|
||||
Assert.Equal(24, read.Value!.DurationHours);
|
||||
Assert.Null(read.Value.Action);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Empty_body_is_400_required(string body)
|
||||
{
|
||||
var req = Build(body);
|
||||
var read = await req.ReadAsync<CreateTenantGroupRequest>();
|
||||
await AssertBadRequest(read.Error, "Request body is required");
|
||||
Assert.Null(read.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{not json")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("{\"durationHours\":\"twelve\",\"tenantId\":\"t\",\"serialNumber\":\"s\"}")] // string in an int slot
|
||||
public async Task Malformed_body_is_400_invalid(string body)
|
||||
{
|
||||
var req = Build(body);
|
||||
var read = await req.ReadAsync<BlockDeviceRequest>();
|
||||
await AssertBadRequest(read.Error, "Invalid JSON body");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Json_null_body_is_400_required()
|
||||
{
|
||||
var req = Build("null");
|
||||
var read = await req.ReadAsync<CreateTenantGroupRequest>();
|
||||
await AssertBadRequest(read.Error, "Request body is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Optional_reader_returns_empty_for_no_body_but_400_for_garbage()
|
||||
{
|
||||
var empty = await Build("").ReadOptionalAsync<DownloadTicketRequest>();
|
||||
Assert.Null(empty.Error);
|
||||
Assert.Null(empty.Value);
|
||||
|
||||
var present = await Build("{\"blobName\":\"b\"}").ReadOptionalAsync<DownloadTicketRequest>();
|
||||
Assert.Null(present.Error);
|
||||
Assert.Equal("b", present.Value!.BlobName);
|
||||
|
||||
var garbage = await Build("{oops").ReadOptionalAsync<DownloadTicketRequest>();
|
||||
await AssertBadRequest(garbage.Error, "Invalid JSON body");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Byte_cap_applies_only_when_given()
|
||||
{
|
||||
var capped = await Build("{\"name\":\"x\"}", contentLength: 2_000_000).ReadAsync<CreateTenantGroupRequest>(maxBytes: 1_048_576);
|
||||
await AssertBadRequest(capped.Error, "Request body too large");
|
||||
|
||||
var uncapped = await Build("{\"name\":\"x\"}", contentLength: 2_000_000).ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(uncapped.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Newtonsoft_branch_has_the_same_contract()
|
||||
{
|
||||
var ok = await Build("{\"Fields\":{\"a\":1},\"reason\":\"r\"}").ReadNewtonsoftAsync<PatchTenantConfigurationFieldsRequest>();
|
||||
Assert.Null(ok.Error);
|
||||
Assert.Single(ok.Value!.Fields!);
|
||||
Assert.Equal("r", ok.Value.Reason);
|
||||
|
||||
var empty = await Build("").ReadNewtonsoftAsync<PatchTenantConfigurationFieldsRequest>();
|
||||
await AssertBadRequest(empty.Error, "Request body is required");
|
||||
|
||||
var bad = await Build("{oops").ReadNewtonsoftAsync<PatchTenantConfigurationFieldsRequest>();
|
||||
await AssertBadRequest(bad.Error, "Invalid JSON body");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Document_reader_requires_an_object_root()
|
||||
{
|
||||
var doc = await Build("{\"planTier\":\"pro\",\"trialExpiresUtc\":null}").ReadDocumentAsync();
|
||||
Assert.Null(doc.Error);
|
||||
var document = doc.Value!;
|
||||
using (document)
|
||||
{
|
||||
Assert.True(document.RootElement.TryGetProperty("trialExpiresUtc", out var trial));
|
||||
Assert.Equal(JsonValueKind.Null, trial.ValueKind); // presence survives — the reason this reader exists
|
||||
Assert.False(document.RootElement.TryGetProperty("maxDelegatedTenants", out _));
|
||||
}
|
||||
|
||||
var array = await Build("[1,2]").ReadDocumentAsync();
|
||||
await AssertBadRequest(array.Error, "Body must be a JSON object");
|
||||
}
|
||||
|
||||
// ── fakes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task AssertBadRequest(HttpResponseData? error, string message)
|
||||
{
|
||||
Assert.NotNull(error);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, error!.StatusCode);
|
||||
error.Body.Position = 0;
|
||||
var envelope = await JsonSerializer.DeserializeAsync<ApiErrorResponse>(error.Body, ApiJsonOptions.Read);
|
||||
Assert.NotNull(envelope);
|
||||
Assert.Equal(message, envelope!.Error);
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.ApiErrorCodes.BadRequest, envelope!.Code);
|
||||
Assert.Equal(CorrelationId, envelope!.CorrelationId);
|
||||
}
|
||||
|
||||
private static HttpRequestData Build(string body, long? contentLength = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(new Dictionary<object, object> { ["CorrelationId"] = CorrelationId });
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var headers = new HttpHeadersCollection();
|
||||
if (contentLength is long len) headers.Add("Content-Length", len.ToString());
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(headers);
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(body)));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -76,7 +77,7 @@ public class RevokeEnforcementTests
|
||||
}
|
||||
|
||||
private static (TenantGroupManagementFunction Fn, Mock<IAdminRepository> Repo,
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn()
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn(ProConferralService? proConferral = null)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
// Loose-mock defaults return a null Task — every repo read the flows touch needs a concrete setup.
|
||||
@@ -89,10 +90,17 @@ public class RevokeEnforcementTests
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new TenantGroupManagementFunction(
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
|
||||
TestProConferral.Inert());
|
||||
proConferral ?? TestProConferral.Inert());
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
|
||||
/// <summary>A recording conferral double (CallBase off, touches no storage): the group flows only need to prove WHAT they hand it.</summary>
|
||||
private static Mock<ProConferralService> RecordingProConferral() =>
|
||||
new(Mock.Of<IAdminRepository>(),
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions())),
|
||||
ManagedTenantProIndex.None,
|
||||
NullLogger<ProConferralService>.Instance) { CallBase = false };
|
||||
|
||||
// --- ChargeHomeTenantQuota flip: audited under every tenant, no-op on an unchanged value ---
|
||||
|
||||
[Fact]
|
||||
@@ -361,4 +369,53 @@ public class RevokeEnforcementTests
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", "*", "ga@vendor.example",
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
}
|
||||
|
||||
// --- Group delete ends conferred Pro: every member of an OWNED group gets its grace anchor ---
|
||||
|
||||
private const string OwnerTenant = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteGroupCore_OwnedGroup_RecordsLossForEveryMember_EvenWithoutAssignees()
|
||||
{
|
||||
var proConferral = RecordingProConferral();
|
||||
var (fn, repo, audit, _) = BuildGroupFn(proConferral.Object);
|
||||
var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(OwnerTenant);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(ownedGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = ownedGroupId,
|
||||
Name = "Customers of owner",
|
||||
OwnerTenantId = OwnerTenant,
|
||||
TenantIds = new List<string> { TenantA, TenantB, OwnerTenant },
|
||||
AssigneeCount = 0,
|
||||
});
|
||||
repo.Setup(r => r.DeleteTenantGroupAsync(ownedGroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.DeleteGroupCoreAsync(ownedGroupId, "ga@vendor.example");
|
||||
|
||||
// The anchor does not depend on assignees (no assignee ⇒ no access audit, but Pro still ends);
|
||||
// the owner itself is never a managed tenant of its own group.
|
||||
proConferral.Verify(p => p.RecordLossAsync(TenantA, OwnerTenant, "group-deleted"), Times.Once);
|
||||
proConferral.Verify(p => p.RecordLossAsync(TenantB, OwnerTenant, "group-deleted"), Times.Once);
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));
|
||||
VerifyNoAudit(audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteGroupCore_OperatorGroup_RecordsNoLoss()
|
||||
{
|
||||
var proConferral = RecordingProConferral();
|
||||
var (fn, repo, _, _) = BuildGroupFn(proConferral.Object);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = GroupId,
|
||||
Name = "MSP Customers",
|
||||
TenantIds = new List<string> { TenantA },
|
||||
AssigneeCount = 0,
|
||||
});
|
||||
repo.Setup(r => r.DeleteTenantGroupAsync(GroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.DeleteGroupCoreAsync(GroupId, "ga@vendor.example");
|
||||
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public sealed class ErrorCodeEnricherTests
|
||||
Data = data,
|
||||
};
|
||||
|
||||
/// <summary>The <c>*Info</c> sibling as the wire sees it: a string-keyed dictionary.</summary>
|
||||
private static Dictionary<string, object> Info(EnrollmentEvent evt, string key) =>
|
||||
Assert.IsType<Dictionary<string, object>>(evt.Data[key]);
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_adds_errorCodeInfo_for_known_hex_code()
|
||||
{
|
||||
@@ -25,13 +29,13 @@ public sealed class ErrorCodeEnricherTests
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("errorCodeInfo"));
|
||||
var info = evt.Data["errorCodeInfo"];
|
||||
var description = info.GetType().GetProperty("description")?.GetValue(info)?.ToString();
|
||||
var confidence = info.GetType().GetProperty("confidence")?.GetValue(info)?.ToString();
|
||||
var source = info.GetType().GetProperty("source")?.GetValue(info)?.ToString();
|
||||
Assert.Contains("Access denied", description ?? "");
|
||||
Assert.Equal("high", confidence);
|
||||
Assert.False(string.IsNullOrEmpty(source));
|
||||
var info = Info(evt, "errorCodeInfo");
|
||||
Assert.Equal("Access is denied", info["description"]);
|
||||
Assert.Equal("high", info["confidence"]);
|
||||
Assert.StartsWith("msdoc:", (string)info["source"]);
|
||||
Assert.Equal("win32", info["category"]);
|
||||
Assert.Equal("ERROR_ACCESS_DENIED", info["symbol"]);
|
||||
Assert.False(info.ContainsKey("derivedFromWin32"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,29 +45,35 @@ public sealed class ErrorCodeEnricherTests
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
var info = evt.Data["exitCodeInfo"];
|
||||
Assert.Contains("Fatal error", info.GetType().GetProperty("description")?.GetValue(info)?.ToString() ?? "");
|
||||
var info = Info(evt, "exitCodeInfo");
|
||||
Assert.Contains("fatal error", (string)info["description"]);
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", info["symbol"]);
|
||||
Assert.Equal("msi", info["category"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_handles_hresult_key()
|
||||
public void EnrichEvent_reports_hresult_from_win32_derivation_as_data()
|
||||
{
|
||||
var evt = EventWithData(new() { { "hresultFromWin32", "0x80070643" } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
var info = Info(evt, "hresultFromWin32Info");
|
||||
Assert.Equal(1603, info["derivedFromWin32"]);
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", info["symbol"]);
|
||||
Assert.DoesNotContain("1603", (string)info["description"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_omits_symbol_when_the_entry_has_none()
|
||||
{
|
||||
var evt = EventWithData(new() { { "hresult", "0x87d1041c" } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("hresultInfo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_handles_hresultFromWin32_key()
|
||||
{
|
||||
var evt = EventWithData(new() { { "hresultFromWin32", "0x80070005" } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("hresultFromWin32Info"));
|
||||
var info = Info(evt, "hresultInfo");
|
||||
Assert.False(info.ContainsKey("symbol"));
|
||||
Assert.Equal("intune-win32", info["category"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -111,6 +121,127 @@ public sealed class ErrorCodeEnricherTests
|
||||
Assert.Same(existing, evt.Data["errorCodeInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_adds_enforcementStateInfo_for_numeric_state()
|
||||
{
|
||||
// registry_app_state carries the IME enforcement state as a numeric string.
|
||||
var evt = EventWithData(new() { { "enforcementState", "1000" } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
var info = evt.Data["enforcementStateInfo"];
|
||||
Assert.Equal("Success", info.GetType().GetProperty("name")?.GetValue(info));
|
||||
Assert.False(string.IsNullOrEmpty(info.GetType().GetProperty("description")?.GetValue(info) as string));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_skips_unknown_enforcement_state_and_stays_idempotent()
|
||||
{
|
||||
var unknown = EventWithData(new() { { "enforcementState", "424242" } });
|
||||
ErrorCodeEnricher.EnrichEvent(unknown);
|
||||
Assert.False(unknown.Data.ContainsKey("enforcementStateInfo"));
|
||||
|
||||
var existing = new { name = "kept" };
|
||||
var evt = EventWithData(new() { { "enforcementState", "1000" }, { "enforcementStateInfo", existing } });
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
Assert.Same(existing, evt.Data["enforcementStateInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_leaves_nested_objects_untouched()
|
||||
{
|
||||
// app_install_summary carries per-app objects; only top-level keys are enriched.
|
||||
var nested = new Dictionary<string, object> { { "exitCode", "1603" } };
|
||||
var evt = EventWithData(new() { { "apps", new List<object> { nested } } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.Single(evt.Data);
|
||||
Assert.Single(nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_skips_gather_rule_events_by_default()
|
||||
{
|
||||
// A logparser gather rule on HP Image Assistant.log captured `exitcode` — HPiA has its own
|
||||
// exit-code table, so the MSI/Win32 catalog meaning ("ERROR_SUCCESS") must not be attached
|
||||
// unless the rule opted in (no enrichErrorCodes marker here).
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "HPiA-UpdateStatus",
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = "Gather: HP Updater Log Analyze",
|
||||
Data = new()
|
||||
{
|
||||
{ "exitcode", "0" },
|
||||
{ "errorCode", "0x80070005" },
|
||||
{ "enforcementState", "1000" },
|
||||
{ "ruleId", "hpia-log-collect" },
|
||||
},
|
||||
};
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.DoesNotContain(evt.Data.Keys, k => k.EndsWith("Info"));
|
||||
Assert.Equal(4, evt.Data.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_matches_gather_rule_source_case_insensitively()
|
||||
{
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" } });
|
||||
evt.Source = "gatherruleexecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.False(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)] // in-process CLR bool
|
||||
[InlineData("true")] // string form after DataJson roundtrip
|
||||
[InlineData("True")]
|
||||
public void EnrichEvent_enriches_gather_rule_events_that_opted_in(object marker)
|
||||
{
|
||||
// A rule that parses an msiexec log set enrichErrorCodes; the agent stamped the marker.
|
||||
var evt = EventWithData(new()
|
||||
{
|
||||
{ "exitCode", "1603" },
|
||||
{ "ruleId", "msi-log" },
|
||||
{ "enrichErrorCodes", marker },
|
||||
});
|
||||
evt.Source = "GatherRuleExecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", Info(evt, "exitCodeInfo")["symbol"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData("false")]
|
||||
[InlineData("")]
|
||||
public void EnrichEvent_ignores_gather_rule_marker_that_is_not_true(object marker)
|
||||
{
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", marker } });
|
||||
evt.Source = "GatherRuleExecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.False(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_marker_on_non_gather_event_changes_nothing()
|
||||
{
|
||||
// Built-in events are always enriched; the marker is only consulted for gather events.
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", false } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_handles_null_data_dictionary()
|
||||
{
|
||||
@@ -137,9 +268,7 @@ public sealed class ErrorCodeEnricherTests
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("errorCodeInfo"));
|
||||
var info = evt.Data["errorCodeInfo"];
|
||||
Assert.Contains("Access denied", info.GetType().GetProperty("description")?.GetValue(info)?.ToString() ?? "");
|
||||
Assert.Equal("Access is denied", Info(evt, "errorCodeInfo")["description"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -7,13 +12,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
|
||||
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
|
||||
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
|
||||
/// <c>TableStorageService.MatchesFreeText</c>.
|
||||
/// Server-side free-text search (<c>q=</c>) for the dashboard search box. The predicate is the
|
||||
/// search grammar of <see cref="SessionSearchQuery"/> over the web dashboard's client-side
|
||||
/// searchable field set (minus derived-only tokens). Parity with the web parser is pinned by
|
||||
/// the shared case file <c>src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json</c>,
|
||||
/// which vitest runs against <c>app/dashboard/utils/sessionSearchQuery.ts</c>.
|
||||
/// </summary>
|
||||
public class SessionFreeTextSearchTests
|
||||
{
|
||||
private const string CasesRepoPath = "src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json";
|
||||
|
||||
private static SessionSummary Sample() => new()
|
||||
{
|
||||
SessionId = "22222222-2222-2222-2222-222222222222",
|
||||
@@ -66,6 +74,79 @@ public class SessionFreeTextSearchTests
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
|
||||
}
|
||||
|
||||
// ---- Parity with the web parser (shared case file) ----
|
||||
|
||||
public static IEnumerable<object[]> SharedCases()
|
||||
{
|
||||
var doc = LoadCases();
|
||||
foreach (var c in doc.RootElement.GetProperty("cases").EnumerateArray())
|
||||
{
|
||||
// `extra` cases exercise client-derived tokens the server never sees.
|
||||
if (c.TryGetProperty("extra", out _)) continue;
|
||||
yield return new object[]
|
||||
{
|
||||
c.GetProperty("query").GetString()!,
|
||||
c.GetProperty("expected").GetBoolean(),
|
||||
c.GetProperty("note").GetString()!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SharedCases))]
|
||||
public void SharedCase_MatchesLikeTheWebParser(string query, bool expected, string note)
|
||||
{
|
||||
var session = SessionFromCaseFile();
|
||||
Assert.True(expected == SessionSearchQuery.Parse(query).Matches(session), $"{query} — {note}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedCaseFile_SampleSession_IsTheOneTheTestsUse()
|
||||
{
|
||||
// The case file's session drives both sides; the local Sample() mirrors it so the
|
||||
// InlineData theories above and the shared cases talk about the same rows.
|
||||
var fromFile = SessionFromCaseFile();
|
||||
var local = Sample();
|
||||
foreach (var field in SessionSearchQuery.Fields)
|
||||
Assert.Equal(field.Value(local), field.Value(fromFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedCaseFile_CoversEveryQualifier_AndEveryFieldHasAValue()
|
||||
{
|
||||
var doc = LoadCases();
|
||||
var queries = string.Join("\n", doc.RootElement.GetProperty("cases").EnumerateArray()
|
||||
.Select(c => c.GetProperty("query").GetString()!.ToLowerInvariant()));
|
||||
var session = SessionFromCaseFile();
|
||||
foreach (var field in SessionSearchQuery.Fields)
|
||||
{
|
||||
Assert.True(queries.Contains(field.Key + "=") || queries.Contains(field.Key + ":"),
|
||||
$"no shared case uses {field.Key}=");
|
||||
Assert.False(string.IsNullOrEmpty(field.Value(session)), $"sample carries no {field.Key}");
|
||||
}
|
||||
Assert.Equal(SessionSearchQuery.Fields.Count, SessionSearchQuery.Fields.Select(f => f.Key).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ResolvesKnownQualifiers_AndKeepsUnknownOnesLiteral()
|
||||
{
|
||||
var q = SessionSearchQuery.Parse("Model=Surface -status:failed 14:30 foo=bar");
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new SessionSearchQuery.Term("Surface", "model"),
|
||||
new SessionSearchQuery.Term("14:30", null),
|
||||
new SessionSearchQuery.Term("foo=bar", null),
|
||||
}, q.Include);
|
||||
Assert.Equal(new[] { new SessionSearchQuery.Term("failed", "status") }, q.Exclude);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LoneMinusOrBareQualifier_IsEmpty()
|
||||
{
|
||||
Assert.True(SessionSearchQuery.Parse(" - model= ").IsEmpty);
|
||||
Assert.True(SessionSearchQuery.Parse(null).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
|
||||
{
|
||||
@@ -81,4 +162,45 @@ public class SessionFreeTextSearchTests
|
||||
Assert.NotEqual(fp1, fp2);
|
||||
Assert.NotEqual(fp1, fp3);
|
||||
}
|
||||
|
||||
private static JsonDocument LoadCases()
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), CasesRepoPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
return JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
private static SessionSummary SessionFromCaseFile()
|
||||
{
|
||||
var s = LoadCases().RootElement.GetProperty("session");
|
||||
string F(string name) => s.GetProperty(name).GetString()!;
|
||||
return new SessionSummary
|
||||
{
|
||||
DeviceName = F("deviceName"),
|
||||
SerialNumber = F("serialNumber"),
|
||||
Manufacturer = F("manufacturer"),
|
||||
Model = F("model"),
|
||||
Status = Enum.Parse<SessionStatus>(F("status")),
|
||||
SessionId = F("sessionId"),
|
||||
GeoCountry = F("geoCountry"),
|
||||
GeoRegion = F("geoRegion"),
|
||||
GeoCity = F("geoCity"),
|
||||
AgentVersion = F("agentVersion"),
|
||||
OsName = F("osName"),
|
||||
OsBuild = F("osBuild"),
|
||||
OsDisplayVersion = F("osDisplayVersion"),
|
||||
OsEdition = F("osEdition"),
|
||||
OsLanguage = F("osLanguage"),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the schemaVersion-2 catalog contract (generated by scripts/error-codes/generate.mjs):
|
||||
/// key/source/category vocabulary, the HRESULT_FROM_WIN32 derivation, the symbol and
|
||||
/// enforcement-state indexes, and the handful of entries product code and rules rely on.
|
||||
/// </summary>
|
||||
public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
private static readonly Regex KeyPattern = new(@"^(0x[0-9a-f]{8}|\d+)$", RegexOptions.Compiled);
|
||||
private static readonly Regex SourcePattern = new(
|
||||
@"^(msdoc:https://learn\.microsoft\.com/\S+|ime:\d+(\.\d+)+|winerror\.h|rule:[A-Z]+-[A-Z]+-\d{3}|legacy-catalog-v1)$",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly HashSet<string> Categories = new()
|
||||
{
|
||||
"msi", "win32", "com", "appx", "windows-update", "cbs", "intune-win32", "intune-mobile",
|
||||
"mdm-enrollment", "autopilot", "entra-join", "delivery-optimization",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void All_loads_entries_from_embedded_resource()
|
||||
{
|
||||
var all = ErrorCodeCatalog.All;
|
||||
|
||||
Assert.NotEmpty(all);
|
||||
Assert.True(all.Count > 20, $"Expected catalog to have >20 entries, got {all.Count}");
|
||||
Assert.True(all.Count > 400, $"Expected the v2 catalog to have >400 entries, got {all.Count}");
|
||||
Assert.True(ErrorCodeCatalog.EnforcementStates.Count > 40, "Expected the IME enforcement states");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_entry_matches_the_schema_v2_vocabulary()
|
||||
{
|
||||
foreach (var (key, entry) in ErrorCodeCatalog.All)
|
||||
{
|
||||
Assert.Matches(KeyPattern, key);
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry.Description), $"{key}: empty description");
|
||||
Assert.True(Categories.Contains(entry.Category), $"{key}: category '{entry.Category}' outside the catalog vocabulary");
|
||||
Assert.Matches(SourcePattern, entry.Source);
|
||||
if (entry.Symbol != null) Assert.Matches("^[A-Za-z][A-Za-z0-9_]{2,}$", entry.Symbol);
|
||||
}
|
||||
|
||||
// Decimal keys are MSI exit codes only; every other decimal exit code is installer-defined.
|
||||
foreach (var key in ErrorCodeCatalog.All.Keys.Where(k => !k.StartsWith("0x")))
|
||||
{
|
||||
var n = int.Parse(key);
|
||||
Assert.True(n == 0 || (n >= 1601 && n <= 1654) || n == 3010, $"decimal key {key} outside the MSI range");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Symbols_are_unique()
|
||||
{
|
||||
var duplicates = ErrorCodeCatalog.All.Values
|
||||
.Where(e => e.Symbol != null)
|
||||
.GroupBy(e => e.Symbol!)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
Assert.Empty(duplicates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enforcement_states_are_numeric_keys_with_unique_names()
|
||||
{
|
||||
foreach (var (key, state) in ErrorCodeCatalog.EnforcementStates)
|
||||
{
|
||||
Assert.Matches("^-?\\d+$", key);
|
||||
Assert.Equal(key, state.Value);
|
||||
Assert.False(string.IsNullOrWhiteSpace(state.Name));
|
||||
Assert.False(string.IsNullOrWhiteSpace(state.Description));
|
||||
}
|
||||
Assert.Equal(ErrorCodeCatalog.EnforcementStates.Count, ErrorCodeCatalog.EnforcementStates.Values.Select(s => s.Name).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -29,6 +93,7 @@ public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
Assert.Null(ErrorCodeCatalog.TryLookup("0xDEADBEEF"));
|
||||
Assert.Null(ErrorCodeCatalog.TryLookup("99999"));
|
||||
Assert.Null(ErrorCodeCatalog.TryLookup("not-a-code"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -36,8 +101,11 @@ public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
var entry = ErrorCodeCatalog.TryLookup("0x80070005");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Access denied", entry!.Description);
|
||||
Assert.Equal("Access is denied", entry!.Description);
|
||||
Assert.Equal(ErrorCodeConfidence.High, entry.Confidence);
|
||||
Assert.Equal("ERROR_ACCESS_DENIED", entry.Symbol);
|
||||
Assert.Equal("win32", entry.Category);
|
||||
Assert.True(entry.ImeRetriesDuringEsp, "Win32 error 5 is on the IME MSI retry list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -45,26 +113,24 @@ public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
var entry = ErrorCodeCatalog.TryLookup("0X80070005");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Access denied", entry!.Description);
|
||||
Assert.Equal("Access is denied", entry!.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookup_normalises_short_hex_without_leading_zeros()
|
||||
{
|
||||
// "0x70005" should pad to "0x00070005" — which doesn't exist; but "0x80070005" should
|
||||
// be reachable via "0X0000000080070005" (extra leading zeros).
|
||||
var entry = ErrorCodeCatalog.TryLookup("0x0000000080070005");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Access denied", entry!.Description);
|
||||
Assert.Equal("Access is denied", entry!.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookup_converts_signed_decimal_hresult_to_hex()
|
||||
{
|
||||
// -2147024891 (signed decimal) == 0x80070005 (unsigned hex) == E_ACCESSDENIED
|
||||
// -2147024891 (signed decimal) == 0x80070005 (unsigned hex)
|
||||
var entry = ErrorCodeCatalog.TryLookup("-2147024891");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Access denied", entry!.Description);
|
||||
Assert.Equal("Access is denied", entry!.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,28 +138,151 @@ public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
var entry = ErrorCodeCatalog.TryLookup("1603");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Fatal error", entry!.Description);
|
||||
Assert.Contains("fatal error", entry!.Description);
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", entry.Symbol);
|
||||
Assert.Equal("msi", entry.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookup_finds_intune_specific_code()
|
||||
{
|
||||
// Wording pinned in the generator: rule explanations and the web tests depend on it.
|
||||
var entry = ErrorCodeCatalog.TryLookup("0x87d1041c");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Contains("Application not detected", entry!.Description);
|
||||
Assert.Equal(ErrorCodeConfidence.High, entry.Confidence);
|
||||
Assert.Equal("intune-win32", entry.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookup_preserves_confidence_levels()
|
||||
{
|
||||
var high = ErrorCodeCatalog.TryLookup("0x80070005");
|
||||
var medium = ErrorCodeCatalog.TryLookup("0x87d30000");
|
||||
var low = ErrorCodeCatalog.TryLookup("0x87d30004");
|
||||
var medium = ErrorCodeCatalog.TryLookup("0x87d30000"); // IME enum member paraphrase
|
||||
var low = ErrorCodeCatalog.TryLookup("0x87d00213"); // v1 entry without primary source
|
||||
|
||||
Assert.Equal(ErrorCodeConfidence.High, high!.Confidence);
|
||||
Assert.Equal(ErrorCodeConfidence.Medium, medium!.Confidence);
|
||||
Assert.StartsWith("ime:", medium.Source);
|
||||
Assert.Equal(ErrorCodeConfidence.Low, low!.Confidence);
|
||||
Assert.Equal("legacy-catalog-v1", low.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Intune_detection_rule_codes_carry_the_ime_meaning()
|
||||
{
|
||||
// v1 glossed these two as download/size errors; the IME enum says detection-rule errors.
|
||||
Assert.Contains("unknown detection type", ErrorCodeCatalog.TryLookup("0x87d30004")!.Description);
|
||||
Assert.Contains("invalid detection value", ErrorCodeCatalog.TryLookup("0x87d30006")!.Description);
|
||||
Assert.Equal("UnzipError", ErrorCodeCatalog.TryLookup("0x87d30067")!.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_hresult_has_an_explicit_entry()
|
||||
{
|
||||
var entry = ErrorCodeCatalog.TryLookup("0x00000000");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("S_OK", entry!.Symbol);
|
||||
// The decimal "0" stays the MSI exit code; the two never merge.
|
||||
Assert.Equal("ERROR_SUCCESS", ErrorCodeCatalog.TryLookup("0")?.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_derives_msi_exit_code_from_hresult_from_win32()
|
||||
{
|
||||
var result = ErrorCodeCatalog.TryLookupDetailed("0x80070643");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("1603", result!.Key);
|
||||
Assert.Equal(1603, result.DerivedFromWin32);
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", result.Entry.Symbol);
|
||||
Assert.Null(ErrorCodeCatalog.All.GetValueOrDefault("0x80070643"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_derives_from_signed_decimal_too()
|
||||
{
|
||||
// -2147023293 == 0x80070643
|
||||
var result = ErrorCodeCatalog.TryLookupDetailed("-2147023293");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("1603", result!.Key);
|
||||
Assert.Equal(1603, result.DerivedFromWin32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_never_derives_a_zero_low_word_to_success()
|
||||
{
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupDetailed("0x80070000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_prefers_the_direct_entry_over_derivation()
|
||||
{
|
||||
var result = ErrorCodeCatalog.TryLookupDetailed("0x80070005");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("0x80070005", result!.Key);
|
||||
Assert.Null(result.DerivedFromWin32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_does_not_derive_outside_the_msi_range()
|
||||
{
|
||||
// 0x80070999 → 2457: no decimal entry, no Win32 entry → miss, not a bogus derivation.
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupDetailed("0x80070999"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupBySymbol_resolves_symbols_case_insensitively()
|
||||
{
|
||||
var result = ErrorCodeCatalog.TryLookupBySymbol("ERROR_INSTALL_FAILURE");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("1603", result!.Key);
|
||||
|
||||
Assert.Equal("0x80240022", ErrorCodeCatalog.TryLookupBySymbol("wu_e_all_updates_failed")?.Key);
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupBySymbol("NOT_A_SYMBOL"));
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupBySymbol(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupEnforcementState_resolves_value_and_name()
|
||||
{
|
||||
var byValue = ErrorCodeCatalog.TryLookupEnforcementState("1000");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal("Success", byValue!.Name);
|
||||
|
||||
var byName = ErrorCodeCatalog.TryLookupEnforcementState("ErrorDownloadingContent");
|
||||
Assert.NotNull(byName);
|
||||
Assert.Equal("5003", byName!.Value);
|
||||
|
||||
Assert.Equal("NotAttemptedDependencyWithFailure", ErrorCodeCatalog.TryLookupEnforcementState("6001")?.Name);
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupEnforcementState("424242"));
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupEnforcementState(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_update_symbols_the_agent_used_to_hardcode_are_present()
|
||||
{
|
||||
// WindowsUpdateTracker reads hresultSymbol from the catalog since the v2 catalog.
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", ErrorCodeCatalog.TryLookup("0x80240022")!.Symbol);
|
||||
Assert.Equal("WU_E_UH_INSTALLERFAILURE", ErrorCodeCatalog.TryLookup("0x8024200B")!.Symbol);
|
||||
Assert.Equal("CBS_E_STORE_CORRUPTION", ErrorCodeCatalog.TryLookup("0x800F0831")!.Symbol);
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", ErrorCodeCatalog.TryLookup("0x800F0922")!.Symbol);
|
||||
Assert.Equal("PSFX_E_MISSING_PAYLOAD_FILE", ErrorCodeCatalog.TryLookup("0x800F0991")!.Symbol);
|
||||
Assert.Equal("ERROR_TIMEOUT", ErrorCodeCatalog.TryLookup("0x800705B4")!.Symbol);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Codes_named_by_analysis_rules_are_present()
|
||||
{
|
||||
foreach (var code in new[]
|
||||
{
|
||||
"0x800705b4", "0x801c03ea", "0x81039001", "0x81039023", "0x81039024", "0x80070490",
|
||||
"0x80180014", "0x80180018", "0xc1036501", "0x801c03f3", "0x80070774", "0x87d13b9c",
|
||||
"0x80073d02", "0x80073cf0", "0x80073cf9", "0x80073cff", "0x80004005", "1601", "1653", "1641",
|
||||
})
|
||||
{
|
||||
Assert.NotNull(ErrorCodeCatalog.TryLookup(code));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -101,6 +290,6 @@ public sealed class ErrorCodeCatalogTests
|
||||
{
|
||||
var entry = ErrorCodeCatalog.TryLookup("0x80070005");
|
||||
Assert.NotNull(entry);
|
||||
Assert.False(string.IsNullOrWhiteSpace(entry!.Source), "Source must not be empty");
|
||||
Assert.StartsWith("msdoc:https://learn.microsoft.com/", entry!.Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,26 @@ public sealed class SharedManifestParityTests
|
||||
// v2: adds the "types" section (full wire-type graph) and drops v1's
|
||||
// "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types").
|
||||
["schemaVersion"] = 2,
|
||||
// "defaults" = the value of every wire field on a freshly constructed instance (the C#
|
||||
// initializer): the web's "custom" highlight compares stored values against these
|
||||
// instead of a hand-copied map. Nested objects appear as null — they have their own section.
|
||||
["adminConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
|
||||
["defaults"] = WireDefaults(typeof(AdminConfiguration)),
|
||||
},
|
||||
["tenantConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(TenantConfiguration)),
|
||||
["defaults"] = WireDefaults(typeof(TenantConfiguration)),
|
||||
},
|
||||
// What an agent receives (GET agent/config) at its class defaults — the runtime column of
|
||||
// the Tenant Config Report highlights every effective value that differs from these.
|
||||
["agentConfig"] = new Dictionary<string, object?>
|
||||
{
|
||||
["responseDefaults"] = WireDefaults(typeof(AgentConfigResponse)),
|
||||
["collectorDefaults"] = WireDefaults(typeof(CollectorConfiguration)),
|
||||
["analyzerDefaults"] = WireDefaults(typeof(AnalyzerConfiguration)),
|
||||
},
|
||||
// Enum member order is load-bearing (append-only ordinals) — declaration order kept.
|
||||
["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
|
||||
@@ -98,7 +111,7 @@ public sealed class SharedManifestParityTests
|
||||
// Declaration order kept: the web derives a union type from this list, order is cosmetic
|
||||
// but a stable order keeps the generated file diff-minimal.
|
||||
["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)),
|
||||
// Every IApiResponse implementer + [WireContract] type, transitively closed —
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of utils/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
};
|
||||
@@ -116,6 +129,29 @@ public sealed class SharedManifestParityTests
|
||||
private static string[] WireFieldNames(Type type)
|
||||
=> WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Wire name → initializer value of a fresh instance. Scalars, strings, enums (by name),
|
||||
/// DateTime (round-trip) and arrays/lists of those travel as-is; a nested object is null.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> WireDefaults(Type type)
|
||||
{
|
||||
var instance = Activator.CreateInstance(type)!;
|
||||
return WireProperties(type).ToDictionary(
|
||||
p => JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
p => DefaultValue(p.GetValue(instance)));
|
||||
}
|
||||
|
||||
private static object? DefaultValue(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool or int or long or double or float or decimal => value,
|
||||
Enum e => e.ToString(),
|
||||
DateTime d => d.ToString("O"),
|
||||
System.Collections.IEnumerable list => list.Cast<object?>().Select(DefaultValue).ToArray(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IEnumerable<PropertyInfo> WireProperties(Type type)
|
||||
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
|
||||
|
||||
@@ -99,4 +99,47 @@ public class SingleFlightCacheTests
|
||||
// w1 is gone: its factory runs again.
|
||||
Assert.Equal("one-again", await cache.GetOrAddAsync("w1", Ttl, () => Task.FromResult("one-again")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Factory_exceeding_the_budget_faults_every_waiter_and_is_evicted()
|
||||
{
|
||||
var cache = new SingleFlightCache<int>(() => DateTime.UtcNow, factoryBudget: TimeSpan.FromMilliseconds(50));
|
||||
var runs = 0;
|
||||
|
||||
async Task<int> Hanging(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref runs);
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var first = cache.GetOrAddAsync("k", Ttl, Hanging);
|
||||
var second = cache.GetOrAddAsync("k", Ttl, Hanging);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => first);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => second);
|
||||
Assert.Equal(1, runs);
|
||||
Assert.Equal(0, cache.Count);
|
||||
|
||||
// The next caller starts a fresh run instead of inheriting the faulted one.
|
||||
Assert.Equal(7, await cache.GetOrAddAsync("k", Ttl, _ => Task.FromResult(7)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Factory_finishing_inside_the_budget_is_cached()
|
||||
{
|
||||
var cache = new SingleFlightCache<int>(() => DateTime.UtcNow, factoryBudget: TimeSpan.FromSeconds(5));
|
||||
var runs = 0;
|
||||
|
||||
Task<int> Quick(CancellationToken ct)
|
||||
{
|
||||
Assert.True(ct.CanBeCanceled);
|
||||
Interlocked.Increment(ref runs);
|
||||
return Task.FromResult(3);
|
||||
}
|
||||
|
||||
Assert.Equal(3, await cache.GetOrAddAsync("k", Ttl, Quick));
|
||||
Assert.Equal(3, await cache.GetOrAddAsync("k", Ttl, Quick));
|
||||
Assert.Equal(1, runs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Azure.Core;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The storage SDK budgets are sized so one hot-path operation — every attempt into its
|
||||
/// timeout, every backoff at its cap — finishes before the agent's 30 s upload timeout. A
|
||||
/// change to either side that breaks that relation turns a storage brownout into zombie
|
||||
/// requests plus agent replays (audit 2026-09-05 F09).
|
||||
/// </summary>
|
||||
public class StorageClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Table_worst_case_stays_under_the_agent_upload_timeout()
|
||||
{
|
||||
var worst = StorageClientOptions.WorstCase(StorageClientOptions.Table().Retry);
|
||||
Assert.True(worst < StorageClientOptions.AgentUploadTimeout, $"worst case {worst} must stay under {StorageClientOptions.AgentUploadTimeout}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Queue_worst_case_stays_under_the_agent_upload_timeout()
|
||||
{
|
||||
var worst = StorageClientOptions.WorstCase(StorageClientOptions.Queue().Retry);
|
||||
Assert.True(worst < StorageClientOptions.AgentUploadTimeout, $"worst case {worst} must stay under {StorageClientOptions.AgentUploadTimeout}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blob_worst_case_is_bounded()
|
||||
{
|
||||
var worst = StorageClientOptions.WorstCase(StorageClientOptions.Blob().Retry);
|
||||
Assert.True(worst < TimeSpan.FromMinutes(5), $"worst case {worst} must stay under five minutes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worst_case_counts_every_attempt_and_every_capped_backoff()
|
||||
{
|
||||
// 3 attempts × 8 s + backoffs 0.5 s·1.2 and 1 s·1.2 (both under the 4 s cap) = 25.8 s.
|
||||
var retry = StorageClientOptions.Table().Retry;
|
||||
Assert.Equal(RetryMode.Exponential, retry.Mode);
|
||||
Assert.Equal(StorageClientOptions.HotPathMaxRetries, retry.MaxRetries);
|
||||
Assert.Equal(StorageClientOptions.HotPathNetworkTimeout, retry.NetworkTimeout);
|
||||
|
||||
var expected = TimeSpan.FromTicks(StorageClientOptions.HotPathNetworkTimeout.Ticks * 3)
|
||||
+ TimeSpan.FromTicks((long)(StorageClientOptions.HotPathRetryDelay.Ticks * 1.2))
|
||||
+ TimeSpan.FromTicks((long)(StorageClientOptions.HotPathRetryDelay.Ticks * 2 * 1.2));
|
||||
|
||||
Assert.Equal(expected, StorageClientOptions.WorstCase(retry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blob_worst_case_counts_four_attempts_and_three_backoffs()
|
||||
{
|
||||
var blob = StorageClientOptions.Blob().Retry;
|
||||
// 4 attempts × 60 s + backoffs 0.6 s, 1.2 s, 2.4 s (all under the 4 s cap).
|
||||
var expected = TimeSpan.FromTicks(StorageClientOptions.BlobNetworkTimeout.Ticks * 4)
|
||||
+ TimeSpan.FromMilliseconds(600) + TimeSpan.FromMilliseconds(1200) + TimeSpan.FromMilliseconds(2400);
|
||||
Assert.Equal(expected, StorageClientOptions.WorstCase(blob));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Queue_options_carry_the_requested_encoding()
|
||||
{
|
||||
Assert.Equal(Azure.Storage.Queues.QueueMessageEncoding.Base64,
|
||||
StorageClientOptions.Queue(Azure.Storage.Queues.QueueMessageEncoding.Base64).MessageEncoding);
|
||||
Assert.Equal(Azure.Storage.Queues.QueueMessageEncoding.None, StorageClientOptions.Queue().MessageEncoding);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -59,4 +62,40 @@ public class StorageErrorsTests
|
||||
Assert.False(StorageErrors.IsPayloadTooLarge(null!));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_response_at_all_is_transient()
|
||||
=> Assert.True(StorageErrors.IsTransient(Failure(0)));
|
||||
|
||||
// ---- Exception overload: what an exhausted SDK budget looks like on a path without a caller token ----
|
||||
|
||||
[Fact]
|
||||
public void Sdk_network_timeout_is_transient()
|
||||
=> Assert.True(StorageErrors.IsTransient((Exception)new TaskCanceledException("exceeded the configured timeout")));
|
||||
|
||||
[Fact]
|
||||
public void Socket_failures_are_transient()
|
||||
{
|
||||
Assert.True(StorageErrors.IsTransient(new IOException("connection reset")));
|
||||
Assert.True(StorageErrors.IsTransient(new HttpRequestException("name resolution failed")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregate_of_retried_attempts_is_transient_only_when_every_attempt_was()
|
||||
{
|
||||
var allTransient = new AggregateException(new TaskCanceledException(), Failure(503), new IOException());
|
||||
var mixed = new AggregateException(new TaskCanceledException(), Failure(404));
|
||||
|
||||
Assert.True(StorageErrors.IsTransient(allTransient));
|
||||
Assert.False(StorageErrors.IsTransient(mixed));
|
||||
Assert.False(StorageErrors.IsTransient(new AggregateException()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ordinary_exceptions_are_not_transient()
|
||||
{
|
||||
Assert.False(StorageErrors.IsTransient(new InvalidOperationException()));
|
||||
Assert.False(StorageErrors.IsTransient((Exception)Failure(412)));
|
||||
Assert.False(StorageErrors.IsTransient((Exception?)null!));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two MCP usage counters go through <see cref="TableCasRetry"/> with a retry budget sized for
|
||||
/// parallel tool calls of one user (2026-09-06: the previous three-attempt loop without backoff lost
|
||||
/// 3 of 6 concurrent increments). These tests pin the budget, the merge-patch shape and the
|
||||
/// create-with-AddEntity path against a mocked table surface.
|
||||
/// </summary>
|
||||
public class TableUserUsageRepositoryCasTests
|
||||
{
|
||||
private sealed class TableHarness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Added { get; } = new();
|
||||
public List<(TableEntity Entity, ETag ETag, TableUpdateMode Mode)> Updates { get; } = new();
|
||||
public Func<Response<TableEntity>> Read { get; set; } = () => throw new RequestFailedException(404, "not found");
|
||||
public Queue<Func<Response>> UpdateBehaviors { get; } = new();
|
||||
|
||||
public TableHarness()
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Task.FromResult(Read()));
|
||||
Table.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Added.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(t => t.UpdateEntityAsync(It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) =>
|
||||
{
|
||||
Updates.Add((e, tag, mode));
|
||||
return Task.FromResult(UpdateBehaviors.Count > 0 ? UpdateBehaviors.Dequeue()() : Mock.Of<Response>());
|
||||
});
|
||||
}
|
||||
|
||||
public static Response<TableEntity> Row(string pk, string rk, long count, string etag)
|
||||
{
|
||||
var e = new TableEntity(pk, rk) { ["RequestCount"] = count };
|
||||
e.ETag = new ETag(etag);
|
||||
return Response.FromValue(e, Mock.Of<Response>());
|
||||
}
|
||||
|
||||
public static Func<Response> Conflict => () => throw new RequestFailedException(412, "precondition failed");
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableHarness Users { get; } = new();
|
||||
public TableHarness Tenants { get; } = new();
|
||||
public TableUserUsageRepository Repo { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UserUsageLog)).Returns(Users.Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.McpTenantUsage)).Returns(Tenants.Table.Object);
|
||||
var storage = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
Repo = new TableUserUsageRepository(storage, NullLogger<TableUserUsageRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_budget_is_sized_for_parallel_tool_calls()
|
||||
{
|
||||
Assert.Equal(8, TableUserUsageRepository.UsageCounterRetries);
|
||||
Assert.True(TableUserUsageRepository.UsageCounterRetries > TableCasRetry.DefaultRetries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Four_consecutive_etag_conflicts_still_land_the_user_increment()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Users.Read = () => TableHarness.Row("oid-1", "20260906_get_session_events:sessions_{id}_events", 5, "e1");
|
||||
for (var i = 0; i < 4; i++)
|
||||
h.Users.UpdateBehaviors.Enqueue(TableHarness.Conflict);
|
||||
|
||||
await h.Repo.IncrementUsageAsync("oid-1", "user@example.test", "tid-home", "get_session_events:sessions_{id}_events");
|
||||
|
||||
// The old loop gave up after three attempts; four conflicts plus the landing write = five updates.
|
||||
Assert.Equal(5, h.Users.Updates.Count);
|
||||
var (entity, etag, mode) = h.Users.Updates.Last();
|
||||
Assert.Equal(6L, entity.GetInt64("RequestCount"));
|
||||
Assert.Equal(new ETag("e1"), etag);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Empty(h.Users.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_user_row_is_created_with_add_and_full_attribution()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Repo.IncrementUsageAsync("oid-1", "user@example.test", "tid-home", "get_metrics:metrics");
|
||||
|
||||
var row = Assert.Single(h.Users.Added);
|
||||
Assert.Equal("oid-1", row.PartitionKey);
|
||||
Assert.EndsWith("_get_metrics:metrics", row.RowKey);
|
||||
Assert.Equal(1L, row.GetInt64("RequestCount"));
|
||||
Assert.Equal("user@example.test", row.GetString("UserPrincipalName"));
|
||||
Assert.Equal("tid-home", row.GetString("TenantId"));
|
||||
Assert.Equal("get_metrics:metrics", row.GetString("Endpoint"));
|
||||
Assert.Empty(h.Users.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_increment_merges_only_the_counter_and_attribution_columns()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Tenants.Read = () => TableHarness.Row("tid-customer", "20260906_oid-1", 41, "t1");
|
||||
|
||||
await h.Repo.IncrementTenantUsageAsync("tid-customer", "oid-1", "msp@example.test", "tid-home");
|
||||
|
||||
var (entity, etag, mode) = Assert.Single(h.Tenants.Updates);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(new ETag("t1"), etag);
|
||||
Assert.Equal(42L, entity.GetInt64("RequestCount"));
|
||||
Assert.Equal("msp@example.test", entity.GetString("UserPrincipalName"));
|
||||
Assert.Equal("tid-home", entity.GetString("HomeTenantId"));
|
||||
Assert.NotNull(entity["LastRequestAt"]);
|
||||
// A merge patch never re-sends the identity columns of the row.
|
||||
Assert.False(entity.ContainsKey("Date"));
|
||||
Assert.False(entity.ContainsKey("UserId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_increment_is_skipped_without_a_tenant_or_user()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Repo.IncrementTenantUsageAsync("", "oid-1", null, null);
|
||||
await h.Repo.IncrementTenantUsageAsync("tid-customer", " ", null, null);
|
||||
|
||||
Assert.Empty(h.Tenants.Updates);
|
||||
Assert.Empty(h.Tenants.Added);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public class TenantConfigProjectionTests
|
||||
{
|
||||
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride",
|
||||
"mcpUsagePlanOverride", "payingCustomer", "managedByProTenantId",
|
||||
"disabled", "disabledReason",
|
||||
"disabled", "disabledReason", "mcpDisabled", "mcpDisabledReason",
|
||||
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -107,22 +108,22 @@ public class TestLogPatternFunctionTests
|
||||
public void Validate_RejectsMissingPatternAndLines()
|
||||
{
|
||||
Assert.Contains("pattern", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
|
||||
new TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
|
||||
Assert.Contains("sampleLines", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { Pattern = "a" })!);
|
||||
new TestLogPatternRequest { Pattern = "a" })!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsTooManyLines_AndBadFormat()
|
||||
{
|
||||
var tooMany = new TestLogPatternFunction.TestLogPatternRequest
|
||||
var tooMany = new TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
SampleLines = Enumerable.Repeat("line", TestLogPatternFunction.MaxSampleLines + 1).ToList(),
|
||||
};
|
||||
Assert.Contains("at most", TestLogPatternFunction.ValidateRequest(tooMany)!);
|
||||
|
||||
var badFormat = new TestLogPatternFunction.TestLogPatternRequest
|
||||
var badFormat = new TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = "json",
|
||||
@@ -136,7 +137,7 @@ public class TestLogPatternFunctionTests
|
||||
{
|
||||
foreach (var format in new[] { null, "cmtrace", "text", "TEXT" })
|
||||
{
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternFunction.TestLogPatternRequest
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = format,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins how a caller's rate-limit budget class is derived: from the signed client-authentication
|
||||
/// claim only. In production the portal SPA, the MCP server and the API share one app registration,
|
||||
/// so <c>appid</c>/<c>azp</c> cannot separate them — <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) can:
|
||||
/// 0 = public client (portal), 1/2 = client secret / certificate (MCP server, integrations). Anything
|
||||
/// else fails closed to the stricter integration budget, and no header can move a caller.
|
||||
/// </summary>
|
||||
public class ThrottleSurfaceTests
|
||||
{
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static readonly (string, string)[] Person = { ("tid", "11111111-1111-1111-1111-111111111111"), ("upn", "user@example.test"), ("oid", "22222222-2222-2222-2222-222222222222") };
|
||||
|
||||
[Fact]
|
||||
public void PublicClient_v1_appidacr0_isPortal()
|
||||
=> Assert.Equal(ThrottleSurface.Portal, Principal(Person.Append(("appidacr", "0")).ToArray()).GetThrottleSurface());
|
||||
|
||||
[Fact]
|
||||
public void PublicClient_v2_azpacr0_isPortal()
|
||||
=> Assert.Equal(ThrottleSurface.Portal, Principal(Person.Append(("azpacr", "0")).ToArray()).GetThrottleSurface());
|
||||
|
||||
[Theory]
|
||||
[InlineData("appidacr", "1")] // client secret — the MCP server in production
|
||||
[InlineData("appidacr", "2")] // certificate
|
||||
[InlineData("azpacr", "1")]
|
||||
[InlineData("azpacr", "2")]
|
||||
public void ConfidentialClient_isIntegration(string claim, string value)
|
||||
=> Assert.Equal(ThrottleSurface.Integration, Principal(Person.Append((claim, value)).ToArray()).GetThrottleSurface());
|
||||
|
||||
[Fact]
|
||||
public void MissingClaim_failsClosed_toIntegration()
|
||||
=> Assert.Equal(ThrottleSurface.Integration, Principal(Person).GetThrottleSurface());
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("00")]
|
||||
[InlineData("zero")]
|
||||
public void MalformedClaim_failsClosed_toIntegration(string value)
|
||||
=> Assert.Equal(ThrottleSurface.Integration, Principal(Person.Append(("appidacr", value)).ToArray()).GetThrottleSurface());
|
||||
|
||||
[Fact]
|
||||
public void AppOnlyPrincipal_isIntegration_evenWithAcr0()
|
||||
{
|
||||
// idtyp=app wins: an application never gets the interactive budget, whatever its acr says.
|
||||
var principal = Principal(("tid", "11111111-1111-1111-1111-111111111111"), ("idtyp", "app"), ("appid", "33333333-3333-3333-3333-333333333333"), ("appidacr", "0"));
|
||||
Assert.Equal(ThrottleSurface.Integration, principal.GetThrottleSurface());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dimension_names_are_stable()
|
||||
{
|
||||
// Bucket keys and the ThrottleSurface request dimension (platform-health KQL) depend on these.
|
||||
Assert.Equal("portal", ThrottleSurface.Portal.ToDimension());
|
||||
Assert.Equal("integration", ThrottleSurface.Integration.ToDimension());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Request-side twin of <see cref="TypedResponseGuardTests"/> (D-207). Every HTTP request body is
|
||||
/// read through <see cref="RequestBody"/> into a Shared <see cref="IApiRequest"/> type, which the
|
||||
/// manifest exports to TypeScript — so the web and the MCP server compile against the same
|
||||
/// shape the backend deserialises. Three guards:
|
||||
/// A: no raw deserializer on a request body inside <c>Functions/</c> (source scan, EMPTY
|
||||
/// baseline except the permanent exemptions below),
|
||||
/// B: the Newtonsoft branch (<c>ReadNewtonsoftAsync</c>) only where the downstream is
|
||||
/// Newtonsoft-bound — a frozen per-file baseline that may only shrink,
|
||||
/// C: no request DTO declared in the Functions assembly (reflection).
|
||||
/// </summary>
|
||||
public class TypedRequestGuardTests
|
||||
{
|
||||
/// <summary>Raw body deserializers a handler must not call — RequestBody owns them.</summary>
|
||||
private static readonly Regex RawDeserializer = new(
|
||||
@"\b(ReadFromJsonAsync<|JsonConvert\.DeserializeObject<|JsonConvert\.DeserializeAnonymousType\(|JsonSerializer\.Deserialize(Async)?<|JObject\.Parse\(|JsonDocument\.Parse\()",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex NewtonsoftBranch = new(@"\bReadNewtonsoftAsync<", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Files where a raw parser is legitimate. Each entry names WHY; a new entry is a decision,
|
||||
/// not a convenience.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> RawParserExemptions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Agent-facing routes: wire frozen by D-200 fixtures; their reader is not this package's concern.
|
||||
["Functions/Sessions/RegisterSessionFunction.cs"] = "agent route (D-200)",
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "agent route (D-200)",
|
||||
["Functions/Ingest/IngestTelemetryFunction.cs"] = "agent route, streamed Newtonsoft (D-200)",
|
||||
["Functions/Ingest/ReportDistressFunction.cs"] = "agent route (pre-auth)",
|
||||
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "agent route",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
|
||||
// Foreign schema: Azure Monitor common alert schema, not our contract.
|
||||
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
|
||||
// Parses the UPSTREAM Kusto response, not the request body.
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
|
||||
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
|
||||
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
|
||||
["Functions/Vulnerability/GetCpeMappingsFunction.cs"] = "stored pattern-list columns",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The Newtonsoft-bound bodies: tenant/admin configuration (redaction restore + patch service
|
||||
/// merge via JsonConvert.PopulateObject) and the rule documents. Porting them is a
|
||||
/// roundtrip-proven change, tracked in the backlog; until then this list only shrinks.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> NewtonsoftBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = 2,
|
||||
["Functions/Rules/GlobalRulesFunction.cs"] = 1,
|
||||
["Functions/Rules/ImeLogPatternsFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void A_NoRawBodyDeserializerInHandlers()
|
||||
{
|
||||
var violations = new List<string>();
|
||||
foreach (var (relative, text) in HandlerSources())
|
||||
{
|
||||
if (RawParserExemptions.ContainsKey(relative)) continue;
|
||||
var count = RawDeserializer.Matches(text).Count;
|
||||
if (count > 0)
|
||||
violations.Add($"{relative}: {count} raw body deserializer(s) — read the body through RequestBody.ReadAsync<T> into a Shared IApiRequest type.");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0, "Raw request-body parsing in handlers:\n " + string.Join("\n ", violations));
|
||||
|
||||
// Every exemption must still exist and still parse something, or it is stale.
|
||||
var stale = RawParserExemptions.Keys
|
||||
.Where(rel => !HandlerSources().Any(s => s.Relative.Equals(rel, StringComparison.OrdinalIgnoreCase) && RawDeserializer.IsMatch(s.Text)))
|
||||
.ToList();
|
||||
Assert.True(stale.Count == 0, "Stale raw-parser exemptions (remove them):\n " + string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void B_NewtonsoftBranchOnlyWhereBaselined()
|
||||
{
|
||||
var actual = HandlerSources()
|
||||
.Select(s => (s.Relative, Count: NewtonsoftBranch.Matches(s.Text).Count))
|
||||
.Where(x => x.Count > 0)
|
||||
.ToDictionary(x => x.Relative, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var violations = new List<string>();
|
||||
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!NewtonsoftBaseline.TryGetValue(file, out var allowed))
|
||||
violations.Add($"{file}: {count} NEW ReadNewtonsoftAsync site(s) — use ReadAsync<T> (System.Text.Json); the Newtonsoft branch is frozen.");
|
||||
else if (count > allowed)
|
||||
violations.Add($"{file}: {count} ReadNewtonsoftAsync site(s), baseline allows {allowed}.");
|
||||
}
|
||||
foreach (var (file, allowed) in NewtonsoftBaseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
actual.TryGetValue(file, out var count);
|
||||
if (count < allowed)
|
||||
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0, "Newtonsoft body-reader baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void C_NoRequestTypeDeclaredInFunctionsAssembly()
|
||||
{
|
||||
var functions = typeof(RequestBody).Assembly;
|
||||
|
||||
var implementers = functions.GetTypes()
|
||||
.Where(t => typeof(IApiRequest).IsAssignableFrom(t) && !t.IsInterface)
|
||||
.Select(t => t.FullName)
|
||||
.ToList();
|
||||
Assert.True(implementers.Count == 0,
|
||||
"IApiRequest implementers must live in AutopilotMonitor.Shared (manifest export):\n " + string.Join("\n ", implementers));
|
||||
|
||||
// Name-suffix sweep: a body DTO that forgot the marker would still be a `*Request` class here.
|
||||
var suspicious = functions.GetTypes()
|
||||
.Where(t => t.IsClass && t.Name.EndsWith("Request", StringComparison.Ordinal))
|
||||
.Where(t => !t.Name.EndsWith("FilterRequest", StringComparison.Ordinal)) // query-string parsing helpers
|
||||
.Where(t => t.Name != "MandrillSendRequest") // outbound Mandrill payload
|
||||
.Select(t => t.FullName)
|
||||
.ToList();
|
||||
Assert.True(suspicious.Count == 0,
|
||||
"Request-shaped classes in the Functions assembly — move them to Shared as IApiRequest, or exempt with a reason:\n " + string.Join("\n ", suspicious));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void D_EveryRequestTypeIsFlatAndInShared()
|
||||
{
|
||||
// Mirrors the response-side rule: flat (no base class outside object), declared in Shared,
|
||||
// no untyped slot except the one field map, so the TypeScript twin is exact.
|
||||
var shared = typeof(IApiRequest).Assembly;
|
||||
var offenders = new List<string>();
|
||||
foreach (var t in shared.GetTypes().Where(t => typeof(IApiRequest).IsAssignableFrom(t) && t.IsClass))
|
||||
{
|
||||
if (t.BaseType != typeof(object))
|
||||
offenders.Add($"{t.Name}: base type {t.BaseType?.Name} (request DTOs stay flat)");
|
||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
var slot = p.PropertyType;
|
||||
var isObjectMap = slot.IsGenericType && slot.GetGenericTypeDefinition() == typeof(Dictionary<,>) && slot.GetGenericArguments()[1] == typeof(object);
|
||||
if (slot == typeof(object))
|
||||
offenders.Add($"{t.Name}.{p.Name}: object slot (TypeScript gets unknown)");
|
||||
else if (isObjectMap && !(t == typeof(PatchTenantConfigurationFieldsRequest) && p.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields)))
|
||||
offenders.Add($"{t.Name}.{p.Name}: untyped map (only the tenant-config field patch carries one by design)");
|
||||
}
|
||||
}
|
||||
Assert.True(offenders.Count == 0, "Request DTO shape violations:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> HandlerSources()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var handlers = Path.Combine(functionsRoot, "Functions");
|
||||
foreach (var file in Directory.EnumerateFiles(handlers, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
yield return (relative, File.ReadAllText(file));
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class UserWhatsNewSeenStorageTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "user@contoso.com";
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserWhatsNewSeen_MissingRow_ReturnsNullMarks()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.Is<IEnumerable<string>>(s => s.SequenceEqual(new[] { "WhatsNewSeenPlatformUtc", "WhatsNewSeenAgentUtc" })),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not found"));
|
||||
|
||||
var seen = await h.Sut.GetUserWhatsNewSeenAsync(TenantId, Upn);
|
||||
|
||||
Assert.Null(seen.PlatformUtc);
|
||||
Assert.Null(seen.AgentUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_OlderThanExisting_DoesNotWrite()
|
||||
{
|
||||
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var h = new Harness();
|
||||
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
|
||||
{
|
||||
["WhatsNewSeenPlatformUtc"] = existing
|
||||
});
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", existing.AddMinutes(-1));
|
||||
|
||||
Assert.Empty(h.Upserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_NewerValue_MergeUpsertsOnlySelectedChannelAndUpn()
|
||||
{
|
||||
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var newer = existing.AddMinutes(1);
|
||||
var h = new Harness();
|
||||
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
|
||||
{
|
||||
["WhatsNewSeenAgentUtc"] = existing
|
||||
});
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "agent", newer);
|
||||
|
||||
var (entity, mode) = Assert.Single(h.Upserts);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(Upn, entity.GetString("Upn"));
|
||||
Assert.Equal(newer, entity.GetDateTime("WhatsNewSeenAgentUtc"));
|
||||
Assert.False(entity.ContainsKey("LastSeen"));
|
||||
Assert.False(entity.ContainsKey("UserRole"));
|
||||
Assert.False(entity.ContainsKey("WhatsNewSeenPlatformUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_MissingRow_MergeCreatesRowWithUpn()
|
||||
{
|
||||
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var h = new Harness();
|
||||
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not found"));
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", seen);
|
||||
|
||||
var (entity, mode) = Assert.Single(h.Upserts);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(TenantId, entity.PartitionKey);
|
||||
Assert.Equal(TableStorageService.PresenceRowKey(Upn), entity.RowKey);
|
||||
Assert.Equal(Upn, entity.GetString("Upn"));
|
||||
Assert.Equal(seen, entity.GetDateTime("WhatsNewSeenPlatformUtc"));
|
||||
Assert.False(entity.ContainsKey("LastSeen"));
|
||||
Assert.False(entity.ContainsKey("UserRole"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
public List<(TableEntity Entity, TableUpdateMode Mode)> Upserts { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, mode, _) =>
|
||||
{
|
||||
Upserts.Add((e, mode));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(Constants.TableNames.UserPresence)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
public void SetupRead(TableEntity entity)
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
entity.PartitionKey,
|
||||
entity.RowKey,
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(entity, Mock.Of<Response>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,24 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.Equal(4, r.DistinctCves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildsPriorityBreakdownFromTheMergedBand_legacyRowsCountInNone()
|
||||
{
|
||||
var act = Row("CVE-A", "s1", "Critical", 9.8, kev: true); act.Priority = "act";
|
||||
var attendHigh = Row("CVE-B", "s1", "Medium", 5.0); attendHigh.Priority = "attend";
|
||||
var attendLow = Row("CVE-B", "s2", "Medium", 5.0); attendLow.Priority = "track"; // merged band = attend
|
||||
var track = Row("CVE-C", "s2", "Low", 2.0); track.Priority = "TRACK"; // case-insensitive
|
||||
var legacy = Row("CVE-D", "s3", "High", 7.5); // empty priority
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { act, attendHigh, attendLow, track, legacy }, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(1, r.PriorityBreakdown.Act);
|
||||
Assert.Equal(1, r.PriorityBreakdown.Attend);
|
||||
Assert.Equal(1, r.PriorityBreakdown.Track);
|
||||
Assert.Equal(4, r.DistinctCves);
|
||||
Assert.Equal(r.KevCves, r.PriorityBreakdown.Act);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RanksTopCvesByAffectedSessionsThenScore_andRespectsTopN()
|
||||
{
|
||||
|
||||
@@ -663,6 +663,7 @@ public class VulnerabilityWireParityTests
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
|
||||
var topCves = new List<TopCve>
|
||||
{
|
||||
new TopCve
|
||||
@@ -687,6 +688,7 @@ public class VulnerabilityWireParityTests
|
||||
distinctCves = 3,
|
||||
kevCves = 1,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
truncated = false,
|
||||
},
|
||||
@@ -698,6 +700,7 @@ public class VulnerabilityWireParityTests
|
||||
DistinctCves = 3,
|
||||
KevCves = 1,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
Truncated = false,
|
||||
});
|
||||
@@ -707,6 +710,7 @@ public class VulnerabilityWireParityTests
|
||||
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
|
||||
AssertParity(
|
||||
@@ -718,6 +722,7 @@ public class VulnerabilityWireParityTests
|
||||
distinctCves = 0,
|
||||
kevCves = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
truncated = true,
|
||||
},
|
||||
@@ -729,6 +734,7 @@ public class VulnerabilityWireParityTests
|
||||
DistinctCves = 0,
|
||||
KevCves = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
Truncated = true,
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Builds the <c>types</c> section of shared-manifests.json (schemaVersion 2): a
|
||||
/// machine-readable description of every HTTP wire type in AutopilotMonitor.Shared —
|
||||
/// roots are all <see cref="IApiResponse"/> implementers plus every
|
||||
/// <see cref="WireContractAttribute"/>-marked type, closed transitively over
|
||||
/// roots are all <see cref="IApiResponse"/> and <see cref="IApiRequest"/> implementers plus
|
||||
/// every <see cref="WireContractAttribute"/>-marked type, closed transitively over
|
||||
/// references, collections, dictionaries, enums and <see cref="ProjectedItemsAttribute"/>
|
||||
/// item types. The web generator (scripts/generate-shared-manifest-types.js) turns it
|
||||
/// into utils/wire-types.generated.ts.
|
||||
@@ -36,6 +36,7 @@ internal static class WireTypeManifestBuilder
|
||||
var roots = assembly.GetTypes()
|
||||
.Where(t => !t.IsInterface && !t.IsAbstract &&
|
||||
(typeof(IApiResponse).IsAssignableFrom(t) ||
|
||||
typeof(IApiRequest).IsAssignableFrom(t) ||
|
||||
t.GetCustomAttribute<WireContractAttribute>() != null))
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
@@ -128,10 +129,11 @@ internal static class WireTypeManifestBuilder
|
||||
{
|
||||
var info = nullability.Create(p);
|
||||
var descriptor = Describe(info.Type, info, queue, $"{type.Name}.{p.Name}");
|
||||
// Property-level null never reaches the wire (WhenWritingNull omits the KEY —
|
||||
// that's what "optional" says). Explicit nulls only exist INSIDE collections
|
||||
// and dictionary values, so the flag stays on nested descriptors only.
|
||||
descriptor.Remove("nullable");
|
||||
// Property-level null never reaches the wire on a RESPONSE (WhenWritingNull omits
|
||||
// the KEY — that's what "optional" says), so the flag stays on nested descriptors
|
||||
// only. A REQUEST body may carry an explicit null (a PATCH that clears an override:
|
||||
// absent = unchanged, null = clear), so IApiRequest types keep it: `field?: T | null`.
|
||||
if (!typeof(IApiRequest).IsAssignableFrom(type)) descriptor.Remove("nullable");
|
||||
field["type"] = descriptor;
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -667,6 +667,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "Disabled", config.Disabled },
|
||||
{ "DisabledReason", config.DisabledReason },
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
{ "McpDisabled", config.McpDisabled },
|
||||
{ "McpDisabledReason", config.McpDisabledReason },
|
||||
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
|
||||
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
@@ -778,6 +780,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
DisabledReason = entity.GetString("DisabledReason"),
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
McpDisabled = entity.GetBoolean("McpDisabled") ?? false,
|
||||
McpDisabledReason = entity.GetString("McpDisabledReason"),
|
||||
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
|
||||
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
@@ -935,6 +939,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// Rate limiting per-role settings
|
||||
{ "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute },
|
||||
{ "GlobalAdminRateLimitRequestsPerMinute", config.GlobalAdminRateLimitRequestsPerMinute },
|
||||
{ "PortalUserRateLimitRequestsPerMinute", config.PortalUserRateLimitRequestsPerMinute },
|
||||
// Plan tier definitions
|
||||
{ "PlanTierDefinitionsJson", config.PlanTierDefinitionsJson ?? string.Empty },
|
||||
// Feedback settings
|
||||
@@ -1003,6 +1008,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// Rate limiting per-role settings
|
||||
UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120,
|
||||
GlobalAdminRateLimitRequestsPerMinute = entity.GetInt32("GlobalAdminRateLimitRequestsPerMinute") ?? 600,
|
||||
PortalUserRateLimitRequestsPerMinute = entity.GetInt32("PortalUserRateLimitRequestsPerMinute") ?? 600,
|
||||
// Plan tier definitions
|
||||
PlanTierDefinitionsJson = entity.GetString("PlanTierDefinitionsJson"),
|
||||
// Feedback settings
|
||||
|
||||
+6
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
|
||||
|
||||
public Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
|
||||
=> _storage.GetUserWhatsNewSeenAsync(tenantId, upn);
|
||||
|
||||
public Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc)
|
||||
=> _storage.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
|
||||
|
||||
public Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window)
|
||||
=> _storage.GetActivePresenceAsync(window);
|
||||
|
||||
|
||||
+2
-2
@@ -57,8 +57,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
|
||||
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
|
||||
|
||||
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null)
|
||||
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select);
|
||||
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null, CancellationToken cancellationToken = default)
|
||||
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select, cancellationToken);
|
||||
|
||||
public Task<SessionStats> GetSessionStatsAsync(string tenantId, int days)
|
||||
=> _storage.GetSessionStatsAsync(tenantId, days);
|
||||
|
||||
+62
-93
@@ -34,57 +34,46 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
_tenantTableClient = storage.GetTableClient(Constants.TableNames.McpTenantUsage);
|
||||
}
|
||||
|
||||
public async Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint)
|
||||
/// <summary>
|
||||
/// CAS attempts for the two usage counters — deliberately above
|
||||
/// <see cref="TableCasRetry.DefaultRetries"/>. MCP clients fan tool calls out in parallel, so
|
||||
/// six requests of one user hit the same (user, day, endpoint) row in the same instant; with
|
||||
/// the helper's jittered backoff one writer lands per round, and eight rounds absorb a burst
|
||||
/// of that size without dropping an increment (2026-09-06: three attempts without backoff lost
|
||||
/// 3 of 6). The increment runs off the request path, so the extra latency is invisible.
|
||||
/// </summary>
|
||||
internal const int UsageCounterRetries = 8;
|
||||
|
||||
private static long ReadCount(TableEntity entity)
|
||||
=> entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
|
||||
public Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint)
|
||||
{
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{endpoint}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
return TableCasRetry.MutateAsync(
|
||||
_tableClient, userId, rowKey,
|
||||
patch: read => new TableEntity(userId, rowKey)
|
||||
{
|
||||
var result = await _tableClient.GetEntityAsync<TableEntity>(userId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
await _tableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
["RequestCount"] = ReadCount(read) + 1,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
createMissing: () => new TableEntity(userId, rowKey)
|
||||
{
|
||||
var entity = new TableEntity(userId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["Endpoint"] = endpoint,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName,
|
||||
["TenantId"] = tenantId,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
|
||||
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
|
||||
["Date"] = date,
|
||||
["Endpoint"] = endpoint,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName,
|
||||
["TenantId"] = tenantId,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
operation: "IncrementUserUsage",
|
||||
tableName: Constants.TableNames.UserUsageLog,
|
||||
metrics: _metrics,
|
||||
logger: _logger,
|
||||
retries: UsageCounterRetries);
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null)
|
||||
@@ -215,65 +204,45 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
// ---- McpTenantUsage (organization-wide quota counters) ----
|
||||
|
||||
public async Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId)
|
||||
public Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId))
|
||||
return;
|
||||
return Task.CompletedTask;
|
||||
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{userId}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
return TableCasRetry.MutateAsync(
|
||||
_tenantTableClient, tenantId, rowKey,
|
||||
patch: read =>
|
||||
{
|
||||
var result = await _tenantTableClient.GetEntityAsync<TableEntity>(tenantId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
// Attribution columns (added 2026-09): the UPN as last seen, and the caller's home tenant
|
||||
// when this row was charged by a delegated (MSP) read. Refreshed on every increment so a
|
||||
// row created before the columns existed heals on its next hit.
|
||||
if (!string.IsNullOrEmpty(userPrincipalName))
|
||||
entity["UserPrincipalName"] = userPrincipalName;
|
||||
entity["HomeTenantId"] = homeTenantId ?? string.Empty;
|
||||
await _tenantTableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, rowKey)
|
||||
var patch = new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName ?? string.Empty,
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
["RequestCount"] = 1L,
|
||||
["RequestCount"] = ReadCount(read) + 1,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
// Attribution columns (added 2026-09): the UPN as last seen, and the caller's home
|
||||
// tenant when this row was charged by a delegated (MSP) read. Refreshed on every
|
||||
// increment so a row created before the columns existed heals on its next hit.
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tenantTableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
if (!string.IsNullOrEmpty(userPrincipalName))
|
||||
patch["UserPrincipalName"] = userPrincipalName;
|
||||
return patch;
|
||||
},
|
||||
createMissing: () => new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment tenant usage after {MaxRetries} retries: tenant={TenantId}, user={UserId}",
|
||||
maxRetries, LogSanitizer.Clean(tenantId), LogSanitizer.Clean(userId));
|
||||
["Date"] = date,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName ?? string.Empty,
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
operation: "IncrementTenantUsage",
|
||||
tableName: Constants.TableNames.McpTenantUsage,
|
||||
metrics: _metrics,
|
||||
logger: _logger,
|
||||
retries: UsageCounterRetries);
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
|
||||
@@ -74,6 +74,24 @@ public static class ClaimsPrincipalExtensions
|
||||
public static bool IsApplicationPrincipal(this ClaimsPrincipal principal)
|
||||
=> string.Equals(principal.FindFirst("idtyp")?.Value, "app", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
|
||||
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
|
||||
/// public client (the portal SPA, PKCE), <c>1</c> a client secret, <c>2</c> a certificate (the MCP
|
||||
/// server and server-side integrations). An app-only token, any other value and a missing claim
|
||||
/// fail closed to <see cref="Security.ThrottleSurface.Integration"/>, the stricter budget.
|
||||
/// </summary>
|
||||
public static Security.ThrottleSurface GetThrottleSurface(this ClaimsPrincipal principal)
|
||||
{
|
||||
if (principal.IsApplicationPrincipal())
|
||||
return Security.ThrottleSurface.Integration;
|
||||
|
||||
var acr = principal.FindFirst("appidacr")?.Value ?? principal.FindFirst("azpacr")?.Value;
|
||||
return string.Equals(acr?.Trim(), "0", StringComparison.Ordinal)
|
||||
? Security.ThrottleSurface.Portal
|
||||
: Security.ThrottleSurface.Integration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The calling application's Entra application (client) id: <c>appid</c> on v1.0 tokens, <c>azp</c> on
|
||||
/// v2.0 tokens. Null when the token names no client (never the case for a token Entra issued).
|
||||
|
||||
+6
-30
@@ -328,15 +328,9 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Authentication enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
ConsentSuccessReport? report;
|
||||
try
|
||||
{
|
||||
report = await req.ReadFromJsonAsync<ConsentSuccessReport>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
report = null;
|
||||
}
|
||||
var read = await req.ReadOptionalAsync<AutopilotConsentSuccessRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var report = read.Value;
|
||||
|
||||
var rawTrigger = report?.Trigger ?? string.Empty;
|
||||
var trigger = KnownConsentTriggers.Contains(rawTrigger) ? rawTrigger.ToLowerInvariant() : "unknown";
|
||||
@@ -357,12 +351,6 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private sealed class ConsentSuccessReport
|
||||
{
|
||||
public string? Trigger { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend reports consent flow failures (Azure AD errors) so we have visibility
|
||||
/// into broken consent flows that never reach our callback.
|
||||
@@ -376,15 +364,9 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Authentication enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
ConsentFailureReport? report;
|
||||
try
|
||||
{
|
||||
report = await req.ReadFromJsonAsync<ConsentFailureReport>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
report = null;
|
||||
}
|
||||
var read = await req.ReadOptionalAsync<AutopilotConsentFailureRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var report = read.Value;
|
||||
|
||||
var errorCode = report?.Error ?? "unknown";
|
||||
var errorDescription = report?.ErrorDescription ?? string.Empty;
|
||||
@@ -406,10 +388,4 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private sealed class ConsentFailureReport
|
||||
{
|
||||
public string? Error { get; set; }
|
||||
public string? ErrorDescription { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-16
@@ -70,7 +70,9 @@ public class DelegatedAdminManagementFunction
|
||||
{
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<GrantDelegatedAdminRequest>();
|
||||
var read = await req.ReadAsync<GrantDelegatedAdminRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
var validationError = ValidateGrantRequest(body, out var role);
|
||||
if (validationError != null)
|
||||
return await Bad(req, validationError);
|
||||
@@ -81,7 +83,7 @@ public class DelegatedAdminManagementFunction
|
||||
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
|
||||
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
|
||||
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
|
||||
var identity = await IdentityBindingRules.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
return await Unresolved(req);
|
||||
|
||||
@@ -232,7 +234,7 @@ public class DelegatedAdminManagementFunction
|
||||
if (!Guid.TryParse(body.TenantId, out _))
|
||||
return "a valid tenantId (GUID) is required";
|
||||
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
var bindingError = IdentityBindingRules.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
return bindingError;
|
||||
|
||||
@@ -255,18 +257,6 @@ public class DelegatedAdminManagementFunction
|
||||
|
||||
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRequest.HomeTenantUnresolvedCode, IdentityBindingRequest.HomeTenantUnresolvedMessage);
|
||||
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRules.HomeTenantUnresolvedCode, IdentityBindingRules.HomeTenantUnresolvedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public class GrantDelegatedAdminRequest
|
||||
{
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
/// <summary>The MANAGED (target) tenant the UPN may read.</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
+4
-2
@@ -57,8 +57,10 @@ public class DelegatedSlotManagementFunction
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var target = ctx.TargetTenantId;
|
||||
var body = await req.ReadFromJsonAsync<ReleaseDelegatedSlotHoldRequest>();
|
||||
if (body == null || (!body.All && string.IsNullOrWhiteSpace(body.InvitationId)))
|
||||
var read = await req.ReadAsync<ReleaseDelegatedSlotHoldRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (!body.All && string.IsNullOrWhiteSpace(body.InvitationId))
|
||||
{
|
||||
return await req.BadRequestAsync("invitationId or all=true is required");
|
||||
}
|
||||
|
||||
+4
-2
@@ -60,8 +60,10 @@ public class DelegationManagedTenantFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/managers/revoke")] HttpRequestData req)
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var body = await req.ReadFromJsonAsync<RevokeTenantManagerRequest>();
|
||||
if (body == null || !Guid.TryParse(body.HomeTenantId, out _))
|
||||
var read = await req.ReadAsync<RevokeTenantManagerRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (!Guid.TryParse(body.HomeTenantId, out _))
|
||||
{
|
||||
return await req.BadRequestAsync("a valid homeTenantId (GUID) is required");
|
||||
}
|
||||
|
||||
+12
-6
@@ -99,8 +99,10 @@ public class DelegationSelfServiceFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/managed/remove")] HttpRequestData req)
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var body = await req.ReadFromJsonAsync<RemoveManagedTenantRequest>();
|
||||
if (body == null || !Guid.TryParse(body.TenantId, out _))
|
||||
var read = await req.ReadAsync<RemoveManagedTenantRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (!Guid.TryParse(body.TenantId, out _))
|
||||
return await BadAsync(req, "a valid tenantId (GUID) is required");
|
||||
|
||||
var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName);
|
||||
@@ -191,8 +193,10 @@ public class DelegationSelfServiceFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/assignees")] HttpRequestData req)
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var body = await req.ReadFromJsonAsync<DelegationAssignRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
|
||||
var read = await req.ReadAsync<DelegationAssignRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Upn))
|
||||
return await BadAsync(req, "upn is required");
|
||||
|
||||
// The member key as listed under Access Management — a UPN or a service principal's app:<client-id>
|
||||
@@ -249,8 +253,10 @@ public class DelegationSelfServiceFunction
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/accept")] HttpRequestData req)
|
||||
{
|
||||
var ctx = req.GetRequestContext();
|
||||
var body = await req.ReadFromJsonAsync<AcceptDelegationInvitationRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Token))
|
||||
var read = await req.ReadAsync<AcceptDelegationInvitationRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Token))
|
||||
return await BadAsync(req, "token is required");
|
||||
|
||||
var result = await _svc.AcceptAsync(body.Token, ctx.TenantId, ctx.UserPrincipalName);
|
||||
|
||||
@@ -8,8 +8,6 @@ using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
@@ -67,20 +65,16 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
string body;
|
||||
using (var reader = new System.IO.StreamReader(req.Body))
|
||||
body = await reader.ReadToEndAsync();
|
||||
var read = await req.ReadAsync<BlockDeviceRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
|
||||
JObject json;
|
||||
try { json = JObject.Parse(body); }
|
||||
catch { return await req.BadRequestAsync("Invalid JSON body"); }
|
||||
|
||||
var tenantId = json["tenantId"]?.ToString();
|
||||
var serialNumber = json["serialNumber"]?.ToString();
|
||||
var durationHours = json["durationHours"]?.Value<int>() ?? 12;
|
||||
var reason = json["reason"]?.ToString();
|
||||
var action = json["action"]?.ToString() ?? "Block";
|
||||
var blockedSessionId = NormalizeOptionalSessionId(json["blockedSessionId"]?.ToString());
|
||||
var tenantId = body.TenantId;
|
||||
var serialNumber = body.SerialNumber;
|
||||
var durationHours = body.DurationHours ?? 12;
|
||||
var reason = body.Reason;
|
||||
var action = body.Action ?? "Block";
|
||||
var blockedSessionId = NormalizeOptionalSessionId(body.BlockedSessionId);
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
||||
return await req.BadRequestAsync("tenantId and serialNumber are required");
|
||||
|
||||
@@ -75,8 +75,10 @@ public class EmailTemplatesFunction
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
|
||||
var error = EmailTemplateService.Validate(body?.Html);
|
||||
var read = await req.ReadAsync<EmailTemplateRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
var error = EmailTemplateService.Validate(body.Html);
|
||||
if (error is not null)
|
||||
return await BadRequest(req, error);
|
||||
|
||||
@@ -118,8 +120,9 @@ public class EmailTemplatesFunction
|
||||
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
|
||||
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
|
||||
|
||||
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
|
||||
var draft = string.IsNullOrWhiteSpace(body?.Html) ? null : body!.Html;
|
||||
var read = await req.ReadOptionalAsync<EmailTemplateRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var draft = string.IsNullOrWhiteSpace(read.Value?.Html) ? null : read.Value!.Html;
|
||||
if (draft is not null)
|
||||
{
|
||||
var error = EmailTemplateService.Validate(draft);
|
||||
@@ -155,8 +158,3 @@ public class EmailTemplatesFunction
|
||||
return await req.BadRequestAsync(error);
|
||||
}
|
||||
}
|
||||
|
||||
public class EmailTemplateRequest
|
||||
{
|
||||
public string? Html { get; set; }
|
||||
}
|
||||
|
||||
+6
-7
@@ -66,8 +66,10 @@ public class IdentityBindingManagementFunction
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
var body = await req.ReadFromJsonAsync<IdentityBindingRequest>();
|
||||
var error = IdentityBindingRequest.Validate(body?.HomeTenantId, body?.ObjectId);
|
||||
var read = await req.ReadAsync<IdentityBindingRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
var error = IdentityBindingRules.Validate(body.HomeTenantId, body.ObjectId);
|
||||
if (error != null)
|
||||
return await Bad(req, error);
|
||||
|
||||
@@ -136,8 +138,8 @@ public class IdentityBindingManagementFunction
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.</summary>
|
||||
public class IdentityBindingRequest
|
||||
/// <summary>The shared validator/resolver for the binding fields every grant carries (<see cref="IdentityBindingRequest"/> is the Shared body DTO).</summary>
|
||||
public static class IdentityBindingRules
|
||||
{
|
||||
/// <summary>
|
||||
/// The identity a grant binds: the operator-supplied values when present, otherwise the resolver's answer.
|
||||
@@ -155,9 +157,6 @@ public class IdentityBindingRequest
|
||||
return resolved == null ? null : (resolved.TenantId, objectId ?? resolved.ObjectId);
|
||||
}
|
||||
|
||||
public string? HomeTenantId { get; set; }
|
||||
public string? ObjectId { get; set; }
|
||||
|
||||
/// <summary>Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).</summary>
|
||||
public static string? Validate(string? homeTenantId, string? objectId)
|
||||
{
|
||||
|
||||
+3
-15
@@ -88,15 +88,9 @@ public class SubmitOffboardingFeedbackFunction
|
||||
return await BadRequest(req, "Request body too large");
|
||||
}
|
||||
|
||||
SubmitOffboardingFeedbackRequest? body;
|
||||
try
|
||||
{
|
||||
body = await req.ReadFromJsonAsync<SubmitOffboardingFeedbackRequest>();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return await BadRequest(req, "Invalid request body");
|
||||
}
|
||||
var read = await req.ReadOptionalAsync<SubmitOffboardingFeedbackRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value;
|
||||
|
||||
var principal = req.FunctionContext.GetUser();
|
||||
var displayName = principal?.GetDisplayName() ?? upn;
|
||||
@@ -238,12 +232,6 @@ public class SubmitOffboardingFeedbackFunction
|
||||
return await req.ErrorAsync(HttpStatusCode.InternalServerError, Constants.ApiErrorCodes.InternalError, message);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubmitOffboardingFeedbackRequest
|
||||
{
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
internal enum SubmitOutcome
|
||||
{
|
||||
Ok,
|
||||
|
||||
+10
-25
@@ -87,9 +87,11 @@ public class TenantAdminManagementFunction
|
||||
|
||||
// Parse request body. A member is either a person (upn) or an application (applicationId — the
|
||||
// Entra client id of a service principal in THIS tenant, stored under the app:<client-id> key).
|
||||
var body = await req.ReadFromJsonAsync<AddTenantAdminRequest>();
|
||||
var isApplication = !string.IsNullOrWhiteSpace(body?.ApplicationId);
|
||||
if (body == null || (string.IsNullOrWhiteSpace(body.Upn) && !isApplication))
|
||||
var read = await req.ReadAsync<AddTenantAdminRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
var isApplication = !string.IsNullOrWhiteSpace(body.ApplicationId);
|
||||
if (string.IsNullOrWhiteSpace(body.Upn) && !isApplication)
|
||||
{
|
||||
return await req.BadRequestAsync("UPN or applicationId is required");
|
||||
}
|
||||
@@ -99,7 +101,7 @@ public class TenantAdminManagementFunction
|
||||
}
|
||||
var memberKey = isApplication
|
||||
? AutopilotMonitor.Shared.Constants.PrincipalKeys.ForApplication(body.ApplicationId!)
|
||||
: body.Upn;
|
||||
: body.Upn!;
|
||||
|
||||
// Determine role (default to Admin for backward compat, Viewer for an application), then validate
|
||||
// against the allow-list so arbitrary strings never reach storage. An application is read-only:
|
||||
@@ -278,8 +280,10 @@ public class TenantAdminManagementFunction
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var upn = requestCtx.UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<UpdateMemberPermissionsRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Role))
|
||||
var read = await req.ReadAsync<UpdateMemberPermissionsRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Role))
|
||||
{
|
||||
return await req.BadRequestAsync("Role is required");
|
||||
}
|
||||
@@ -357,22 +361,3 @@ public class TenantAdminManagementFunction
|
||||
return validRoles.FirstOrDefault(v => string.Equals(v, role, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public class AddTenantAdminRequest
|
||||
{
|
||||
/// <summary>The person's UPN. Leave empty when adding an application (<see cref="ApplicationId"/>).</summary>
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The Entra application (client) id of a service principal in this tenant. Stored under the
|
||||
/// <c>app:<client-id></c> member key; the role is fixed to Viewer.
|
||||
/// </summary>
|
||||
public string? ApplicationId { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public bool CanManageBootstrapTokens { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateMemberPermissionsRequest
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public bool CanManageBootstrapTokens { get; set; }
|
||||
}
|
||||
|
||||
+35
-40
@@ -84,8 +84,10 @@ public class TenantGroupManagementFunction
|
||||
{
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<CreateTenantGroupRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Name))
|
||||
var read = await req.ReadAsync<CreateTenantGroupRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Name))
|
||||
return await Bad(req, "name is required");
|
||||
|
||||
var groupId = await _delegatedAdminService.CreateGroupAsync(body.Name, currentUpn ?? "");
|
||||
@@ -111,8 +113,10 @@ public class TenantGroupManagementFunction
|
||||
{
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<UpdateTenantGroupRequest>();
|
||||
if (body == null || (string.IsNullOrWhiteSpace(body.Name) && body.ChargeHomeTenantQuota == null))
|
||||
var read = await req.ReadAsync<UpdateTenantGroupRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Name) && body.ChargeHomeTenantQuota == null)
|
||||
return await Bad(req, "name and/or chargeHomeTenantQuota is required");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(body.Name))
|
||||
@@ -176,17 +180,32 @@ public class TenantGroupManagementFunction
|
||||
/// BEFORE the delete removes them, so the bulk access removal is audited under each affected
|
||||
/// tenant and EVERY (former) assignee's live streams are cut — group authorization is join-time
|
||||
/// only, so without the kick they keep receiving the managed tenants' telemetry until their
|
||||
/// connection drops.
|
||||
/// connection drops. Deleting an OWNED group also ends conferred Pro for every member at once,
|
||||
/// so each member's retention grace anchor is stamped (the projection leaves no trace by itself).
|
||||
/// </summary>
|
||||
internal async Task DeleteGroupCoreAsync(string groupId, string? currentUpn)
|
||||
{
|
||||
var group = await _delegatedAdminService.GetGroupAsync(groupId);
|
||||
var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId);
|
||||
var owner = string.IsNullOrWhiteSpace(group?.OwnerTenantId) ? null : group!.OwnerTenantId;
|
||||
await _delegatedAdminService.DeleteGroupAsync(groupId);
|
||||
|
||||
foreach (var assignee in assignees)
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
|
||||
// The portal hides Delete for owned groups, but the route accepts any group id — without the
|
||||
// anchor a member that raised its retention under Pro (MSP) would be clamped to the Community
|
||||
// cap at the next sweep instead of getting the grace period.
|
||||
if (owner != null)
|
||||
{
|
||||
foreach (var member in group!.TenantIds)
|
||||
{
|
||||
if (string.Equals(member, owner, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
await _proConferral.RecordLossAsync(member, owner, "group-deleted");
|
||||
}
|
||||
}
|
||||
|
||||
if (group != null && group.AssigneeCount > 0)
|
||||
{
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn,
|
||||
@@ -211,8 +230,10 @@ public class TenantGroupManagementFunction
|
||||
{
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<AddGroupTenantRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.TenantId) || !Guid.TryParse(body.TenantId, out _))
|
||||
var read = await req.ReadAsync<AddGroupTenantRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.TenantId) || !Guid.TryParse(body.TenantId, out _))
|
||||
return await Bad(req, "a valid tenantId (GUID) is required");
|
||||
|
||||
var tenantId = body.TenantId.ToLowerInvariant();
|
||||
@@ -301,8 +322,10 @@ public class TenantGroupManagementFunction
|
||||
{
|
||||
var currentUpn = context.GetRequestContext().UserPrincipalName;
|
||||
|
||||
var body = await req.ReadFromJsonAsync<AssignGroupRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
|
||||
var read = await req.ReadAsync<AssignGroupRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
var body = read.Value!;
|
||||
if (string.IsNullOrWhiteSpace(body.Upn))
|
||||
return await Bad(req, "upn is required");
|
||||
|
||||
// Fail-closed role handling — mirror the delegated grant: default to least privilege, reject unknowns.
|
||||
@@ -310,7 +333,7 @@ public class TenantGroupManagementFunction
|
||||
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
|
||||
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
|
||||
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
var bindingError = IdentityBindingRules.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
return await Bad(req, bindingError);
|
||||
|
||||
@@ -320,10 +343,10 @@ public class TenantGroupManagementFunction
|
||||
return await NotFound(req);
|
||||
|
||||
var upn = body.Upn.ToLowerInvariant();
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
|
||||
var identity = await IdentityBindingRules.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRequest.HomeTenantUnresolvedCode, IdentityBindingRequest.HomeTenantUnresolvedMessage);
|
||||
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRules.HomeTenantUnresolvedCode, IdentityBindingRules.HomeTenantUnresolvedMessage);
|
||||
}
|
||||
|
||||
// Slot limit of the assignee's HOME tenant: the whole group's tenant set must fit (fresh read).
|
||||
@@ -430,31 +453,3 @@ public class TenantGroupManagementFunction
|
||||
return await req.NotFoundAsync("Group not found");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateTenantGroupRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UpdateTenantGroupRequest
|
||||
{
|
||||
/// <summary>New display name; omitted/blank = unchanged.</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>See <see cref="TenantGroup.ChargeHomeTenantQuota"/>; omitted = unchanged.</summary>
|
||||
public bool? ChargeHomeTenantQuota { get; set; }
|
||||
}
|
||||
|
||||
public class AddGroupTenantRequest
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AssignGroupRequest
|
||||
{
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
/// <summary>The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user