Compare commits

..

69 Commits

Author SHA1 Message Date
Oliver Kieselbach e33d85c01d Agent Host changes for agents/gather-rule-exitcode-info-update (#151) 2026-09-08 14:57:30 +02:00
Oliver Kieselbach 4e73ba8900 Agent Host changes for agents/improve-changelog-promotion-ui (#152) 2026-09-08 14:57:11 +02:00
Oliver Kieselbach 4df52b3da9 Agent Host changes for agents/timeline-search-enhancement (#150) 2026-09-08 14:47:30 +02:00
Oliver Kieselbach 844d86410c Agent Host changes for agents/mcp-server-beschreibung-lieferung (#149) 2026-09-08 14:46:15 +02:00
dependabot[bot] 9514e1033d chore(deps): bump the web group (#148)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.3` | `16.3.4` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.5` | `19.2.7` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.4` | `10.5.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

Updates `@next/bundle-analyzer` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-bundle-analyzer)

Updates `@types/react-dom` from 19.2.5 to 19.2.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `autoprefixer` from 10.5.4 to 10.5.5
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.4...10.5.5)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 13:07:51 +02:00
Oliver Kieselbach b2236bf65f web: card and page headers stack below sm so their action buttons stay on screen at phone width
Title and trailing controls shared one non-wrapping flex row with the controls
marked flex-shrink-0. At 440px (iPhone 16 Pro Max) that squeezed the Operational
Events title into an 86px column and pushed the Refresh buttons of Agent Metrics
and the Tenant Config Report past the viewport, scrolling the whole page sideways.
SectionCardHeader now stacks below sm, which covers all 32 cards that use it.

A width sweep over the portal routes at 440px found two more real overflows, fixed
here as well: the audit From/To date pair, and Alert Rules, where the unbreakable
event-type names and a channel-name input without min-w-0 pushed the severity
selects 99px off screen.

Verified: every swept route now measures zero horizontal overflow at 440px, desktop
is unchanged at 1440px. The header test asserted "no trailing group" via the absence
of gap-3, which the wrapper itself now carries; it checks flex-wrap instead and pins
the stacking classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 01:35:50 +02:00
Oliver Kieselbach 31535b07e0 web+backend: a warming MCP health card says only "Starting instance... (attempt n of 12)" and re-probes for ~60s before it warns (D-220)
The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.

The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
2026-09-07 21:27:07 +02:00
Oliver Kieselbach 066b6e6f04 backend+web: the MCP health probe answers a cold container in 3s with status "warming" instead of holding the request for 30s, and the System Health card re-probes until it is up (D-220)
Measured over 14 days, GET health/mcp answered in 4..148ms when warm and timed out at 30s
in 50 of 83 calls — the median call took 27,972ms. A cold start is 20-30s of Container Apps
activation (~98% KEDA plus image mount, ~500ms application), so no UI wait covers it and the
generous timeout only bought a long wall in front of the same answer. The browser's own
budget is the same 30s, so the backend's carefully worded warning never reached the user at
all: they saw a raw DOMException.

Backend: the budget drops to 3s, which sits in the widest observed latency gap
(2347ms..3930ms) so jitter cannot flip the verdict. Our own cancellation now reports the new
status "warming" at Information level (with minReplicas=0 a cold probe is the expected path,
and logging it as a warning poisons the ops signal); HttpRequestException is split out of
that catch and stays a real warning, still without the host in the message. The warm-hit
threshold drops to 750ms — ~5x the warm maximum, deliberately generous because every
measurement comes from a near-idle single-user system and warm latency will rise under
concurrency — and moves into McpReachableMessage so a test can pin the boundary. The XML doc
now describes the probe that exists rather than the wake-signal design the measurements
disproved.

"warming" is a new status value rather than an added DTO boolean because the backend deploys
before the web: an unknown value degrades to a grey card with a clean banner, whereas a
boolean would leave old web showing yellow and "Warnings detected" on every cold start.
HealthCheck.Status now documents the five-value vocabulary, which rides into both generated
TypeScript mirrors as JSDoc.

Web: resolveMcpCardState owns display, banner rating and the poll decision as a pure
function (the suite runs on node, so testable logic cannot live in JSX). A warming server
re-probes every 5s for up to 8 attempts and stays out of the overall banner exactly as
"unknown" already does — scaling from zero is expected behaviour, not a degraded platform —
and only becomes a real warning once the re-polls are spent. The card keeps showing
"warming" while the next probe is in flight so it cannot flicker, carries the blue
in-progress look instead of yellow, and was hoisted out of the healthResult guard: a failing
detailed check would otherwise hide a card that is still polling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 18:25:56 +02:00
Oliver Kieselbach a1e2b76a9f web: the session Vulnerability Report names its inventory badge "Identification Confidence" (header and legend, Inventory and Delta tab); the Software Inventory analyzer toggle uses the rose accent like the other Agent Analyzer toggles
The column showed high/medium/low under the header "Identification", which reads as a method rather than a rating. The wire field is `confidence` and the tooltip already says "identification confidence"; the header and legend now match. The analyzer toggle and its hover border were still emerald from before the section moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:13:31 +02:00
Oliver Kieselbach 29e5b8251d web(rules): analyze and gather rule cards get a two-row header on every breakpoint — badge row with fixed-width toggle, severity and rule-id columns on top, the full title below with threshold, sparkline and hit rate right-aligned; gather badges ordered category, type, collector like the analyze card
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:11:44 +02:00
Oliver Kieselbach 02168a5c55 chore: bump internal gitlink (geo-statistics-explained plan, D-219, backlog B-fpu/B-csp)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:12 +02:00
Oliver Kieselbach a6b6661320 web(geo): the Location Performance table states what Avg Duration, P95 and vs Global are computed over (footnote with docs links, header and benchmark tooltips); a location without a succeeded session shows no duration instead of 0 min / -100% in the fastest bucket
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:10:25 +02:00
Oliver Kieselbach 0c72666a7a chore: bump skills gitlink (CAS marker row corrected to the pre-fix code path)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:35:34 +02:00
Oliver Kieselbach 9023321db9 chore: bump skills gitlink (platform-health KQL fix + 09-07 marker baselines)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:48:08 +02:00
Oliver Kieselbach 7ef097529c backend: deleting an owned tenant group stamps the retention grace anchor on every member (conferred Pro ended without ProDowngradedUtc)
The portal hides Delete for owned (msp-{home}) groups, but DELETE global/tenant-groups/{groupId} accepts any id: a Global Admin deleting one ended Pro (MSP) for every member with no grace anchor, so a member that raised its retention under Pro was clamped to the Community cap at the next sweep. DeleteGroupCoreAsync now snapshots the owner and calls ProConferralService.RecordLossAsync(member, owner, "group-deleted") per member, owner excluded, independent of assignees. Two tests in RevokeEnforcementTests; internal docs list the fourth writer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 00:15:21 +02:00
Oliver Kieselbach e7df69efe4 backend+web: vulnerability summary carries a priority breakdown (act/attend/track); the exposure panel shows Act and Attend tiles and every severity or priority chip filters the top-CVE list (D-218)
Act equals the KEV set by construction (CvePriority: KEV => act), so the KEV tile becomes the Act tile and Attend joins it instead of a duplicate count. The breakdown is counted in the backend over every CVE in the window, like the severity breakdown, because the list is top-N. Both panel consumers read the generated CveExposureSummary type; the MCP tool description names the new block. Wire types regenerated for web and MCP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:30:36 +02:00
Oliver Kieselbach 2c7efa2733 backend: key-safe X-MCP-Tool-Name prefix for the usage row key (CodeQL cs/log-forging #91)
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:19:56 +02:00
Oliver Kieselbach 2742549827 chore: bump skills gitlink (platform-health marker baselines + GetBootstrapScript trap)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:07:46 +02:00
Oliver Kieselbach 3c45f5cb09 chore: regenerate the shared manifest and wire types for the NtpServer summary text (D-216 follow-up)
The manifest committed with d5d1d7bf carried the types section of the previous HEAD, so
the ntpServer doc string still read "null = use agent default"; SharedManifestParityTests
compares byte-for-byte. Generated output only, no wire change. Bumps the internal gitlink
(plan review addendum).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:02:10 +02:00
Oliver Kieselbach d5d1d7bfbd backend+web: one agent-config derivation (AgentConfigResolver) served on agent/config and GET config/{tenantId}/effective-agent-config; wire-type initializer defaults in the shared manifest; the Tenant Config Report reads both (D-216)
The report still carried two hand copies after D-215: a typed map of C#
initializer defaults for the "custom" highlight, and a TypeScript mirror of
GetAgentConfigFunction's resolution rules for the runtime column.

Backend: the derivation moves verbatim into Services/AgentConfigResolver
(Build = pure, ResolveAsync loads tenant + admin config and the two rule
catalogs). GetAgentConfigFunction keeps the kill verdict, the migrate logging
and the device fields on top; ParseAgentMajor, ResolveDiagnosticsUploadEnabled,
MergeDiagnosticsLogPaths and ResolveMigrateTarget move with it (call sites and
tests retargeted; the QueryParams ratchet baseline shrinks by the file). New
GlobalAdminOnly route config/{tenantId}/effective-agent-config returns the same
AgentConfigResponse without a device (optional ?agentVersion=, default the V2
line). AgentConfigResolverTests pin the rules that used to live in the web:
upload 10, Hosted destination without SAS, SAS regardless of mode, unrestricted
only with entitlement + gate + toggle, operator knobs from AdminConfiguration,
analyzer defaults, hash line per major, migrate override and rejection.

Manifest: SharedManifestParityTests emits "defaults" next to "fields" for
TenantConfiguration and AdminConfiguration and an "agentConfig" section with
the AgentConfigResponse / CollectorConfiguration / AnalyzerConfiguration class
defaults (fresh-instance values; nested objects null). TenantConfiguration.
NtpServer gets the initializer "time.windows.com" the table read already
applies, so the manifest default and a stored row agree.

Web: the catalog rows lose value() and default; the runtime column renders
the endpoint's response, the highlight compares against the manifest defaults,
informational rows (reasons, timestamps, identities, secrets) never read as
custom. Previously excluded fields are visible (configVersion, catalog counts,
sealing pattern ids, hash oracle, migrate target); the collector class
defaults sit in a collapsed "Collector Internals" section. Fail-soft: while the
backend is not deployed the runtime column shows a notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:59:34 +02:00
Oliver Kieselbach f4e2088b69 chore: bump internal gitlink (lessons: shared index) 2026-09-06 21:57:49 +02:00
Oliver Kieselbach 55425058a8 chore: bump internal gitlink (dashboard-search plan review) 2026-09-06 21:57:26 +02:00
Oliver Kieselbach eec0d17607 chore: bump internal + skills gitlinks (D-217 plan/review, lessons, ux-insights marker row) 2026-09-06 21:54:47 +02:00
Oliver Kieselbach 43f5304ba4 web+backend: dashboard search gets field qualifiers (model=, manufacturer=), AND-ed terms and exclusions, parsed identically on both sides; Fleet Health model rows link with that search (D-217)
The "Health by Device Model" link searched "{Manufacturer} {Model}" as one substring; the server's
free-text predicate tested it per field, so once the failed sessions were outside the loaded first
page the sweep returned nothing.

- lib/searchQueryTokens.ts: tokenizer shared by the timeline and the session search (terms, quotes,
  leading minus, key=value / key:value qualifier when the separator precedes any quote)
- app/dashboard/utils/sessionSearchQuery.ts: 15 qualifiers = exactly the fields both sides search,
  matcher over session fields + client-derived extra text, suggestion field lookup, link builder
- useDashboardFilters / SessionTable: filter and suggestions use the grammar; fuzzy only for a
  single free term; session_searched gains a `qualifiers` count
- Helpers/SessionSearchQuery.cs: the same grammar for q= on /api/search/sessions (+ global), parsed
  once per request; MatchesFreeText is a thin wrapper
- utils/session-search-syntax.cases.json: one case file run by vitest and by
  SessionFreeTextSearchTests (parity proof)
- FleetModelHealth/FleetSlowModel/FleetFailingModel: manufacturer + model as stored plus label,
  grouped on the pair; useFleetHealth uses the generated wire types; "Unknown" is not a link
- shared manifest + wire types regenerated for the three DTOs

Backend suite 6158/6159 (the one failure is SharedManifestParityTests against uncommitted WIP of a
parallel task), web vitest 1177/1177, build 0 warnings.
2026-09-06 21:54:10 +02:00
Oliver Kieselbach b21472dd8c web(progress): pre-provisioned device parked after the technician part sits on Account setup instead of a forever-running device apps step
The progress portal placed the active step purely by the stored phase. After
whiteglove_complete the backend caps that phase at Apps (Device), so the end
user saw "Waiting for you to sign in" above a step list that still showed
"Installing apps (device)" as running at 43 % (customer report).

The seal detection the headline already used is now one helper,
isParkedAfterTechnicianPart (pre-provisioned, whiteglove_complete, no resumedAt
and no whiteglove_resumed), and resolveActiveStepIndex places such a session on
the first user step: every device step done, 57 %, Account setup current. A
stale Complete phase on older sessions can no longer push it past the sign-in;
once resumed the placement is phase-driven again.

Vitest 24/24, tsc and eslint clean. Backlog B-ydz carries the post-deploy check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:24:32 +02:00
Oliver Kieselbach e3991d55be web(admin): Tenant Config Report renders from an exhaustive catalog over the generated wire types (D-215)
The report typed its own TenantConfig interface and drifted for five months: 31 stored
fields were missing (payingCustomer, the MSP overrides, three validation gates, the SLA
block, four analyzer flags, app-homing and trial bookkeeping) and three runtime values
were wrong (upload interval 30 instead of 10, diagnostics-enabled without the Hosted
destination, unrestricted mode without the entitlement).

tenantConfigReportCatalog.ts is Record<keyof TenantConfiguration> and
Record<keyof AgentConfigResponse | CollectorConfiguration | AnalyzerConfiguration>:
every key is rendered or excluded with a reason, so a new C# property fails tsc once the
wire type is regenerated. Runtime rows carry the GetAgentConfigFunction rule and a source
tag; entitlement values come from feature-flags, operator knobs from global/config.
A vitest pins the same parity against SHARED_MANIFEST.tenantConfiguration.fields and the
resolution rules (upload 10, Hosted destination, flags-driven unrestricted mode, admin
idle timeout, analyzer defaults). Empty strings count as unset for the custom highlight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:08:03 +02:00
Oliver Kieselbach 864d1ed31e backend: MCP usage counters go through TableCasRetry with an 8-attempt jittered budget instead of three immediate retries (B-c92)
Parallel tool calls of one user hit the same (user, day, endpoint) row in the same instant; without backoff the losers stayed synchronised and 3 of 6 increments were lost. Both increments now use the one CAS helper (merge patch, AddEntity on 404, StorageCasConflict marker) with UsageCounterRetries = 8; the tenant counter's attribution columns travel in the merge patch. Tests pin the budget, the patch shape and the create path.
2026-09-06 19:04:58 +02:00
Oliver Kieselbach 76ef1e53ca chore: bump internal gitlink (final check 2026-09-06: plans released, backlog long-tail rewritten, todo cleared) 2026-09-06 18:48:46 +02:00
Oliver Kieselbach 7dd298f04e chore: bump skills gitlink (session skills: exit-code class, packaging counters, WU symbols)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:21:14 +02:00
Oliver Kieselbach 66452621c6 exit-code class per app + reboot rule: IME-EXITCODE-CLASS, ANALYZE-APP-018, exitCode on every terminal app event (D-214)
- IME-EXITCODE-CLASS captures the return-code class of the installer exit code ("lpExitCode is defined as <Success|SoftReboot|HardReboot|Retry|Failed>", verified against the IME string literal) as exitCodeClass on AppPackageState; new action captureExitCodeClass, persisted across the agent restart a reboot causes, last value wins across a Retry loop. IME-EXITCODE-MAPPED-FAILED keeps the error path; both match the same line.
- Adapter: exitCodeClass whenever known, exitCode on every terminal state (Installed/Postponed/Skipped/Error) instead of Error only.
- ANALYZE-APP-018 (warning, apps): app_install_completed.exitCodeClass in SoftReboot,HardReboot; +25 HardReboot, +15 observed system_reboot_detected; never fails the session.
- Web: completed-app exit badge amber (failed stays red), "mapped as SoftReboot/HardReboot/Retry" on timeline and Install Progress.
- Tests: pack end to end (phase, current app, class lines, negatives, restart round trip), adapter payloads, linearity samples, backend rule engine + known actions.
- internal gitlink: plan, D-214, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:08:49 +02:00
Oliver Kieselbach 0b23d93957 updated last modified 2026-09-06 17:49:25 +02:00
Oliver Kieselbach 4281a6d81b chore: bump internal gitlink (rules category groups plan, Paket 3) 2026-09-06 17:39:10 +02:00
Oliver Kieselbach fe919a51aa web rules pages: group analyze and gather rules by category with collapsible headers, counters and Expand/Collapse All (state per tab in localStorage); reveal a rule inside its group from telemetry, template copy and view-copy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:37:44 +02:00
Oliver Kieselbach 0dcf0eb503 Observation coverage in get_session_summary, absence audit with coverage lint, diagnostics packaging counters (D-213)
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the
health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule
warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded
precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps.
Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test —
absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004).
Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles,
skippedFiles, skippedByReason, problemsByKind) — counts only, never paths.
Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:35:52 +02:00
Oliver Kieselbach e2fb99d5d8 mcp access-guard: surface the suspension text from the policy envelope; AdminConfiguration.McpAccessPolicy comment matches the enforced AllMembers semantics; internal gitlink (MCP-Nebenbefunde, B-4s2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:12:19 +02:00
Oliver Kieselbach a3ee613b60 scripts/error-codes: decode entities in one pass and strip tags until none remain (CodeQL 89/90)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:33:36 +02:00
Oliver Kieselbach d2197c6a57 error-code catalog v2: generated from primary sources, symbols and families, MCP lookup_error_code (D-211/D-212)
Shared: error-codes.json schemaVersion 2 (607 entries, 50 IME enforcement states) generated
by scripts/error-codes/generate.mjs from MS Learn tables and the IME enums of one build;
ErrorCodeEntry gains symbol/category/imeRetriesDuringEsp, ErrorCodeCatalog gains
TryLookupDetailed (HRESULT_FROM_WIN32 derivation as data), TryLookupBySymbol,
TryLookupEnforcementState. Backend enricher siblings carry symbol/category/derivedFromWin32
plus enforcementStateInfo. Web: validating, minifying sync; formatErrorCodeSource for every
tooltip; windows_update_failed badge; script error codes decoded; bundle budget test.
Agent: WindowsUpdateTracker reads the catalog symbol (WU_E_UNKNOWN sentinel, load failure
never costs an event). MCP: error-code-catalog.ts (lexical-only), lookup_error_code for every
role, search_knowledge merges catalog hits, get_session_summary key events carry
errorCode/errorText, Dockerfile COPY + ERROR_CODES_PATH. ANALYZE-APP-014 1.2.1 corrects the
0x80073cf0 gloss. Gitlinks: internal (D-211/D-212, plan review, backlog), .claude/commands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 16:24:35 +02:00
Oliver Kieselbach e5997bc50e chore: bump internal gitlink (error-code catalog v2 plan)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 15:25:13 +02:00
Oliver Kieselbach 75138016ce chore: bump internal gitlink (analysis notes revision 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:46:44 +02:00
Oliver Kieselbach fc4772efb0 chore: bump internal gitlink (analysis notes: detection-coverage ideas)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:42:56 +02:00
Oliver Kieselbach 0bde428fa0 web: GA tenant modal — MCP switch and suspension moved down next to offboarding
Order at the end of the modal: MCP Access, Tenant Suspension, Offboard Tenant. Rarely used
levers sit together; the frequently edited plan, contact and limit sections come first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:13:40 +02:00
Oliver Kieselbach 16ca3a059c backend/web/mcp: per-tenant MCP switch as an operator control (D-210)
TenantConfiguration.McpDisabled + McpDisabledReason (GA-only, same deny-list and PUT guard as Disabled).
Front door: McpUserService denies every caller of a closed home tenant below the platform roles.
Per request: PolicyEnforcementMiddleware denies a closed home or target tenant for X-Client-Source: mcp
(403 McpDisabled) and narrows the delegated fleet bound, never to null. ClientSourceHeader is the one
reader of the header. The MCP access guard surfaces the backend reason verbatim. GA tenant modal,
list badge and config report; wire types regenerated; internal doc, health marker, gitlinks bumped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:01:01 +02:00
Oliver Kieselbach 1bfc80e6db chore: bump internal gitlink (release backfill plan RELEASED, D-209)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 13:26:49 +02:00
Oliver Kieselbach fa705a2ef6 updated Readme.md 2026-09-06 13:13:42 +02:00
Oliver Kieselbach 81f807e124 web: modal overlays render through ModalPortal into <body> (D-208)
A fixed overlay still receives its parent's box-model margins: the tenants page's
space-y-8 wrapper gave the edit dialog's backdrop a 32 px margin-top, leaving the
navbar un-dimmed. All 21 full-screen overlays now portal to document.body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 12:27:01 +02:00
Oliver Kieselbach a4f7c88785 chore: bump internal gitlink (request-wire-contract RELEASED, live verify) 2026-09-06 12:02:36 +02:00
Oliver Kieselbach 54259f070f chore: bump internal gitlink (request-wire-contract GEBAUT, D-207, B-r95/B-cme) 2026-09-06 11:42:37 +02:00
Oliver Kieselbach 26cef3ab06 web: request bodies as jsonBody<T> against the generated wire types (D-207)
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.

What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.

Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:24 +02:00
Oliver Kieselbach 00ad230ffe mcp: request bodies typed against the generated wire types (jsonBody<T>, D-207)
The eight backend POST/PUT/PATCH bodies name their request DTO through
jsonBody<T> from client.ts — the one place a body is encoded — so a renamed
or retyped backend field fails tsc instead of surfacing as a 400 at runtime.
The apiFetch guard additionally refuses `body: JSON.stringify(` and a bare
`jsonBody(` with an empty baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:22 +02:00
Oliver Kieselbach 3d920a8113 shared+backend: IApiRequest marker, one RequestBody reader, every request body a Shared wire type (D-207)
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).

Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).

35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).

TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:42:03 +02:00
Oliver Kieselbach f6eac1964a chore: bump internal gitlink (both plans RELEASED, live verify, B-fq7 / B-8va)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 04:10:34 +02:00
Oliver Kieselbach e1b5b9d163 backend+web: per-user rate limits split by throttle surface — portal (public client) gets its own global budget, MCP/integrations keep override and floor (D-206)
The surface comes from the signed appidacr/azpacr claim (0 = public client = portal;
secret/certificate, app-only or missing = integration), never from a header: portal SPA,
MCP server and API share one app registration in production, so the client id cannot tell
them apart and any token holder controls headers. New AdminConfiguration field
PortalUserRateLimitRequestsPerMinute (default 600, Global Settings round-trip, wire types
regenerated); the existing user budget is relabelled MCP & integrations and keeps the tenant
override and the Pro floor; buckets are keyed per surface; ThrottleSurface is a request
dimension next to ClientSource so the classification is auditable (platform-health check 2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:45:42 +02:00
Oliver Kieselbach 094833f831 backend: a Graph 401/403 on the first validator attempt invalidates the tenant token and retries fresh; the agent keeps getting 503 for a permission gap (D-205)
A freshly onboarded tenant spent 45 minutes in 503 because a second instance held a
roleless app-only token minted before the consent; InvalidateTenant never reaches other
instances. GraphAuthFailure is the shared step for all five Graph validators: attempt 1
drops the cached token and reports transient so the existing loop re-mints; attempt 2
keeps each validator's semantics (Autopilot/DeviceAssociation transient with Retry-After
120, CorporateIdentifier/CloudPc/IntuneBinding definitive) and logs a stable marker.
SecurityValidator hands the agent the largest Retry-After requested. Platform-health
check 6 and the marker catalog know both markers; the cloudpc concept doc carries the
contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 03:35:39 +02:00
Oliver Kieselbach 3579ce0add chore: bump internal gitlink (typed call sites RELEASED, verify) 2026-09-06 02:55:20 +02:00
Oliver Kieselbach 0188860d1c chore: bump internal gitlink (storage-budgets plan RELEASED)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:52:23 +02:00
Oliver Kieselbach 871a1ac059 chore: bump internal (typed call sites plan GEBAUT, D-204, envelope doc consumers, B-7sw) and .claude/commands (mcp-telemetry retries) gitlinks 2026-09-06 02:37:21 +02:00
Oliver Kieselbach 298a182fbb shared+web: backup job status and restore-row payloads join the wire contract; the web reads the generated types (audit package 5, stage 4b)
BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the
portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest
never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome,
RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files;
the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no
runtime behaviour changes, no backend deploy needed.

Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by
GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous
object outside the typed regime) and the RestoreRow request body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:34:11 +02:00
Oliver Kieselbach e71223507c web: customs archive reads the generated types, annotations page follows nextLink through the typed builder, dead PaginatedResponse removed (audit package 5, stage 4a)
The five hand-written customs-archive response types in lib/api.ts duplicated CustomsArchiveRunListResponse,
CustomsArchiveEntryListResponse, CustomsArchiveRunSummary, CustomsArchiveEntrySummary and
TenantOffboardingCustomsArchiveEntry from the manifest; the two pages import those. The annotations page
built its next-page URL from API_BASE_URL + nextLink instead of the scoped builder plus extractContinuation.
PaginatedResponse<T> had no importer. types/diagnostics.ts states why it stays hand-written (no wire DTO
for GET diagnostics/paths).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:28:08 +02:00
Oliver Kieselbach 02187f0f3c web: the last raw API call sites join the call layer; ratchet baseline for authenticatedFetch reaches zero (audit package 5, stage 3d)
Rules pages, apps tabs, audit, delegations, diagnosis, fleet health, geographic, health check, SLA, usage
metrics, the notification and SignalR contexts, the feedback bubble, global search and the shared hooks
now call fetchJson/fetchOk; the activation page's hand-rolled Bearer fetch (no 401 retry, no
telemetry) joins the layer too. The SignalR join/leave paths keep their visible-refusal semantics through
ApiError, the delegation acceptance explains refusals by code, the health check keeps its 403 wording,
and the local mirrors of SlaMetricsResponse and PlatformUsageMetrics give way to the generated types.

No page, component, hook or context calls authenticatedFetch directly any more. What stays is
documented per file in the baseline's PERMANENT map: the auth/me bootstrap, the SWA's own version.json,
the public stats manifest, the delete-cascade classifier's 202 body, and seven TokenExpiredError
branches where the UI must act differently from showing the message (route, rethrow, batch, retry flag).

Ratchet baseline: 39 → 0 raw fetches, 40 → 7 parses, 25 → 8 token checks (from 228 / 221 / 140).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:21:25 +02:00
Oliver Kieselbach 3e2f62daeb backend: host.json holds Azure.Identity, Microsoft.AspNetCore and Microsoft.Hosting.Lifetime at Warning
About 12 MB/day of severityLevel-1 traces without diagnostic value (MSAL verbose, StatusCodeResult,
CORS success, Application started). Application logs were Warning+ already via Function: Warning.
Measured and documented in internal/docs/backend/telemetry-ingest-shaping.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:11:44 +02:00
Oliver Kieselbach c8b8a8dbb4 web(sessions/dashboard/progress): every API call through fetchJson/fetchOk/fetchBlob (audit package 5, stage 3c)
The 33 raw authenticatedFetch sites under app/sessions, app/dashboard and app/progress are gone. Hooks
that receive addNotification as a parameter report failures through notifyApiError (same rendering as
notifyError, injected notifier); fail-soft reads that used to check res.ok read through
.catch(nullOnApiError). The delete-cascade classifier accepts the ApiError of a refused DELETE next to
the 202 Response, so the 409/503/404 branches keep their wording; the polling probe reads the status
from either. Session and event reads name their wire types (GetSessionResponse, GetSessionEventsResponse,
SearchSessionsResponse, SessionStatsResponse, the progress and rule-stats responses); the legacy
list-shaped fallbacks on the single-session read are gone. SessionTimeBreakdownDto is the generated type.

Ratchet baseline: 72 → 39 raw fetches, 67 → 40 parses, 36 → 25 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:09:51 +02:00
Oliver Kieselbach 9e4c5f11c5 chore: bump internal gitlink (plan verify line, trace-noise backlog idea)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:00:11 +02:00
Oliver Kieselbach b16e72c10e web(settings): every API call through fetchJson/fetchOk, errors through apiErrorText (audit package 5, stage 3b)
The 46 raw authenticatedFetch sites under app/settings are gone: 20 by the transformer, the rest by hand.
The tenant config load keeps its 403 fallback to the feature-flags view through ApiError; the access
check treats a backend refusal as an outcome and only a failed round trip as transient; delegated
access, MCP users and the offboarding flows read their specialised envelopes through ApiError.code
and ApiError.body. Twenty 'Session Expired toast / else inline' catch dialects collapse to the inline
message apiErrorText already renders; local mirrors of McpUserEntry, PlanTierDefinition,
HardwareRejectedResponse and DeviceNotRegisteredResponse give way to the generated types.

Ratchet baseline: 115 → 72 raw fetches, 111 → 67 parses, 59 → 36 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:56:53 +02:00
Oliver Kieselbach 5cb9b0b30b backend: storage SDK budgets under the agent timeout, ingest 503 on an exhausted budget, request-token fan-outs, single-flight factory budget, ProcessStall monitor (audit F09, D-203)
- StorageClientOptions: Table/Queue 8 s per attempt × 3 attempts + capped backoffs = 25.8 s < the
  agent's 30 s upload timeout (pinned by test); Blob 60 s × 4. Every production client takes its
  options from there. No Polly over the SDK.
- StorageErrors.IsTransient(Exception): OperationCanceled / IO / HttpRequest / Aggregate-of-those
  / RequestFailed status 0; the ingest answers 503 + Retry-After instead of 500.
- The ingest still takes no caller cancellation (a batch is a unit). GetAllSessionsPageAsync
  carries the request token down to BoundedFanOut (Global Admin list); SingleFlightCache hands its
  factory a 30 s budget token and evicts on timeout (tenant ids, app-summary snapshot, session stats).
- ProcessStallMonitor: 1 s tick drift ≥ 1 s ⇒ ProcessStall event (sampling bypass) + metric with
  cause gc / threadpool / unknown from GC.GetTotalPauseDuration and thread-pool counters;
  GcPauseMs on request rows ≥ 100 ms. Measures the instance-local 5–10 s pauses seen in production.
- Gitlinks: internal (plan, D-203, concept doc, B-9hf, lesson), .claude/commands (marker rows).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:51:02 +02:00
Oliver Kieselbach c925001a8a web(admin): every API call through fetchJson/fetchOk/fetchBlob, errors through apiErrorText (audit package 5, stage 3a)
The 113 raw authenticatedFetch sites under app/admin are gone: 73 by the transformer (fetch + ok-check
+ json parse become one typed call), the rest by hand — 404-as-empty lists read through
.catch(nullOn404), the app-homing, offboard, delegated-admin and tenant-group flows read the
specialised envelope through the new ApiError.body, downloads use fetchBlob, action calls fetchOk.
Session-expired branches that only logged or set the same message are gone; apiErrorText renders
every failure with the reference.

Typing the responses surfaced eight local mirrors that had drifted from the wire types (required
fields the backend leaves optional): BlockedDevice, FeedbackEntry, DistressReport, OpsEvent,
SessionReport, IdentityBinding, UnmatchedSoftwareEntry, DailySummary and the whole
SectionPlatformUsage metric set are now aliases of the generated types, with the optional fields
handled where they render. AdminConfiguration keeps its literal-union narrowing through
fromWireAdminConfiguration instead of an unchecked parse.

Ratchet baseline: 228 → 115 raw fetches, 221 → 111 parses, 140 → 59 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:41:19 +02:00
Oliver Kieselbach e494334720 mcp: apiFetch<T> at every call site, one retry on 429/503 within Retry-After, typed error envelope (audit package 5, stage 2)
apiFetch takes a mandatory wire type (60 call sites name their generated DTO; the 28 as-casts
and 31 unknown results are gone) and refuses an empty or malformed 2xx body. It retries once on
429 (never the MCP quota) or 503 when Retry-After is at most 10 s — GET only, never with a
caller-supplied signal, opt-in via retry:true otherwise — and counts the retry on the tool-call
context, which withToolTelemetry writes as `retries` on the tool_call line. ApiError.parsed is
the generated ApiErrorResponse plus domain fields and carries retryAfterSeconds; toolError
renders it in the 503, envelope and rate-limit branches. The lean-projection default lives in
one helper (leanFieldSelection) instead of two copies.

apifetch-typed.guard.test.ts refuses a bare apiFetch( or apiFetch<unknown>; apifetch-retry.test.ts
pins the retry, the opt-outs and the body contract. tools-delegated fakes now answer text() with
the body they claim (the client parses text, not json).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:13:20 +02:00
Oliver Kieselbach 1554540431 web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1)
lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first,
Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of
handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and
fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice;
apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the
"Session Expired" dialect into one place and NotificationContext.notifyError uses it.
useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch
and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers.

lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/
.json()/instanceof TokenExpiredError per file against a baseline that may only shrink
(a count above fails as a new site, below as a stale entry); fetchJson without a type
argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 01:01:41 +02:00
Oliver Kieselbach fef09690d2 web: coalesce live timeline refetches per burst instead of per SignalR signal
Agent uploads arrive as bursts of batches ~0.3 s apart with seconds of silence in
between, and every batch raises a SignalR signal. The 300 ms trailing debounce on the
session page either refetched once per batch or hid a tight burst until it paused;
session 46096bcd produced 155 full first-page reads for 261 batches in 13 minutes.

lib/burstScheduler: leading edge + trailing debounce + max wait. The first signal after
a quiet period refetches at once, later signals of a burst refetch once after the
burst's last signal, and a burst that never pauses still refetches every max-wait
(spool replay after reconnect/reboot). Fetch semantics are unchanged: every run reads
the full first page, the server stays the single source of truth, no incremental path.

Session page: trailing 300 ms, max wait 1 s. Progress portal (same pattern, two
requests per refresh): trailing 500 ms, max wait 1.5 s. scheduleFetchEvents loses its
delayMs parameter; the scheduler is created lazily on the first signal so refs stay
out of render (react-hooks/refs). Seven fake-timer tests pin the timing rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:41:13 +02:00
Oliver Kieselbach 3a975fa74f chore: bump internal gitlink (wire-contract plan RELEASED, short-sentence lesson) 2026-09-05 23:47:00 +02:00
464 changed files with 24292 additions and 8801 deletions
+24 -2
View File
@@ -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('?')
+4
View File
@@ -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.
+50 -14
View File
@@ -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
[![Status](https://img.shields.io/badge/status-live-brightgreen)](https://www.autopilotmonitor.com)
[![License](https://img.shields.io/badge/license-MIT%20%2B%20AGPL--3.0-blue)](LICENSE)
[![Website](https://img.shields.io/badge/website-autopilotmonitor.com-2ea44f)](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
+2 -2
View File
@@ -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"
]
},
{
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+20
View File
@@ -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
}
+5
View File
@@ -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\")."
+1
View File
@@ -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": [
+2 -1
View File
@@ -79,7 +79,8 @@
"captureAppTypeWinGet",
"captureAppTypeMsi",
"captureAttemptNumber",
"captureDetectionResult"
"captureDetectionResult",
"captureExitCodeClass"
]
},
"parameters": {
+1
View File
@@ -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)}`);
}
+63
View File
@@ -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.
+563
View File
@@ -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 "&amp;lt;" yields "&lt;", 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); });
}
@@ -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));
}
}
}
@@ -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()
{
@@ -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);
}
}
}
@@ -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);
}
}
}
}
@@ -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")));
}
// ---------------------------------------------------------------------------
@@ -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
@@ -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))
@@ -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,
@@ -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; }
@@ -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";
}
}
@@ -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;
@@ -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
@@ -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
@@ -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
});
}
@@ -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;
}
@@ -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
@@ -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);
@@ -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);
@@ -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).
@@ -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; }
}
}
@@ -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; }
}
@@ -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");
}
@@ -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");
}
@@ -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; }
}
@@ -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)
{
@@ -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,
@@ -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:&lt;client-id&gt;</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; }
}
@@ -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