Compare commits

...

63 Commits

Author SHA1 Message Date
Oliver Kieselbach b7c6b11909 bump pointer: internal (D-392 phase 2 built; release and VM check B-qfy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:16:13 +01:00
Oliver Kieselbach 74d489b51e Agent reports IME's detection check after the install as app_post_install_detection
IME checks an app right after its installer; only that check's verdict reaches the ESP and
Intune. The agent now reports its result as its own informational event (D-392):

- IME-POST-INSTALL-DETECTION marks the check ("expected to have enforcement state:
  Detected", logged only for the app just installed); the app's next detection result line
  becomes app_post_install_detection (postInstallDetection, afterReboot) and detectionResult
  stays the check before the install.
- After an exit code mapped to SoftReboot/HardReboot the check right after the install is not
  reported; IME-POST-REBOOT-DETECTION (InProgressPendingReboot -> Success) marks the check
  after the restart instead.
- The mark is transient and the marker action app-mutating, so a marker from a previous
  enrollment's log or without its result line never turns a later check into this one. No
  state transition, no decision signal; historic replay is suppressed like DO telemetry.
- Install Progress, the timeline, the diagnosis tile (DiagnosisCulprit.PostInstallDetection)
  and the MCP event catalog show the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:16:00 +01:00
Oliver Kieselbach dff69b4d73 bump pointer: internal (D-392 app detection, phase 1 built; deploy and verify B-wpr)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:55:12 +01:00
Oliver Kieselbach 9c654983d1 App detection follows IME's two checks: 0x87D1041C is the verdict after the install, detectionResult the check before it
IME checks every app before the install (NotDetected starts it) and right after the
installer; only the second check's verdict reaches the ESP and Intune. app_install_completed
comes at IME's provisional "Success", so detectionResult on app events is the check before
the install. The apps banner, ANALYZE-APP-001 and the diagnosis read it as the result after
the install and flagged healthy installs (D-392).

- IME-ERROR-REPORT captures EnforcementErrorCode as errorCode (-2016345060 = 0x87D1041C).
- AppInstallSummaries.FailureCode is stored canonical (ErrorCodeCatalog.NormalizeCode); one
  HRESULT parser serves the catalog lookup, the diagnosis and the ingest.
- The apps banner counts failed installs IME did not detect after the installer
  (DetectionFailureCount, renamed from DetectionLiesCount), with its own advice for Store
  apps; the app page uses the generated AppAnalyticsResponse.
- ANALYZE-APP-001 v2 matches the code in any enrollment outcome; ANALYZE-APP-013 picks its
  app by the code; ANALYZE-APP-010 is retired (APP-001 reports the same event).
- Diagnosis, Install Progress and the timeline read the code: IME's verdict turns an
  installed row into a detection failure, error codes read as hex, and the timeline shows the
  check before the install.
- The summary column DetectionResult and DiagnosisCulprit.DetectionResult are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:54:40 +01:00
Oliver Kieselbach 0bc82fd719 bump pointer: internal (D-390/D-391 released: backend 1.5.1306, MCP 1.6.569 @ 783b390; field verify B-w7y, alert rule B-5q5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:17:29 +01:00
Oliver Kieselbach 783b390448 bump pointer: internal (lesson from the D-390/D-391 request-body work)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:00:39 +01:00
Oliver Kieselbach 5c75155293 bump pointer: internal, .claude/commands (D-390/D-391 counted body cap and report upload limit built; deploy and verify B-w7y, alert rule B-5q5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:58:50 +01:00
Oliver Kieselbach 7588286787 Request bodies are capped on the bytes actually read, and report uploads run three at a time per instance
RequestBody checked its byte cap only against the Content-Length header and then buffered the
whole body. A chunked body carries no Content-Length and a gzip body's header names the compressed
size, so a route with an 8 KB cap read whatever Kestrel's unpinned default allowed once inflated.
Every read now counts the bytes the handler receives, after request decompression, and stops one
byte past the cap; a site that names no cap reads under 1 MiB, and the error stays the 400
"Request body too large". The agent routes that parse with their own reader (session
registration, error report, distress, upload URL) and the Azure Monitor alert webhook read through
the same counted buffer and keep their responses. The telemetry ingest shares the counting, so a
body refused at the worker limit gets its 413 instead of a 500.

The worker's request body limit is pinned at 20 MiB, the largest route cap. Kestrel and the
decompression stream both enforce it, and a PostConfigure keeps registration order and app
settings from lifting it. A guard test keeps every body read inside the counted reader.

Session report and diagnostic-file uploads (up to 20 MiB each) run at most three at a time per
instance. A fourth gets 429 with Retry-After, and the portal dialogs keep their input. The first
refusal per instance and hour raises the new ReportUploadLimitReached ops event, which an alert
rule can route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:58:24 +01:00
Oliver Kieselbach 0d6797275c bump pointer: internal (B-xg7 done, B-vu8 trimmed, D-389 changelog entry)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:14:48 +01:00
Oliver Kieselbach 17f98f5c56 bump pointer: internal (D-389 usage hardware device count released, B-xg7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:06:20 +01:00
Oliver Kieselbach f53fdf86f6 Fleet Health counts read "1 enrollment" and "1 install" in the singular
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:00:46 +01:00
Oliver Kieselbach b57f9312ce bump pointer: internal (D-389 usage hardware device count built, B-xg7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:42:21 +01:00
Oliver Kieselbach 5136d33c2c Usage Metrics hardware lists show the devices behind their sessions; Fleet Health counts enrollments
Top Manufacturers and Top Models counted enrollment sessions without saying so, so a device
reset and enrolled again read like several devices. The lists still rank and share by sessions;
every row now also carries the distinct devices behind them (HardwareCount.DeviceCount), keyed
like device journeys: the normalized serial number per tenant, and a session without a usable
serial counts as its own device. The page groups both lists under a Hardware heading that says
so. Fleet Health's Health by Device Model labelled its session count "devices"; it now says
"enrollments". The get_usage_metrics description names the unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:41:45 +01:00
Oliver Kieselbach 5589ca42c7 bump pointer: internal (D-388 feedback id released: backend 1.5.1304, B-cfz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:39:38 +01:00
Oliver Kieselbach 8f9cf6e153 bump pointer: internal (lesson: browser checks are always possible)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:13:42 +01:00
Oliver Kieselbach eebbc895c8 bump pointer: internal, infra (D-388 feedback id built; deploy, backfill and verify B-cfz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:06:28 +01:00
Oliver Kieselbach 7d19a71230 Every feedback entry gets a 12-character id, shown on User Feedback and linked from the bell and alerts
Help-menu feedback, ratings and farewell comments each carry a FeedbackId: 12 lowercase hex
characters, the shape of a session report id. The feedback repository mints it when a row is first
written and keeps it on every later write (the rating and farewell rows are rewritten whole); a
help-menu row's RowKey ends in it. It is stored as a column in every kind, so one filter finds an
entry across all partitions.

FeedbackEntryWire.FeedbackId is that id for every kind (it was the help-menu RowKey).
FeedbackReceived, FeedbackRatingReceived and OffboardingFeedbackReceived carry feedbackId and a
portal path to the single entry, and the "New Feedback" / "New Rating" bells link it too.

The User Feedback page shows the id with a copy button on every card and follows ?feedbackId= to
the entry's tab and page, highlighting the card; an unknown id is named in the error banner.

Rows written before get their id from a one-time backfill after this backend is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:05:41 +01:00
Oliver Kieselbach 77569e0dfb bump pointer: internal (D-387 push telemetry released: backend 1.5.1303, B-6qk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:52:35 +01:00
Oliver Kieselbach 7a457b5d63 bump pointer: internal, .claude/commands (D-387 push telemetry built; deploy and verify B-6qk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:17:01 +01:00
Oliver Kieselbach 1ea366c419 Push app usage telemetry: opens, pairing steps, actions and device type per pairing scope
The push receiver reports push_app_opened (per page load, per return after a minute in
the background, per notification tap), push_device_checked, push_pair_step and
push_action. Each event carries the platform, display mode, notification permission and
screen class and, where the browser gives Client Hints, the model and OS version. Every
receiver item is attributed to the scope its device token names; only the token's first
segment is read, and a platform-scope receiver counts as a Global Admin. Nothing from the
history, no id and no token leaves the device.

The 2-hourly maintenance run reports the paired-device inventory (counts only), and
PushPairing and PushDelivery carry the tenant. Notify me when done reports
session_watch_toggled.

A notification tapped while the receiver shows its status or pairing page now opens the
entry. The CSP no longer allows js.monitor.azure.com: the SDK is bundled and its only
request there is switched off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:16:15 +01:00
Oliver Kieselbach bba8a0d6b4 bump pointer: internal, .claude/commands (B-s8j: own tenant out of the growth figures)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:45:53 +01:00
Oliver Kieselbach 6abe450388 Growth figures can leave the operator's own and test tenants out
GET global/metrics/growth and get_growth_metrics take excludeTenantIds (at
most 20 tenant ids, normalized; anything else is a 400) and drop those
tenants before any figure is computed: signups, cohorts, activation,
segments, plans and the tenant rows. excludedTenants counts the
configuration rows that matched, so a mistyped or offboarded id shows up
instead of being counted silently. The ids stay with the operator, never
in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:45:32 +01:00
Oliver Kieselbach fe193268ea bump pointer: internal, .claude/commands (D-386 growth report built; deploy and verify B-ddq)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:28:35 +01:00
Oliver Kieselbach ef6cf8e3f4 Growth figures for operators: one server-side join of signups, counters and daily usage
GET global/metrics/growth (GlobalReadOrAdmin) and the MCP tool
get_growth_metrics return the signups of the last N UTC days with peak days
and hour/weekday counts in an IANA time zone, weekly cohorts with enrollment,
success and the days to the first day with sessions, segments of all tenants
and plan counts; rows per signup only on request, ids and dates without
domains. The per-tenant counters are read with their tenant id, and the
unused UsageMetrics snapshot reader gives way to a range reader that throws
instead of reporting no activity.

query_table projects fields server-side and keeps the projection across
pages, as the server instructions promise; the raw table route no longer
copies the entity flattening.

The dashboard welcome card sends welcome_link_clicked and the OOBE code copy
buttons send bootstrap_session_copied (through useCopy, so a failed clipboard
write no longer rejects unhandled), which makes the step between signup and
the first enrollment measurable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:28:22 +01:00
Oliver Kieselbach e06a687ddd bump pointer: internal (aborted-body 400 released: backend 1.5.1301, B-usk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:32:00 +01:00
Oliver Kieselbach 1ee13de7a8 bump pointer: internal, .claude/commands (B-9zh; platform-health baselines 2026-10-28)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:27:10 +01:00
Oliver Kieselbach 8cb061784e Backend: a request body the client stopped sending is a 400, not a 500
When the browser cancels a request mid-body (tab closed, navigation,
network drop), Kestrel throws BadHttpRequestException "Unexpected end of
request content" while the body is read. The function's catch turned that
into a 500 and an exception row: 39 x POST AddToGroup and 1 x
GetScriptDisplayNames in 30 days, all client aborts.

RequestBody, the one body reader, now answers that case with the same 400
envelope as an empty or malformed body ("Request body incomplete"). Agent
routes are untouched: they read req.Body themselves, and ingest keeps its
503 so the agent replays the batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:27:08 +01:00
Oliver Kieselbach 7fd4a4fe62 MCP + rules tooling: qs 6.16.1, body-parser 2.4.0, fast-uri 3.1.9 close the open Dependabot alerts
Lockfile-only bumps of transitive dependencies, all inside the ranges their
parents already declare (express ^2.3.0 / ^6.16.0, ajv ^3.0.1):

- body-parser 2.3.0 -> 2.4.0 (mcp via express), GHSA-27r3-jxmh-p828;
  pulls iconv-lite 0.7.2 -> 0.7.3 with it
- qs 6.16.0 -> 6.16.1 (mcp via express/body-parser), GHSA-6pq8-jhm2-2vcw
- fast-uri 3.1.8 -> 3.1.9 (mcp and rules/scripts via ajv),
  GHSA-w74x-v6f9-j3xg, GHSA-xhq7-xmqj-769h, GHSA-wjr9-j795-jxcr,
  GHSA-333x-w4r6-6crp

The MCP fast-uri override is a security floor (7df208fa), so it moves from
^3.1.4 to ^3.1.9. qs stays on 6.16.1 rather than 6.17.0, which is a day old
and inside the 7-day cooldown. The MCP lockfile was written with npm 11.21.1
so the libc fields Dependabot added in #192 survive.

npm audit: 0 vulnerabilities in mcp, web, rules/scripts and tools/quality.
MCP: tsc, build, lint (0 warnings), knip clean; 1057 tests pass.
rules/scripts/validate.js passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:10:38 +01:00
dependabot[bot] 8929f1c5b4 chore(deps): bump the mcp group (#192)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 3 updates: [undici](https://github.com/nodejs/undici), [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `undici` from 8.11.2 to 8.12.3
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.11.2...v8.12.3)

Updates `knip` from 6.41.0 to 6.42.1
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.42.1/packages/knip)

Updates `typescript-eslint` from 8.71.2 to 8.72.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.42.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.72.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-28 12:45:05 +01:00
dependabot[bot] ff04137d0a chore(deps): bump the web group (#193)
Bumps the web group in /src/Web/autopilot-monitor-web with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.4.1` | `16.4.2` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.4.0` | `16.4.2` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.4.0` | `16.4.2` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.41.0` | `6.42.1` |


Updates `next` from 16.4.1 to 16.4.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.4.1...v16.4.2)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

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

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

Updates `knip` from 6.41.0 to 6.42.1
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.42.1/packages/knip)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.42.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-28 12:44:47 +01:00
Oliver Kieselbach ff8bc7927f bump pointer: internal (D-385 addendum released: one gate workflow)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:18:08 +02:00
Oliver Kieselbach 8a53c6233f Web + MCP: vitest 4.1.12 closes the @vitest/mocker advisories (GHSA-qxg6-89mg-rqv8, GHSA-xp26-3454-mjgp)
Dev-only. Resolved with --before=2026-10-16 so the bump honours the 7-day
cooldown of dependabot.yml: vite stays 8.3.4 and rolldown 1.2.14, only the
vitest chain moves (plus chai 6.3.0, tinyrainbow 3.2.0, and in MCP
@jridgewell/sourcemap-codec 1.6.0). The libc fields Dependabot wrote for the
@next/swc-linux-* entries in #191 are kept; a local npm 11.8 drops them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:09:29 +02:00
Oliver Kieselbach f3a4ce6ed8 bump pointer: internal (D-385 amended: one gate workflow)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:00:02 +02:00
Oliver Kieselbach 42dae1b38c Bootstrap script gates: one workflow instead of a caller plus a reusable copy (D-385)
The gate workflow carries its own push, pull-request and dispatch triggers
next to workflow_call, so the workflow list shows it once and its page lists
its runs. pester.yml, which only called it, is gone, and so are its entries in
the paths-ignore of ci.yml and codeql.yml. The workflow has no concurrency
group, because a workflow-level group would also bind the runs the publishers
call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:59:41 +02:00
dependabot[bot] b58830ddcb chore(deps): bump the web-security group across 1 directory with 2 updates (#191)
Bumps the web-security group with 2 updates in the /src/Web/autopilot-monitor-web directory: [next](https://github.com/vercel/next.js) and [dompurify](https://github.com/cure53/DOMPurify).


Updates `next` from 16.4.0 to 16.4.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.4.0...v16.4.1)

Updates `dompurify` from 3.4.16 to 3.4.17
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.16...3.4.17)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.1
  dependency-type: direct:production
  dependency-group: web-security
- dependency-name: dompurify
  dependency-version: 3.4.17
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 11:45:54 +02:00
Oliver Kieselbach caac165172 bump pointer: internal (D-385 released: bootstrap gates run on every change)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:42:03 +02:00
Oliver Kieselbach d25f2286b4 bump pointer: internal (D-385 bootstrap gates on every change and before every signing or upload)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:30:53 +02:00
Oliver Kieselbach 1dd4a477c5 Bootstrap gates run on every change and before every signing or upload (D-385)
The gates were attached to publishing: they ran only as the first job of the
script publish. Holding that publish back with GitHub's skip-actions marker
dropped them together with CI, CodeQL and zizmor. The stable agent release
published the same scripts without them, the MSI build signed the loader
without them, and no gate page ever showed a run.

- pester.yml, shown as "Bootstrap script gates", runs on pushes to main as
  well as pull requests, with one list of every input of the checks.
- publish-scripts.yml: a push whose commit message carries the skip-publish
  marker (spelled out in the workflow) runs neither job and uploads nothing;
  every check of the push still runs.
- build-agent.yml: a publish_as_stable release waits for the gates, which run
  next to the build and are skipped otherwise.
- build-msi.yml: nothing is signed before the gates pass. The loader's own
  parse/ASCII step goes, since the gates cover it.
- The reusable workflow is named "Bootstrap script gates (reusable)", since
  its runs show under the caller only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:29:19 +02:00
Oliver Kieselbach b8312ee0c6 bump pointer: internal (D-384 released: installer 2.9, bootstrap MSI 1.4.0, docs, web, MCP)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:49:58 +02:00
Oliver Kieselbach d639d9dda9 Bootstrap MSI 1.4: the action runs only the exact loader the package embeds (SHA-256 baked in at build time)
The automated security review of the previous commit found that a publisher pin alone
would run any validly signed script of ours placed at the copy's path, such as an older
loader without the held handle. The build now bakes the SHA-256 of the signed loader into
the action (wix build -d LoaderSha256=...; the package refuses to build without it), and
the action compares the bytes it read against it before the signature check.

- Package.wxs: hash check, preprocessor guard, comment
- build-msi.yml: the build step hashes the signed loader and passes it to wix
- Bootstrap.Integration.Tests.ps1: the harness substitutes the hash of the loader it serves;
  a copy that is not the embedded loader, however validly signed, runs nothing
- BootstrapContractTests: the hash condition, the guard and the build step are pinned
- internal: D-384, footprint doc and plan updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:09:56 +02:00
Oliver Kieselbach f44e8aadb0 Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.

- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
  deferred SYSTEM action, started in System32, reads the copy once, checks our signature
  and the publisher pin on those bytes and runs those bytes from memory, so a file a user
  planted at that path leads to nothing and no handle keeps the installer from removing
  the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
  agent-present branch (install and skip clear the base already) and an agent folder that
  is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
  in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
  asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
  read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
  agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
  directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
  marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:01:15 +02:00
Oliver Kieselbach e5b7003799 bump pointer: internal (ConfigMgr Watcher opt-in, D-382 updated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:59 +02:00
Oliver Kieselbach 6db3cb9741 ConfigMgr Watcher: the ConfigMgr client check is opt-in per tenant (D-382)
The check from d4e67cdb now runs only for tenants that enable it, like the
RealmJoin Watcher: TenantConfiguration.EnableConfigMgrWatcher feeds
AnalyzerConfiguration.EnableConfigMgrWatcher (default off, ConfigVersion
42). With the setting off, DefaultComponentFactory does not create the
ConfigMgrClientHost, so other tenants' devices run no check at all.

- Portal: toggle "ConfigMgr Watcher" in Agent Settings, section field map,
  tenant config report.
- Privacy page: the check is named as optional.
- Shared manifest and the web and MCP wire types regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:22 +02:00
Oliver Kieselbach a213290eb6 bump pointer: internal (D-382 V1-style ConfigMgr detection, D-384 dropped, B-b2u, lesson)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:45:12 +02:00
Oliver Kieselbach d4e67cdbb5 ConfigMgr client detection is back in the V2 agent (D-382)
The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.

- Session Info shows the latest detection; the V1 sentence about disturbed
  event sequences is gone.
- Privacy page: device context names the Configuration Manager client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:44:16 +02:00
Oliver Kieselbach e4d3e8ed30 bump pointer: internal (D-382 to D-384, provider-stall concept doc, configmgr-client-status plan phase 1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:23 +02:00
Oliver Kieselbach d4774ed043 Provider-stall tripwire counts device-preparation providers only while device preparation is open (D-383)
A ConfigMgr client installed as a Win32 app registers its device-preparation
policy provider after its own install, with InstallationState=1, and nothing
completes it. The tripwire then reported a stall long after device
preparation had ended.

- EspPhaseSnapshot.DevicePreparationOpen decides whether device preparation
  can still hold the ESP (it runs or failed, or the phase is unknown).
  EvaluateStallTransition takes it as a parameter; the detector reads the
  phase through an injectable reader.
- The cross-restart fingerprint applies the same filter, so a restart does
  not report the same stall again.
- The message names the wait: Setup/Apps providers hold the ESP without any
  timeout, device-preparation providers until their timeout.
- The diagnosis names a stalled provider only for a failure in its ESP
  category; the waiting list of an unfinished session stays unfiltered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:00 +02:00
Oliver Kieselbach b9cc515258 bump pointer: internal (deploy of D-379, D-380, D-381 recorded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:14:11 +02:00
Oliver Kieselbach fef1f54c09 bump pointer: internal (configmgr-client-status plan draft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach 60dc850317 web: AGENTS.md heading level, page lastmod regenerated for /about and /privacy
Both were left uncommitted in the working tree; committed as they stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach da58ac0a27 bump pointer: internal (B-ne6 join volume)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:57 +02:00
Oliver Kieselbach d1d15db4fe bump pointer: internal (backlog B-ne6 from the security review of D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:30 +02:00
Oliver Kieselbach a3810b74a7 bump pointer: internal (SignalR revoke bound to the sign-in tenant, D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:40:47 +02:00
Oliver Kieselbach 80d50a6d92 A live SignalR connection is addressed by its sign-in tenant; a revoke reaches only the tenant whose grant it ends (D-381)
Security finding (CWE-863): the SignalR user id was the bare UPN and every revoke cut that UPN across the
hub. A tenant admin could add any UPN (a Global Admin, another customer's operator) and remove, disable or
demote it in a loop, restarting that person's live connections in every tenant.

- Negotiate binds the user id to the sign-in tenant plus principal key from the validated request context
  (SignalRGroupHelper.UserId); a caller without a principal key negotiates without a user id, since every
  revocable join gate needs the key.
- ISignalRNotificationService.DisconnectUserAsync takes the tenant and the key. LiveStreamRevoker is its
  only caller and names the tenant each grant resolved under: member routes the route's tenant,
  self-service routes the managing tenant, Global Admin routes the tenant of the UPN's identity binding.
  Replacing or deleting a binding now cuts the streams signed in under the previous one.
- A self-service assignment confers scope only while its assignee is bound to the owning tenant, so a
  self-service grant resolves under the owner alone and the owner's revokes never need another tenant.
- Remove, disable and enable of a UPN without a row in the tenant answer 404 and change nothing: no audit,
  no push revoke, no end of an assignment, no cut.
- GlobalAdminService.DisableGlobalAdminAsync and its repository method had no caller and are removed.
- Tests pin the finding's attack, the 404s, the self-service gate, the binding routes and the call sites;
  each part was mutated back to the old behaviour and failed its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:39:26 +02:00
Oliver Kieselbach 5371de3856 bump pointer: internal (D-380 verify items in B-pxg)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:24:55 +02:00
Oliver Kieselbach 483dc3de94 The onboarding seat belongs to the first activation and to the recorded member alone (D-380)
Two follow-up findings of the security scan against D-378.

- IsPromotableUpn is a shape test (contains '@', no whitespace, no #EXT#); the "System" prefix
  test is gone. It rejected real members such as systemadmin@… although every sentinel already
  fails the '@' test, so such a tenant got no seat and fell back to first-caller-wins.
- BuildAuthResult keys the first-user guard off a recorded OnboardedBy, not off its promotability:
  a recorded value nobody can seat still blocks every other roleless caller.
- RevokeAsync records the revoke (PreviewWhitelist row "revoked") before it deletes the activation,
  fail-loud; a re-approve never clears it, and activation seats nobody while it exists. A member
  the tenant removed stays removed whatever a Global Admin's revoke + re-approve does.
- Every successful first-admin seat (activation and first sign-in) writes the tenant's audit entry
  CREATE TenantAdmin, fail-soft, through a new longest TenantAdminsService constructor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:22:57 +02:00
Oliver Kieselbach 06b69aa0d8 The logparser pattern test ends at the first timeout or a 2 s budget, honours cancellation, caps the pattern at 2,000 characters, runs interpreted and admits two tests per instance (D-379)
Security finding (ReDoS / CWE-1333): a Tenant Admin of any tenant or a Global Reader could send a catastrophic pattern with 200 non-matching lines to POST rules/gather/test-pattern and cost ~200 s of a worker thread per request (one swallowed 1 s timeout per line), repeat it up to the portal budget and disconnect at once — nothing cancelled the work, nothing limited concurrency, the pattern length was bounded only by the body cap, and RegexOptions.Compiled emitted IL for every caller pattern.

- TestLogPatternFunction: the first RegexMatchTimeoutException ends the test, a 2 s EvaluationBudget ends it when every line is merely slow, and the invocation CancellationToken (client disconnect, host shutdown) ends it between two lines — without throwing. Lines after the stop get no row; LogPatternTestResult.StopReason (regex_timeout | time_budget | cancelled) and a note name the stop and the unevaluated range. A timeout on the last line is a complete test.
- ValidateRequest rejects patterns over 2,000 characters (the MCP schema's cap) before any Regex is built.
- BuildAgentRegex keeps the agent's semantic options and its 1 s match timeout but drops Compiled: the flag changes speed only, never a match result.
- TestLogPatternGate (singleton, limit 2) is entered before the regex is built; without a free slot the function answers 429 RateLimited with Retry-After: 3 and never queues. Stops and refusals log at Warning.
- Shared: LogPatternTestResult.StopReason (additive); manifests and wire types regenerated. MCP: the tool description says that the first timeout ends the test.
- Tests: pattern cap, interpreted construction with the agent's timeout, catastrophic pattern stops after one timeout (was ~200 s), last-line timeout is no stop, budget and cancellation stops, gate slots.

Submodule pointers: internal (plan, D-379, backlog B-66p/B-j42), .claude/commands (platform-health marker).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:35:14 +02:00
Oliver Kieselbach 351c8d4b3a bump pointer: internal (activation race fix live, D-378)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:01:23 +02:00
Oliver Kieselbach e704aa7359 Activation seats the onboarding admin before it activates the tenant; the first-user seat belongs to that member alone (D-378)
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.

- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
  insert, best-effort, fresh config read), then the activation row (still the arbiter), then
  the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
  to that UPN only; other roleless callers stay roleless. Without one (rows from before the
  field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
  409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
  replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
  repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
  conditional write; each part was mutated back to the old code and failed its tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 20:47:53 +02:00
Oliver Kieselbach 622a2437a0 bump pointer: internal (plan note on the customer wording correction, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 19:01:17 +02:00
Oliver Kieselbach 5ceab7e611 bump pointer: internal (bootstrap security findings released, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:52:47 +02:00
Oliver Kieselbach 8a7cd5ed98 bump pointer: internal (MSI field check and lab name both MSI paths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:51:18 +02:00
Oliver Kieselbach 15804e2817 bump pointer: internal (bootstrap MSI agent start report closed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:20:39 +02:00
243 changed files with 9564 additions and 2027 deletions
+43 -6
View File
@@ -1,13 +1,50 @@
name: Bootstrap script gates
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
# once and both callers run the identical checks:
# * pester.yml -- pull requests (reports, does not block the merge button)
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
# download alias. Devices execute these scripts as SYSTEM
# during enrollment, so a red suite must never ship.
# Quality gate for the customer-facing PowerShell scripts. Devices execute them as SYSTEM
# during enrollment, so a red suite must never ship. It runs in two roles:
# * on its own, on every pull request and push to main that changes an input of these
# checks (reports, does not block the merge button);
# * called as the gates job that BLOCKS every signing or upload of bootstrap code:
# publish-scripts.yml, build-agent.yml (stable releases), build-msi.yml. Those runs
# show under the caller.
#
# Both path lists are the checks' complete inputs and stay identical. They cover every
# push path of publish-scripts.yml, which leaves the check of a [skip publish] push to
# this workflow.
#
# No concurrency group: it would also bind the runs called by the publishers, and a
# cancel there would block a publish.
on:
push:
branches: [main]
paths:
- 'scripts/Bootstrap/**'
- 'scripts/CustomerSetup/**'
- '.github/scripts/Publish-BootstrapScripts.ps1'
- '.github/scripts/Get-Endpoints.ps1'
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
- '.github/scripts/Test-BootstrapAttacker.ps1'
- '.github/workflows/bootstrap-script-gates.yml'
# The integration tests run the generated OOBE script and the MSI's custom action; the
# contract tests also read the MSI build.
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
- '.github/workflows/build-msi.yml'
pull_request:
branches: [main]
paths:
- 'scripts/Bootstrap/**'
- 'scripts/CustomerSetup/**'
- '.github/scripts/Publish-BootstrapScripts.ps1'
- '.github/scripts/Get-Endpoints.ps1'
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
- '.github/scripts/Test-BootstrapAttacker.ps1'
- '.github/workflows/bootstrap-script-gates.yml'
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
- '.github/workflows/build-msi.yml'
workflow_dispatch:
workflow_call:
permissions:
+12 -4
View File
@@ -254,6 +254,12 @@ jobs:
if-no-files-found: error
retention-days: 1
# A stable release publishes the customer scripts as well, so it waits for the gates their
# own publish waits for. Next to the build; skipped when nothing goes out as stable.
gates:
if: inputs.publish_as_stable
uses: $/.github/workflows/bootstrap-script-gates.yml
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
# run here -- never a restore or build. The build output arrives as data. The runner
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
@@ -263,7 +269,9 @@ jobs:
# sign nor write storage, whatever its permissions say.
release:
name: Sign and publish
needs: [reserve, build]
needs: [reserve, build, gates]
# A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it.
if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }}
runs-on: windows-latest
environment: release
permissions:
@@ -726,9 +734,9 @@ jobs:
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
# to main, so a release lands a set that is consistent with what is already live
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
# Customer-facing scripts: same publisher and same gates (the gates job) as
# publish-scripts.yml, so a release lands a set that is consistent with what is
# already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
# Signed with the same identity: the device-side loader verifies the publisher
# of what it downloads, and the publisher refuses to upload unsigned scripts.
+93 -18
View File
@@ -34,6 +34,12 @@ env:
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
jobs:
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
# of ours is trusted wherever it lands.
gates:
uses: $/.github/workflows/bootstrap-script-gates.yml
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
# need the embedded loader to carry a signature of its own.
@@ -42,6 +48,7 @@ jobs:
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
needs: gates
runs-on: windows-latest
environment: release
permissions:
@@ -59,22 +66,6 @@ jobs:
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# The loader executes as SYSTEM on every enrolling device — a parse error or
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
# stands on its own because it can be dispatched at any time.
- name: Validate runner script (parse + ASCII-only)
shell: pwsh
run: |
$errs = $null
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
Write-Error "Runner script contains non-ASCII characters"
exit 1
}
Write-Host "Runner script parse + ASCII check passed"
# Every published MSI is signed; a missing variable stops the run before the build.
- name: Check code-signing configuration
shell: pwsh
@@ -181,11 +172,16 @@ jobs:
- name: Build MSI
shell: pwsh
run: |
# The MSI's action runs only the exact bytes the package embeds: the hash of the
# signed loader goes into the command here, so no other signed script of ours runs
# in its place on a device.
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Embedded loader SHA-256: $loaderSha256"
# wix resolves File/@Source relative to the working directory, not the wxs —
# build from inside the project dir (same as the local build documented in the wxs).
$out = Join-Path (Get-Location) $env:STABLE_MSI
Push-Location $env:MSI_PROJECT_DIR
wix build Package.wxs -arch x64 -o $out
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
Pop-Location
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
@@ -198,11 +194,90 @@ jobs:
if-no-files-found: error
retention-days: 1
# The real chain on a runner, before anything is signed or published: msiexec installs the
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
# published installer, which finds the deployment marker set here and removes the copy. The
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
# the published installer to be 2.9 or later, which removes the copy in every outcome.
smoke:
name: Install on a runner (${{ matrix.os }})
needs: build
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
- name: Install with the deployment marker set, check the footprint, uninstall
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
$version = $Matches[1]
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
function Invoke-Msiexec {
param([string]$Verb, [string]$Log)
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
}
function Get-Registration {
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
}
function Show-Tree {
param([string]$Path)
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
}
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
Invoke-Msiexec -Verb i -Log $installLog
$registered = Get-Registration
if (-not $registered) { throw 'The product is not registered after the install.' }
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
# Only the installer removes the copy the MSI put under ProgramData: its absence
# proves the action ran the loader and the loader ran the installer.
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
}
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
if ($runFolders.Count -ne 0) {
foreach ($folder in $runFolders) {
Show-Tree $folder.FullName
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
}
throw 'A loader run folder stays: the loader did not end with exit 0.'
}
Write-Host 'After the install: nothing under ProgramData or Program Files.'
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
Write-Host 'Uninstalled; the registration is gone.'
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: build
needs: smoke
runs-on: windows-latest
environment: release
permissions:
-2
View File
@@ -46,7 +46,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
@@ -79,7 +78,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
workflow_dispatch:
-2
View File
@@ -38,7 +38,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
branches: [main]
@@ -70,7 +69,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
schedule:
# Weekly, so advisories published after a merge still surface. Deliberately
-35
View File
@@ -1,35 +0,0 @@
name: Pester (Bootstrap scripts)
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
# blocks the merge button.
#
# There is deliberately no push trigger: publish-scripts.yml runs the very same
# reusable gate workflow on every push to main and blocks the upload to the download
# alias on it. A push trigger here would only duplicate that run, with no consequence.
on:
pull_request:
branches: [main]
paths:
- 'scripts/Bootstrap/**'
- 'scripts/CustomerSetup/**'
- '.github/scripts/Publish-BootstrapScripts.ps1'
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
- '.github/scripts/Test-BootstrapAttacker.ps1'
- '.github/workflows/bootstrap-script-gates.yml'
# The integration tests run the generated OOBE script and the MSI's command line.
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pester-${{ github.ref }}
cancel-in-progress: true
jobs:
# $/ is the self-repository form: this repository at the running commit.
gates:
uses: $/.github/workflows/bootstrap-script-gates.yml
+5
View File
@@ -55,7 +55,12 @@ env:
jobs:
# $/ is the self-repository form: this repository at the running commit, never a
# path some earlier step could have written to the workspace.
#
# A push with [skip publish] in one of its commit messages runs neither job and uploads
# nothing; the gates' own push trigger, whose paths cover these, still checks it. Never
# [skip actions] for that: it drops every check of the push, CI and these gates included.
gates:
if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]')
uses: $/.github/workflows/bootstrap-script-gates.yml
publish:
+1 -1
Submodule infra updated: 0085deab45...7589909f54
+32 -23
View File
@@ -1,56 +1,63 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-001",
"title": "Win32 App Detection Script Failure",
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"title": "App Not Detected After Install (0x87D1041C)",
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
"severity": "warning",
"category": "apps",
"version": "1.3.0",
"version": "2.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 60,
"baseConfidence": 90,
"conditions": [
{
"signal": "detection_failure",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "detectionResult",
"operator": "equals",
"value": "NotDetected",
"required": true,
"suppressByEvent": {
"eventType": "app_install_completed",
"joinField": "appId"
}
"dataField": "errorCode",
"operator": "regex",
"value": "^(-2016345060|0x87d1041c)$",
"required": true
}
],
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
"remediation": [
{
"title": "Fix detection rules",
"title": "Check the detection rule against an installed device",
"steps": [
"Review the app's detection rules in Intune",
"Verify file/registry paths are correct",
"Test detection script manually on a device"
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
]
},
{
"title": "Microsoft Store (WinGet) apps",
"steps": [
"Check the package identifier and the install behavior (System or User) of the app in Intune",
"Install the package manually in the same context on a test device and confirm it is reported as installed"
]
}
],
"relatedDocs": [
{
"title": "Win32 app detection rules in Intune",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
}
],
"presentation": {
"role": "cause",
"summary": "{{detection_failure.appName}} was not detected after its install",
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
"subject": {
"kind": "app",
"key": "{{detection_failure.appId}}",
"name": "{{detection_failure.appName}}"
},
"facts": [
{
"label": "Detection",
"value": "{{detection_failure.value}}"
},
{
"label": "Error",
"value": "{{detection_failure.errorCode}}",
@@ -61,6 +68,8 @@
"tags": [
"apps",
"detection",
"win32"
"win32",
"winget",
"0x87d1041c"
]
}
-87
View File
@@ -1,87 +0,0 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-010",
"version": "1.1.0",
"title": "App Reverted to Error After Successful Install",
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
"severity": "warning",
"category": "apps",
"enabled": true,
"trigger": "correlation",
"baseConfidence": 75,
"conditions": [
{
"signal": "app_regression",
"source": "event_correlation",
"eventType": "app_install_completed",
"correlateEventType": "app_install_failed",
"joinField": "appId",
"dataField": "errorPatternId",
"operator": "equals",
"value": "IME-ERROR-REPORT",
"required": true
},
{
"signal": "enrollment_succeeded",
"source": "event_type",
"eventType": "enrollment_complete",
"operator": "exists",
"value": "",
"required": true
}
],
"confidenceThreshold": 70,
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
"remediation": [
{
"title": "Investigate the app's post-install enforcement state",
"steps": [
"Note the appId and appName from the rule finding — these identify the exact affected app",
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
]
},
{
"title": "Fix detection or installation issues",
"steps": [
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
"Consider adding a post-install validation script to catch this scenario proactively"
]
}
],
"relatedDocs": [
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
],
"presentation": {
"role": "cause",
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
"subject": {
"kind": "app",
"key": "{{app_regression.joinValue}}",
"name": "{{app_regression.eventA_appName}}"
},
"facts": [
{
"label": "Installed",
"value": "{{app_regression.eventA_timestamp}}",
"format": "timestamp"
},
{
"label": "Error after",
"value": "{{app_regression.timeDeltaSeconds}}",
"format": "duration"
},
{
"label": "Error",
"value": "{{app_regression.eventB_errorCode}}",
"format": "errorCode"
}
]
},
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
}
+6 -10
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.3.0",
"version": "1.4.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -27,9 +27,9 @@
"signal": "app_with_detection_failure",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "detectionResult",
"operator": "equals",
"value": "NotDetected",
"dataField": "errorCode",
"operator": "regex",
"value": "^(-2016345060|0x87d1041c)$",
"required": true
},
{
@@ -42,7 +42,7 @@
"required": false
}
],
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
"remediation": [
{
"title": "Validate the detection rule against an installed device",
@@ -66,7 +66,7 @@
{
"title": "Cross-check the agent's per-app event",
"steps": [
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
]
@@ -95,10 +95,6 @@
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "Detection",
"value": "{{app_with_detection_failure.value}}"
}
]
},
+38 -131
View File
@@ -3,56 +3,63 @@
"rules": [
{
"ruleId": "ANALYZE-APP-001",
"title": "Win32 App Detection Script Failure",
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"title": "App Not Detected After Install (0x87D1041C)",
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
"severity": "warning",
"category": "apps",
"version": "1.3.0",
"version": "2.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed"
],
"baseConfidence": 60,
"baseConfidence": 90,
"conditions": [
{
"signal": "detection_failure",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "detectionResult",
"operator": "equals",
"value": "NotDetected",
"required": true,
"suppressByEvent": {
"eventType": "app_install_completed",
"joinField": "appId"
}
"dataField": "errorCode",
"operator": "regex",
"value": "^(-2016345060|0x87d1041c)$",
"required": true
}
],
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
"remediation": [
{
"title": "Fix detection rules",
"title": "Check the detection rule against an installed device",
"steps": [
"Review the app's detection rules in Intune",
"Verify file/registry paths are correct",
"Test detection script manually on a device"
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
]
},
{
"title": "Microsoft Store (WinGet) apps",
"steps": [
"Check the package identifier and the install behavior (System or User) of the app in Intune",
"Install the package manually in the same context on a test device and confirm it is reported as installed"
]
}
],
"relatedDocs": [
{
"title": "Win32 app detection rules in Intune",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
}
],
"presentation": {
"role": "cause",
"summary": "{{detection_failure.appName}} was not detected after its install",
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
"subject": {
"kind": "app",
"key": "{{detection_failure.appId}}",
"name": "{{detection_failure.appName}}"
},
"facts": [
{
"label": "Detection",
"value": "{{detection_failure.value}}"
},
{
"label": "Error",
"value": "{{detection_failure.errorCode}}",
@@ -63,7 +70,9 @@
"tags": [
"apps",
"detection",
"win32"
"win32",
"winget",
"0x87d1041c"
]
},
{
@@ -541,104 +550,6 @@
],
"version": "1.2.0"
},
{
"ruleId": "ANALYZE-APP-010",
"version": "1.1.0",
"title": "App Reverted to Error After Successful Install",
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
"severity": "warning",
"category": "apps",
"enabled": true,
"trigger": "correlation",
"baseConfidence": 75,
"conditions": [
{
"signal": "app_regression",
"source": "event_correlation",
"eventType": "app_install_completed",
"correlateEventType": "app_install_failed",
"joinField": "appId",
"dataField": "errorPatternId",
"operator": "equals",
"value": "IME-ERROR-REPORT",
"required": true
},
{
"signal": "enrollment_succeeded",
"source": "event_type",
"eventType": "enrollment_complete",
"operator": "exists",
"value": "",
"required": true
}
],
"confidenceThreshold": 70,
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
"remediation": [
{
"title": "Investigate the app's post-install enforcement state",
"steps": [
"Note the appId and appName from the rule finding — these identify the exact affected app",
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
]
},
{
"title": "Fix detection or installation issues",
"steps": [
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
"Consider adding a post-install validation script to catch this scenario proactively"
]
}
],
"relatedDocs": [
{
"title": "Troubleshoot Win32 app installations",
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
},
{
"title": "Intune Management Extension logs",
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension"
}
],
"presentation": {
"role": "cause",
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
"subject": {
"kind": "app",
"key": "{{app_regression.joinValue}}",
"name": "{{app_regression.eventA_appName}}"
},
"facts": [
{
"label": "Installed",
"value": "{{app_regression.eventA_timestamp}}",
"format": "timestamp"
},
{
"label": "Error after",
"value": "{{app_regression.timeDeltaSeconds}}",
"format": "duration"
},
{
"label": "Error",
"value": "{{app_regression.eventB_errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"win32",
"enforcement",
"post-install",
"correlation"
]
},
{
"ruleId": "ANALYZE-APP-012",
"title": "Enforcement Error Resolved by Successful Detection",
@@ -724,7 +635,7 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.3.0",
"version": "1.4.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -746,9 +657,9 @@
"signal": "app_with_detection_failure",
"source": "event_type",
"eventType": "app_install_failed",
"dataField": "detectionResult",
"operator": "equals",
"value": "NotDetected",
"dataField": "errorCode",
"operator": "regex",
"value": "^(-2016345060|0x87d1041c)$",
"required": true
},
{
@@ -761,7 +672,7 @@
"required": false
}
],
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
"remediation": [
{
"title": "Validate the detection rule against an installed device",
@@ -785,7 +696,7 @@
{
"title": "Cross-check the agent's per-app event",
"steps": [
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
]
@@ -814,10 +725,6 @@
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "Detection",
"value": "{{app_with_detection_failure.value}}"
}
]
},
+22 -3
View File
@@ -195,7 +195,7 @@
"category": "always",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
},
{
@@ -285,12 +285,12 @@
{
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
"action": "updateStateError",
"parameters": {
"checkTo": "true"
},
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
"enabled": true
},
{
@@ -487,6 +487,25 @@
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
"enabled": true
},
{
"patternId": "IME-POST-INSTALL-DETECTION",
"category": "always",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
"action": "markPostInstallDetection",
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
"enabled": true
},
{
"patternId": "IME-POST-REBOOT-DETECTION",
"category": "always",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "markPostInstallDetection",
"parameters": {
"afterReboot": "true"
},
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
"enabled": true
},
{
"patternId": "IME-RESTART-STOP-PROCESSING",
"category": "currentPhase",
+1
View File
@@ -292,6 +292,7 @@
"app_install_completed",
"app_install_failed",
"app_install_skipped",
"app_post_install_detection",
"app_download_started",
"app_tracking_summary",
"download_progress",
@@ -4,6 +4,6 @@
"category": "always",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
}
+2 -2
View File
@@ -2,11 +2,11 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
"action": "updateStateError",
"parameters": {
"checkTo": "true"
},
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
"enabled": true
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-POST-INSTALL-DETECTION",
"category": "always",
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
"action": "markPostInstallDetection",
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
"enabled": true
}
@@ -0,0 +1,12 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-POST-REBOOT-DETECTION",
"category": "always",
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "markPostInstallDetection",
"parameters": {
"afterReboot": "true"
},
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
"enabled": true
}
+2 -1
View File
@@ -82,7 +82,8 @@
"captureAppTypeMsi",
"captureAttemptNumber",
"captureDetectionResult",
"captureExitCodeClass"
"captureExitCodeClass",
"markPostInstallDetection"
]
},
"parameters": {
+3 -3
View File
@@ -53,9 +53,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true,
"funding": [
{
@@ -70,8 +70,8 @@ function global:HarnessInvokeRestMethod {
function global:HarnessGetAuthenticodeSignature {
[CmdletBinding()]
param([string]$FilePath)
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
return [pscustomobject]@{
Status = $global:HarnessOptions.SignatureStatus
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
@@ -188,8 +188,11 @@ if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'Harness
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
# The OOBE script arrives as irm | iex, which ignores #Requires.
if ($global:HarnessOptions.Iex) {
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
# ignores #Requires.
if ($global:HarnessOptions.CommandText) {
Invoke-Expression $global:HarnessOptions.CommandText
} elseif ($global:HarnessOptions.Iex) {
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
} else {
& $global:HarnessOptions.Script @args
@@ -228,6 +231,7 @@ exit $LASTEXITCODE
Root = $root
ProgramData = Join-Path $root 'ProgramData'
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
ProgramFiles = Join-Path $root 'ProgramFiles'
Temp = Join-Path $root 'Temp'
Server = Join-Path $root 'Server'
Outside = Join-Path $root 'Outside'
@@ -262,9 +266,11 @@ exit $LASTEXITCODE
CatalogValid = $true
Elevated = $true
Iex = $false
CommandText = $null
AgentStartFails = $false
}
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
$script:promiseScenario = $Scenario
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
$settings.SwapDacl = -not $script:isElevated
@@ -287,6 +293,7 @@ exit $LASTEXITCODE
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
$process = [System.Diagnostics.Process]::Start($psi)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
@@ -328,6 +335,25 @@ exit $LASTEXITCODE
return ($sorted -join "`n")
}
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
# ours anywhere else. The scenario's own fixture folders are the only other entries.
function Assert-NothingOutsideProgramData {
param($Scenario)
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
}
# Every Describe runs this after each test: the last run of the test kept the promise.
function Assert-PromiseOfLastRun {
if (-not $script:promiseScenario) { return }
$scenario = $script:promiseScenario
$script:promiseScenario = $null
Assert-NothingOutsideProgramData $scenario
}
function Get-StdErrLines {
param($Run)
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
@@ -369,6 +395,7 @@ exit $LASTEXITCODE
}
Describe 'loader 1.4' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs and leaves only the agent folder, with its log carried over' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
@@ -471,7 +498,7 @@ Describe 'loader 1.4' {
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.8"', '$ScriptVersion = "2.7"') }
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
) {
$s = New-Scenario
@@ -502,6 +529,7 @@ Describe 'loader 1.4' {
}
Describe 'installer assigned directly' {
AfterEach { Assert-PromiseOfLastRun }
BeforeEach {
$s = New-Scenario
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
@@ -605,6 +633,7 @@ Describe 'installer assigned directly' {
}
Describe 'loader 1.2, frozen' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loader12Path
@@ -644,6 +673,7 @@ Describe 'loader 1.2, frozen' {
}
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
AfterEach { Assert-PromiseOfLastRun }
It '<Name> skips and leaves <Left>' -TestCases @(
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@@ -660,6 +690,26 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
# installer takes it away in every outcome, also when an agent is present.
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
$s = New-Scenario
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData -Content | Should -Be $before
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
}
It '<Name> installs' -TestCases @(
@@ -678,10 +728,127 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
}
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
# System32: read the copy once, check our signature on those bytes, run those bytes. The
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
$script:actionCommand = $Matches[1]
# What the build bakes in: the hash of the loader the package embeds, here the one served.
$script:loaderSha256 = (Get-FileHash -LiteralPath $script:loaderPath -Algorithm SHA256).Hash.ToLowerInvariant()
function Format-MsiAction {
param([string]$Text, [string]$InstallFolder)
return $Text.Replace('$(var.LoaderSha256)', $script:loaderSha256).Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
}
function Add-MsiCopy {
param($Scenario)
$msi = Join-Path $Scenario.Base 'Msi'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
}
function Invoke-MsiAction {
param($Scenario, [hashtable]$Options = @{})
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
}
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
}
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
# Without the [\x] escapes, the only bracket left is the one property reference.
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
$errors = $null
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
@($errors) | Should -BeNullOrEmpty
}
It 'installs, and the copy goes with the emptied agent folder' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
}
It 'skips and leaves nothing behind' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
}
It 'leaves nothing behind when only the deployment marker is there' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
}
It 'removes the copy and leaves the folder of an agent as it is' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
}
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Append = $null }
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' }; Append = $null }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Append = $null }
@{ Case = 'not the loader the package embeds, however validly signed'; Options = @{}; Append = "`r`n# one more line" }
) {
$s = New-Scenario
Add-MsiCopy $s
if ($Append) { [System.IO.File]::AppendAllText((Join-Path $s.Base 'Msi\Start-AutopilotMonitor.ps1'), $Append) }
$run = Invoke-MsiAction $s -Options $Options
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
}
}
Describe 'OOBE script, as the backend generates it' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
$s = New-Scenario
Add-Leftovers $s
@@ -733,6 +900,7 @@ Describe 'OOBE script, as the backend generates it' {
}
Describe 'publish pipeline and dev channel' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$script:rendered = Join-Path $TestDrive 'rendered'
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
@@ -768,9 +936,9 @@ Describe 'publish pipeline and dev channel' {
}
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
) {
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
@@ -817,7 +985,7 @@ Describe 'publish pipeline and dev channel' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
}
}
@@ -800,6 +800,153 @@ Describe 'Remove-AgentBaseOnSkip' {
}
}
Describe 'Remove-MsiTransportFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
}
It 'has nothing to do without an agent folder or without the copy' {
Remove-MsiTransportFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
}
It 'removes the copy and keeps an agent folder that holds anything else' {
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
Test-Path $MsiTransportPath | Should -BeFalse
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
}
It 'removes the copy and with it an agent folder that is then empty' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
Test-Path $AgentBasePath | Should -BeFalse
}
It 'leaves a copy that is not ours, and says so' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'touches nothing below an agent folder that is not ours' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $false }
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'never follows a link planted as the copy' {
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
$target = New-SentinelJunction -Link $MsiTransportPath
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-MsiTransportFolder } | Should -Not -Throw
}
}
Describe 'Remove-LegacyMsiLoaderFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:realProgramW6432 = $env:ProgramW6432
$script:realProgramFiles = $env:ProgramFiles
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
}
AfterEach {
$env:ProgramW6432 = $script:realProgramW6432
$env:ProgramFiles = $script:realProgramFiles
}
It 'has nothing to do where MSI 1.3 never ran' {
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
Test-Path $script:company | Should -BeTrue
}
It 'removes what MSI 1.3 installed, and the company folder with it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
Test-Path $script:company | Should -BeFalse
}
It 'keeps the company folder when anything else is in it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
Test-Path (Split-Path $script:loader) | Should -BeFalse
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
}
It 'reads the 64-bit Program Files, also from the 32-bit host' {
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
New-Item -ItemType File -Path $script:loader -Force | Out-Null
New-Item -ItemType File -Path $other -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
Test-Path $script:company | Should -BeFalse
Test-Path $other | Should -BeTrue
}
It 'leaves a folder that is not ours' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Mock Test-IsOwnedByUs { $false }
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
Test-Path $script:loader | Should -BeTrue
}
It 'never follows a link' {
$target = New-SentinelJunction -Link $script:company
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
}
}
Describe 'Get-AgentPayloadRejections' {
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
@@ -1122,7 +1269,7 @@ Describe 'script text' {
It 'declares the role and version the loader checks, one line each' {
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.8"\s*$')).Count | Should -Be 1
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
}
It 'never stages anything in the shared temp folder' {
+65 -3
View File
@@ -13,7 +13,8 @@
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
file matches the catalog signed by glueckkanja AG. The agent removes itself when
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
that the device is not installed again. A skipped device without an agent keeps nothing.
that the device is not installed again. A skipped device without an agent keeps nothing,
and the loader copy the bootstrap MSI installs is removed in every outcome.
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
or the install fails; a skip is logged to the script output only. Exit code 0: installed
@@ -53,7 +54,7 @@ param(
)
# The loader checks these two lines before it runs this script.
$ScriptVersion = "2.8"
$ScriptVersion = "2.9"
$ScriptRole = "AutopilotMonitor-Installer"
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
@@ -69,6 +70,9 @@ $LogFile = "$AgentLogPath\bootstrap-script.log"
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
# Where the bootstrap MSI installs its loader copy; this script removes it in every outcome.
$MsiTransportPath = "$AgentBasePath\Msi"
$AgentExeName = "AutopilotMonitor.Agent.exe"
$AgentTaskName = "AutopilotMonitor-Agent"
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
@@ -608,6 +612,57 @@ function Remove-AgentBaseOnSkip {
}
}
# The loader copy the bootstrap MSI installs. It goes whatever the outcome; an agent folder
# that is then empty goes with it (a running agent, or logs kept after enrollment, keep it).
# Returns what was done, or $null when there was nothing to do; never throws.
function Remove-MsiTransportFolder {
try {
if ((Get-FolderState $AgentBasePath) -ne 'Ours') { return $null }
$state = Get-FolderState $MsiTransportPath
if ($state -eq 'Missing') { return $null }
if ($state -ne 'Ours') { return "$MsiTransportPath left as it is ($state)." }
$left = Remove-OwnTree -Path $MsiTransportPath
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
return ("$MsiTransportPath kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
}
if (@([System.IO.Directory]::EnumerateFileSystemEntries($AgentBasePath)).Count -eq 0) {
[System.IO.Directory]::Delete($AgentBasePath, $false)
return "$MsiTransportPath removed, and with it the empty $AgentBasePath."
}
return "$MsiTransportPath removed."
} catch {
return "$MsiTransportPath left as it is ($($_.Exception.Message))."
}
}
# What bootstrap MSI 1.3 installed under Program Files, on devices that still enroll with it.
# Returns what was done, or $null when there was nothing to do; never throws.
function Remove-LegacyMsiLoaderFolder {
try {
$programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
if (-not $programFiles) { return $null }
$company = Join-Path $programFiles 'AutopilotMonitor'
$companyState = Get-FolderState $company
if ($companyState -eq 'Missing') { return $null }
if ($companyState -ne 'Ours') { return "$company left as it is ($companyState)." }
$path = Join-Path $company 'Bootstrap'
$state = Get-FolderState $path
if ($state -eq 'Missing') { return $null }
if ($state -ne 'Ours') { return "$path left as it is ($state)." }
$left = Remove-OwnTree -Path $path
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
return ("$path kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
}
if (@([System.IO.Directory]::EnumerateFileSystemEntries($company)).Count -eq 0) {
[System.IO.Directory]::Delete($company, $false)
return "$path removed, and with it the empty $company."
}
return "$path removed."
} catch {
return "The loader folder of bootstrap MSI 1.3 is left as it is ($($_.Exception.Message))."
}
}
function Test-IsLoaderRunFolder {
param([string]$Folder)
if (-not $Folder) { return $false }
@@ -711,12 +766,17 @@ function Invoke-Bootstrap {
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
$agent = Get-AgentPresence
if ($agent) {
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
$transport = Remove-MsiTransportFolder
Write-Log "Agent present ($agent): $(if ($transport) { $transport } else { "$AgentBasePath is left as it is." })"
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
if (-not $decision.Install) {
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
@@ -729,6 +789,8 @@ function Invoke-Bootstrap {
Write-LoaderLogs $loaderLogs
Open-LogFile
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
$agentExePath = Join-Path $AgentBinPath $AgentExeName
@@ -467,11 +467,15 @@ Describe 'publish contract' {
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
}
# The file a SYSTEM custom action runs must not sit where a standard user could have
# created the folder first: Program Files, never ProgramData.
It 'is installed by the bootstrap MSI under Program Files' {
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
# the installer removes it. A standard user could have created that folder first, so the
# action never runs the file as such: it checks the signature on the bytes it read and runs
# those bytes, from System32.
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
$wxs | Should -Not -Match 'CommonAppDataFolder'
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
$wxs | Should -Not -Match 'ProgramFiles'
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
}
}
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -26,6 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1"));
private static readonly string OobeScript = File.ReadAllText(Path.Combine(
RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1"));
private static readonly string Wxs = File.ReadAllText(Path.Combine(
RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
@@ -80,8 +84,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
var wxs = File.ReadAllText(Path.Combine(RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, wxs);
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, Wxs);
}
// The bootstrap MSI installs its loader copy where the installer removes it, starts
// PowerShell in System32, and runs only bytes it has checked against the publisher pin
// the loader applies to the installer. Windows Installer formats the command, so every
// bracket and brace is escaped and the only property references are the two paths.
[Fact]
public void The_msi_puts_the_loader_copy_where_the_installer_removes_it_and_runs_only_what_it_checked()
{
XNamespace ns = "http://wixtoolset.org/schemas/v4/wxs";
var package = XDocument.Parse(Wxs).Root.Element(ns + "Package");
Assert.Equal("1fbe5515-d346-445c-b8b8-14ee79f97522", (string)package.Attribute("UpgradeCode"));
var root = package.Elements(ns + "StandardDirectory").Single();
Assert.Equal("CommonAppDataFolder", (string)root.Attribute("Id"));
var chain = root.Descendants(ns + "Directory").Select(d => (string)d.Attribute("Name")).ToArray();
Assert.Equal("INSTALLFOLDER", (string)root.Descendants(ns + "Directory").Last().Attribute("Id"));
var transport = Assignment(Installer, "MsiTransportPath").Replace("$AgentBasePath", Assignment(Installer, "AgentBasePath"));
Assert.Equal("$env:ProgramData\\" + string.Join("\\", chain), transport);
var action = package.Elements(ns + "CustomAction").Single(a => (string)a.Attribute("Id") == "RunBootstrap");
Assert.Equal("System64Folder", (string)action.Attribute("Directory"));
Assert.Equal("deferred", (string)action.Attribute("Execute"));
Assert.Equal("no", (string)action.Attribute("Impersonate"));
Assert.Equal("ignore", (string)action.Attribute("Return"));
var exeCommand = (string)action.Attribute("ExeCommand");
var command = exeCommand.Substring(exeCommand.IndexOf(" -Command ", StringComparison.Ordinal));
Assert.Contains("-NonInteractive", exeCommand);
Assert.Contains("[\\[]System.IO.File[\\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1')", command);
Assert.Contains("Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'", command);
Assert.Contains("$h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '" + Assignment(Loader, "ExpectedPublisher") + "'", command);
// The hash is baked in by the build from the loader it embeds; a package built without it must not exist.
Assert.Contains("<?ifndef var.LoaderSha256 ?>", Wxs);
var workflow = File.ReadAllText(Path.Combine(RepoPaths.Root(), ".github", "workflows", "build-msi.yml"));
Assert.Contains("wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256", workflow);
Assert.Contains("Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256", workflow);
Assert.Contains("[\\[]scriptblock[\\]]::Create([\\[]System.Text.Encoding[\\]]::UTF8.GetString($b))", command);
Assert.DoesNotContain("-File", command);
// Without the [\x] escapes, the only bracket left is the one property reference.
var unescaped = Regex.Replace(command, @"\[\\.\]", "");
Assert.DoesNotMatch(@"[{}\[\]]", unescaped.Replace("[INSTALLFOLDER]", ""));
Assert.Equal(new[] { "[INSTALLFOLDER]" }, Regex.Matches(command, @"\[[A-Za-z0-9_.]+\]").Cast<Match>().Select(m => m.Value).Distinct().ToArray());
var custom = package.Element(ns + "InstallExecuteSequence").Elements(ns + "Custom").Single();
Assert.Equal("RunBootstrap", (string)custom.Attribute("Action"));
Assert.Equal("NOT Installed", (string)custom.Attribute("Condition"));
}
[Fact]
@@ -0,0 +1,80 @@
#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.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
/// <summary>
/// An <see cref="ImeLogTracker"/> fed the shipped IME pattern pack (<c>rules/ime-log-patterns</c>,
/// sorted as combine.js embeds it) and a CMTrace <c>IntuneManagementExtension.log</c> the test
/// appends to, so a test drives the real patterns end to end.
/// </summary>
internal sealed class ShippedImePackHarness : 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 ShippedImePackHarness()
{
StateDir = Path.Combine(_tmp.Path, "State");
Tracker = Build();
}
public static List<ImeLogPattern> Pack()
=> Directory.GetFiles(Path.Combine(RepoPaths.Root(), "rules", "ime-log-patterns"), "*.json")
.OrderBy(f => f, StringComparer.Ordinal)
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
.ToList();
/// <summary>A second tracker on the same log and state folder (an agent restart).</summary>
public ImeLogTracker Build()
{
var tracker = new ImeLogTracker(
logFolder: _tmp.Path,
patterns: Pack(),
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 appended entries are always fresh.
tracker.UtcNowProvider = () => Now;
return tracker;
}
public void Append(params string[] messages) => AppendAt(Now.AddSeconds(-60), messages);
/// <summary>Entries one second apart from <paramref name="firstUtc"/> (an older instant replays a previous enrollment).</summary>
public void AppendAt(DateTime firstUtc, params string[] messages)
{
var lines = messages.Select((m, i) => Entry(m, firstUtc.AddSeconds(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 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=\"\">";
}
}
}
@@ -270,6 +270,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
new[] { "id=" + Guid1, "to=InProgress" }
};
yield return new object[]
{
"IME-ERROR-REPORT",
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"Success\",\"NewValue\":\"Error\"},\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":-2016345060}}",
new[] { "id=" + Guid1, "from=Success", "to=Error", "errorCode=-2016345060" }
};
yield return new object[]
{
"IME-POST-INSTALL-DETECTION",
"[Win32App][DetectionActionHandler] Policy with id: " + Guid1 + " is expected to have enforcement state: Detected.",
new[] { "id=" + Guid1 }
};
yield return new object[]
{
"IME-POST-REBOOT-DETECTION",
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"InProgressPendingReboot\",\"NewValue\":\"Success\"}}",
new[] { "id=" + Guid1 }
};
yield return new object[]
{
"IME-TOKEN-FAILURE",
"Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929",
@@ -0,0 +1,229 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// IME's sequence per app, driven through the shipped pattern pack: the check before the
/// install, the installer, "Execution state … Success", the check right after the install,
/// and the final state (D-392).
/// <list type="bullet">
/// <item>When the check after the install does not find the app, IME flips Success → Error
/// with EnforcementErrorCode -2016345060 (0x87D1041C) BEFORE it logs the check's
/// result line; that code is IME's verdict and must reach the error transition.</item>
/// <item>The check after the install is marked ("expected to have enforcement state:
/// Detected"); its result is reported through OnPostInstallDetection, and
/// detectionResult stays the check before the install.</item>
/// <item>After an exit code that asked for a restart, that check decides nothing; the check
/// after the restart (InProgressPendingReboot → Success) is the one reported.</item>
/// </list>
/// </summary>
public sealed class ImeLogTrackerDetectionAfterInstallTests
{
private const string AppId = "5c95bf94-1cf4-4629-88d1-3f616e7a405c";
private const string DependencyId = "9d2b8a71-4e0c-4f5a-a3c6-71b0e2d5c8f4";
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
private static string DetectionResult(string state, string appId = AppId)
=> "[Win32App][DetectionActionHandler] Detection for policy with id: " + appId +
" resulted in action status: Success and detection state: " + state + ".";
private static string[] CheckAfterInstallMarkers() => new[]
{
"[Win32App][DetectionActionHandler] Detection running for policy with id: " + AppId + ".",
"[Win32App][DetectionActionHandler] The last enforcement action was on policy with id: " + AppId + ".",
"[Win32App][DetectionActionHandler] Policy with id: " + AppId + " is expected to have enforcement state: Detected.",
};
private static string ExecutionDelta(string from, string to, string? errorCode = null)
=> "[Win32App][ReportingManager] Execution state for app with id: " + AppId + " has been updated. Report delta: " +
"{\"EnforcementState\":{\"OldValue\":\"" + from + "\",\"NewValue\":\"" + to + "\"}" +
(errorCode == null ? "" : ",\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":" + errorCode + "}") + "}";
private sealed class Run
{
public ShippedImePackHarness Harness { get; } = new ShippedImePackHarness();
public AppPackageState Package { get; } = new AppPackageState(AppId, 0);
public List<(AppInstallationState To, string? Code)> Transitions { get; } = new List<(AppInstallationState, string?)>();
public List<(string Detection, bool AfterReboot)> ChecksAfterInstall { get; } = new List<(string, bool)>();
public Run()
{
Package.UpdateState(AppInstallationState.Installing);
Harness.Tracker.PackageStates.Add(Package);
Harness.Tracker.OnAppStateChanged = (p, _, to) => { if (p.Id == AppId) Transitions.Add((to, p.ErrorCode)); };
Harness.Tracker.OnPostInstallDetection = (p, detection, afterReboot, _) => ChecksAfterInstall.Add((detection, afterReboot));
}
public void Lines(params string[] lines) => Harness.Append(lines);
}
private static string[] CleanInstallThen(params string[] tail)
{
var lines = new List<string>
{
PhaseLine,
DetectionResult("NotDetected"), // the check before the install
ExecutionDelta("InProgress", "Success"), // IME holds this back until its check
};
lines.AddRange(CheckAfterInstallMarkers());
lines.AddRange(tail);
return lines.ToArray();
}
[Fact]
public async Task Check_after_install_failing_carries_IMEs_code_on_the_error_transition()
{
var run = new Run();
using (run.Harness)
{
run.Lines(CleanInstallThen(
ExecutionDelta("Success", "Error", "-2016345060"),
DetectionResult("NotDetected"))); // the check's own result line comes last
await run.Harness.Pass();
Assert.Equal(new[] { AppInstallationState.Installed, AppInstallationState.Error }, run.Transitions.Select(t => t.To));
Assert.Equal("-2016345060", run.Transitions.Last().Code);
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
Assert.Equal("IME-ERROR-REPORT", run.Package.ErrorPatternId);
Assert.Equal(new[] { ("NotDetected", false) }, run.ChecksAfterInstall);
Assert.Equal("NotDetected", run.Package.DetectionResult);
}
}
[Fact]
public async Task Check_after_a_clean_install_is_reported_and_detectionResult_stays_the_check_before()
{
var run = new Run();
using (run.Harness)
{
run.Lines(CleanInstallThen(DetectionResult("Detected")));
await run.Harness.Pass();
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
Assert.Equal("NotDetected", run.Package.DetectionResult);
Assert.Equal(AppInstallationState.Installed, run.Package.InstallationState);
}
}
[Fact]
public async Task A_different_version_found_after_the_install_is_reported_as_such()
{
var run = new Run();
using (run.Harness)
{
run.Lines(CleanInstallThen(DetectionResult("DetectedDifferentVersion")));
await run.Harness.Pass();
Assert.Equal(new[] { ("DetectedDifferentVersion", false) }, run.ChecksAfterInstall);
}
}
[Fact]
public async Task A_dependency_checked_in_the_same_run_is_no_check_after_its_install()
{
// IME checks every app of the dependency chain in the run after an install; only the
// installed app carries the markers.
var run = new Run();
using (run.Harness)
{
var dependency = new AppPackageState(DependencyId, 1);
run.Harness.Tracker.PackageStates.Add(dependency);
run.Lines(CleanInstallThen(
DetectionResult("Detected", DependencyId),
DetectionResult("Detected")));
await run.Harness.Pass();
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
Assert.Equal("Detected", dependency.DetectionResult);
}
}
[Fact]
public async Task The_check_before_a_requested_restart_is_not_reported_the_one_after_it_is()
{
var run = new Run();
using (run.Harness)
{
var beforeRestart = new List<string>
{
PhaseLine,
CurrentAppLine,
"[Win32App] lpExitCode 3010",
"[Win32App] lpExitCode is defined as SoftReboot",
ExecutionDelta("InProgress", "InProgressPendingReboot"),
};
beforeRestart.AddRange(CheckAfterInstallMarkers());
beforeRestart.Add(DetectionResult("NotDetected")); // provisional: IME sets no error
run.Lines(beforeRestart.ToArray());
await run.Harness.Pass();
Assert.Empty(run.ChecksAfterInstall);
run.Lines(
ExecutionDelta("InProgressPendingReboot", "Success"), // after the restart
DetectionResult("Detected"));
await run.Harness.Pass();
Assert.Equal(new[] { ("Detected", true) }, run.ChecksAfterInstall);
}
}
[Fact]
public async Task Lines_from_a_previous_enrollment_mark_nothing()
{
var run = new Run();
using (run.Harness)
{
run.Harness.AppendAt(run.Harness.Now.AddDays(-2), CleanInstallThen(DetectionResult("Detected")));
await run.Harness.Pass();
Assert.Empty(run.ChecksAfterInstall);
Assert.Null(run.Package.DetectionResult);
}
}
[Fact]
public async Task Other_IME_error_reports_carry_their_code_too()
{
// A download that fails is the app's first move to Error through the same line.
var run = new Run();
using (run.Harness)
{
run.Lines(PhaseLine, ExecutionDelta("InProgressDownloadCompleted", "Error", "-2145091577"));
await run.Harness.Pass();
Assert.Equal(AppInstallationState.Error, Assert.Single(run.Transitions).To);
Assert.Equal("-2145091577", run.Package.ErrorCode);
}
}
[Fact]
public async Task Error_report_without_a_code_leaves_the_code_empty()
{
var run = new Run();
using (run.Harness)
{
run.Lines(PhaseLine, ExecutionDelta("InProgress", "Error"));
await run.Harness.Pass();
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
Assert.True(string.IsNullOrEmpty(run.Package.ErrorCode));
}
}
}
}
@@ -1,15 +1,10 @@
#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
@@ -70,82 +65,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
// ---------------------------------------------------------------- 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();
using var h = new ShippedImePackHarness();
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
h.Append(
PhaseLine, // activates the currentPhase pack (IME-EXITCODE, IME-EXITCODE-CLASS)
@@ -165,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
[Fact]
public async Task Retry_loop_that_ends_in_success_reports_the_last_class()
{
using var h = new Harness();
using var h = new ShippedImePackHarness();
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
h.Append(
PhaseLine, CurrentAppLine,
@@ -188,7 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
// 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();
using var h = new ShippedImePackHarness();
var pkg = new AppPackageState(AppId, 0);
pkg.UpdateState(AppInstallationState.Installing);
h.Tracker.PackageStates.Add(pkg);
@@ -207,7 +133,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
[Fact]
public async Task Default_branch_and_raw_numeric_types_leave_the_class_unset()
{
using var h = new Harness();
using var h = new ShippedImePackHarness();
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
h.Append(
PhaseLine, CurrentAppLine,
@@ -228,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
// 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();
using var h = new ShippedImePackHarness();
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
h.Append(
PhaseLine, CurrentAppLine,
@@ -0,0 +1,175 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
public sealed class ConfigMgrClientScoreTests
{
[Fact]
public void Nothing_found_scores_zero_and_is_not_detected()
{
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
}
[Fact]
public void A_registered_running_client_scores_100()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
Assert.Equal(100, snapshot.ConfidenceScore);
}
[Fact]
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
{
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
Assert.True(snapshot.Detected);
Assert.Equal(10, snapshot.ConfidenceScore);
}
[Fact]
public void A_stopped_service_counts_without_the_running_bonus()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
Assert.Equal(80, snapshot.ConfidenceScore);
}
}
/// <summary>
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
/// </summary>
[Collection("SerialThreading")]
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
return (sut, sink);
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == "configmgr_client_detected").ToList();
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
[Fact]
public void No_client_never_emits()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Empty(Detected(sink));
}
[Fact]
public void First_detection_emits_the_v1_payload()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
var (sut, sink) = BuildDetector();
sut.Check();
var evt = Assert.Single(Detected(sink));
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(true, data["ccmRegistryFound"]);
Assert.Equal(true, data["ccmDirectoryFound"]);
Assert.Equal("Running", data["ccmServiceState"]);
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
Assert.Equal("ABC", data["siteCode"]);
Assert.Equal(100, data["confidenceScore"]);
Assert.Equal("startup", data["detectedAt"]);
}
[Fact]
public void An_unchanged_state_emits_once_a_changed_state_again()
{
var current = Client("Stopped", siteCode: null);
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Single(Detected(sink));
current = Client("Running");
sut.Check();
var events = Detected(sink);
Assert.Equal(2, events.Count);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
Assert.Equal("Running", latest["ccmServiceState"]);
Assert.Equal("poll", latest["detectedAt"]);
}
[Fact]
public void A_restart_into_the_same_state_stays_silent()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var current = Client("Running");
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run1.Check();
Assert.Single(Detected(sink1));
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run2.Check();
Assert.Empty(Detected(sink2));
current = Client("Stopped");
run2.Check();
Assert.Single(Detected(sink2));
}
[Fact]
public void Emissions_are_capped_per_run()
{
var running = true;
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
var (sut, sink) = BuildDetector();
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
{
sut.Check();
running = !running;
}
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
}
}
}
@@ -185,6 +185,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
}
// ------------------------------------------------- phase gate for DevicePreparation providers
[Theory]
[InlineData("device_preparation", true)]
[InlineData("device_preparation_failed", true)] // a failed device preparation keeps its culprit
[InlineData("none", true)] // unknown phase → keep observing
[InlineData("unavailable", true)]
[InlineData("", true)]
[InlineData("device_preparation_done", false)]
[InlineData("device_setup", false)]
[InlineData("device_setup_failed", false)]
[InlineData("account_setup", false)]
[InlineData("esp_done", false)]
public void Device_preparation_counts_until_it_is_done(string phase, bool expected)
=> Assert.Equal(expected, EspPhaseSnapshot.DevicePreparationOpen(phase));
[Fact]
public void DevicePreparation_provider_past_its_phase_drops_out_while_setupApps_still_counts()
{
// Field shape (client installed as a Win32 app): the client registers its
// DevicePreparation provider after its own install with InstallationState=1, long
// after device preparation ended. Nothing ever completes it.
var snapshot = Snapshot(
DevicePrep("ConfigMgr", installState: 1),
SetupApps("Contoso", tracking: 0));
var seen = new Dictionary<string, DateTime>
{
["devicePreparation|device|ConfigMgr"] = T0,
["setupApps|device|Contoso"] = T0,
};
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(30), Dwell,
devicePreparationOpen: false);
Assert.False(eval.UpdatedFirstSeenUtc.ContainsKey("devicePreparation|device|ConfigMgr")); // reset
var stalled = Assert.Single(eval.NewlyStalled);
Assert.Equal("Contoso", stalled.Provider.Name);
}
// ------------------------------------------------- arm 2: sidecar-missing transition
[Fact]
@@ -265,17 +305,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
public void Dispose() => _tmp.Dispose();
// The phase defaults to "none" so the tests never read the test machine's own ESP state.
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
BuildDetector(StartupEventGate? gate = null)
BuildDetector(StartupEventGate? gate = null, Func<string>? phase = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var post = new InformationalEventPost(sink, clock);
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate,
espPhaseReader: phase ?? (() => EspPhaseSnapshot.None));
return (sut, sink, clock);
}
private static readonly TimeSpan DwellSpan = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
private static PolicyProviderState SidecarDone() =>
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null);
private static PolicyProviderState ConfigMgrDevicePrep(int? installationState) =>
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, installationState);
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
@@ -435,6 +485,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
}
}
[Fact]
public void Provider_registered_after_device_preparation_never_emits()
{
// The ConfigMgr client as a Win32 app: its DevicePreparation provider shows up during
// device setup with InstallationState=1 and stays there.
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => "device_setup");
sut.Tick();
clock.Advance(TimeSpan.FromHours(2));
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
[Fact]
public void A_real_device_preparation_hang_still_emits()
{
// Field shape: only ConfigMgr providers registered, the ESP never left device
// preparation and failed with 0x800705b4.
var snapshot = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
ConfigMgrDevicePrep(1),
});
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
Assert.Contains(stalled, s => (string)s["kind"] == EspPolicyProviderProbe.KindDevicePreparation);
}
[Fact]
public void Device_preparation_stall_message_names_the_provider_timeout()
{
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
var message = Assert.Single(StalledEvents(sink)).Payload![SignalPayloadKeys.Message];
Assert.Contains("until their timeout", message);
Assert.DoesNotContain("without any timeout", message);
}
[Fact]
public void Fingerprint_skips_device_preparation_providers_past_their_phase()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var phase = EspPhaseSnapshot.DevicePreparation;
var devicePrepOnly = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
var withContoso = new EspPolicyProviderSnapshot(new[]
{
SidecarDone(),
ConfigMgrDevicePrep(1),
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
});
var current = devicePrepOnly;
// Run 1: the DevicePreparation provider stalls during device preparation; later, in
// device setup, a Setup/Apps provider stalls as well.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => current))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => phase);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
phase = "device_setup";
current = withContoso;
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
Assert.Equal(2, StalledEvents(sink).Count);
}
// Run 2 (restart in device setup): the same Contoso stall lands on the same
// fingerprint only if the stale DevicePreparation provider stayed out of it.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => withContoso))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => "device_setup");
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
}
[Fact]
public void Healthy_or_absent_tracking_never_emits()
{
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
/// virtual clock, no network metrics, no telemetry spool.
/// </summary>
private CollectorSurfaces CreateSurfaces()
private CollectorSurfaces CreateSurfaces(AnalyzerConfiguration? analyzers = null)
{
var factory = new DefaultComponentFactory(
agentConfig: new AgentConfiguration
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
remoteConfig: new AgentConfigResponse
{
Collectors = CollectorConfiguration.CreateDefault(),
Analyzers = analyzers ?? new AnalyzerConfiguration(),
},
networkMetrics: null,
agentVersion: "test",
@@ -142,6 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
}
[Theory]
[InlineData(false, 0)]
[InlineData(true, 1)]
public void ConfigMgr_client_host_exists_only_when_the_tenant_enables_the_watcher(bool enabled, int expected)
{
// Opt-in (D-382): with the watcher off the check must not run at all, not merely stay quiet.
var hosts = CreateSurfaces(new AnalyzerConfiguration { EnableConfigMgrWatcher = enabled }).Hosts;
try
{
Assert.Equal(expected, hosts.Count(h => h is ConfigMgrClientHost));
}
finally
{
foreach (var host in hosts)
{
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
}
}
}
[Fact]
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
{
@@ -92,6 +92,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
}
[Fact]
public void Stale_post_install_detection_is_suppressed_fresh_emits()
{
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-check", 0);
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddDays(-7));
Assert.Empty(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddMinutes(-1));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
}
[Fact]
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
{
@@ -663,6 +663,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal("00:01:45.159", info.Payload["doDownloadDuration"]);
}
[Fact]
public void PostInstallDetection_emits_the_check_after_the_install_as_its_own_info_event()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = AppPackageState.Restore(
"0e3be4ce-790b-4081-831c-d72d92f3cc9b", 0, "Contoso VPN",
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
new HashSet<string>(),
AppInstallationState.Installed, true, 100, 0, 0,
exitCode: "0", exitCodeClass: "Success");
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, f.Clock.UtcNow.AddSeconds(-5));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
Assert.Equal(EventSeverity.Info.ToString(), info.Payload[SignalPayloadKeys.Severity]);
Assert.Equal(app.Id, info.Payload["appId"]);
Assert.Equal("Contoso VPN", info.Payload["appName"]);
Assert.Equal("Detected", info.Payload["postInstallDetection"]);
Assert.Equal("false", info.Payload["afterReboot"]);
Assert.Equal("Success", info.Payload["exitCodeClass"]);
// detectionResult is the check before the install; this event never carries it.
Assert.False(info.Payload.ContainsKey("detectionResult"));
// Informational only: no app state signal.
Assert.Empty(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
}
[Fact]
public void PostInstallDetection_not_detected_is_a_warning_and_the_check_after_a_restart_says_so()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("8a1c2f6e-5b7d-4e93-9c4a-0f2d6b8e1a37", 0);
adapter.TriggerPostInstallDetectionFromTest(app, "NotDetected", afterReboot: true, f.Clock.UtcNow.AddSeconds(-5));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
Assert.Equal(EventSeverity.Warning.ToString(), info.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("NotDetected", info.Payload["postInstallDetection"]);
Assert.Equal("true", info.Payload["afterReboot"]);
Assert.Contains("after the restart", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void DoTelemetry_null_app_or_empty_id_is_ignored()
{
@@ -114,7 +114,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public string AppType { get; private set; }
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
public int AttemptNumber { get; private set; }
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
/// <summary>
/// IME's detection check outside the check right after an install: Detected, NotDetected or
/// DetectedDifferentVersion — for an app being installed, the check before the install. The
/// check after the install is reported through OnPostInstallDetection instead (D-392).
/// </summary>
public string DetectionResult { get; private set; }
// Delivery Optimization telemetry (populated from [DO TEL] log entries OR Get-DeliveryOptimizationStatus poll)
@@ -117,6 +117,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>
/// Result of IME's check right after an install (D-392). After an exit code that asked for a
/// restart that check decides nothing: IME sets no error then and checks again after the
/// restart (IME-POST-REBOOT-DETECTION marks that check), so it is not reported.
/// </summary>
private void HandlePostInstallDetection(string appId, string detection, bool afterReboot)
{
var pkg = _packageStates.GetPackage(appId);
if (pkg == null) return;
if (!afterReboot
&& (string.Equals(pkg.ExitCodeClass, "SoftReboot", StringComparison.OrdinalIgnoreCase)
|| string.Equals(pkg.ExitCodeClass, "HardReboot", StringComparison.OrdinalIgnoreCase)))
{
_logger.Debug($"ImeLogTracker: check after the install of {pkg.Name ?? appId} precedes the requested restart ({detection}), IME decides after it");
return;
}
_logger.Info($"ImeLogTracker: check after the install of {pkg.Name ?? appId}: {detection}{(afterReboot ? " (after the restart)" : string.Empty)}");
try { OnPostInstallDetection?.Invoke(pkg, detection, afterReboot, LastMatchedLogTimestamp); }
catch (Exception ex) { _logger.Warning($"OnPostInstallDetection handler threw: {ex.Message}"); }
}
private void HandleDoTelemetry(string json)
{
try
@@ -516,7 +516,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
"updatestateskipped", "updatestateerror", "updatestatepostponed",
"captureexitcode", "capturehresult", "captureexitcodeclass", "captureappversion",
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
"capturedetectionresult", "markpostinstalldetection", "esptrackstatus", "policiesdiscovered",
"ignorecompletedapp", "updatename", "updatewin32appstate",
"cancelstuckandsetcurrent", "updatedotelemetry",
// Not app-mutating, but a stale token-failure replayed from a previous
@@ -790,10 +790,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
break;
case "markpostinstalldetection":
// The check right after an install starts (D-392); its result line follows.
if (!string.IsNullOrEmpty(id))
_postInstallCheckPending[id] = pattern.Parameters.ContainsKey("afterReboot")
&& pattern.Parameters["afterReboot"] == "true";
break;
case "capturedetectionresult":
var detectionVal = match.Groups["detection"]?.Value;
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal))
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(detectionVal))
break;
bool afterReboot;
if (_postInstallCheckPending.TryGetValue(id, out afterReboot))
{
// detectionResult stays the check before the install.
_postInstallCheckPending.Remove(id);
HandlePostInstallDetection(id, detectionVal, afterReboot);
}
else
{
_packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal);
}
break;
case "updatestatepostponed":
@@ -50,6 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates.
private readonly HashSet<string> _seenAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// IME's check right after an install (D-392): app id → "after the restart the installer
// requested". Set by IME-POST-INSTALL-DETECTION / IME-POST-REBOOT-DETECTION, consumed by
// the app's next detection result line, which IME always logs last in that check.
// Transient on purpose: a marker whose result line was never read must not survive an
// agent restart and turn a later ordinary check into a post-install result.
private readonly Dictionary<string, bool> _postInstallCheckPending = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
// Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup
// re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase).
private static readonly Dictionary<string, int> EspPhaseOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
@@ -513,6 +520,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
/// <summary>
/// IME's check right after an install found its result (D-392): the package, the detection
/// state (Detected, NotDetected, DetectedDifferentVersion), whether the check followed the
/// restart the installer requested, and the result line's timestamp. Not raised for the
/// check before a requested restart: IME decides only after it.
/// </summary>
public Action<AppPackageState, string, bool, DateTime?> OnPostInstallDetection { get; set; }
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
/// <summary>
@@ -0,0 +1,105 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
/// with the V1 payload when the client is first seen, and again when what it reports changes
/// (the service starts, a site code is assigned) — the client often installs after the agent
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
/// </summary>
internal sealed class ConfigMgrClientDetector
{
internal const string SourceLabel = "ConfigMgrClientDetector";
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
internal const int MaxEmissionsPerRun = 10;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly IClock _clock;
private readonly StartupEventGate? _startupGate;
private string? _lastFingerprint;
private int _emissions;
private bool _checkedOnce;
public ConfigMgrClientDetector(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
IClock clock,
StartupEventGate? startupGate = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger;
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_startupGate = startupGate;
}
/// <summary>Host timer callback and test seam — one probe round.</summary>
internal void Check()
{
var trigger = _checkedOnce ? "poll" : "startup";
_checkedOnce = true;
var snapshot = ConfigMgrClientProbe.Read(_logger);
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
return;
_lastFingerprint = snapshot.Fingerprint;
if (_startupGate != null && !_startupGate.ShouldEmit(
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
{
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
return;
}
_emissions++;
var score = snapshot.ConfidenceScore;
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = _clock.UtcNow,
EventType = Constants.EventTypes.ConfigMgrClientDetected,
Severity = EventSeverity.Info,
Source = SourceLabel,
Phase = EnrollmentPhase.Unknown,
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
Data = new Dictionary<string, object>
{
["ccmRegistryFound"] = snapshot.RegistryFound,
["ccmDirectoryFound"] = snapshot.DirectoryFound,
["ccmServiceState"] = snapshot.ServiceState,
["ccmVersion"] = snapshot.Version ?? "unknown",
["siteCode"] = snapshot.SiteCode ?? "unknown",
["confidenceScore"] = score,
["detectedAt"] = trigger,
}
});
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
_logger?.Info(
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
}
}
}
@@ -0,0 +1,146 @@
#nullable enable
using System;
using System.IO;
using System.ServiceProcess;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
/// exists, so devices without the client never throw on every poll.
/// </summary>
internal static class ConfigMgrClientProbe
{
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
internal const string CcmExecServiceName = "CcmExec";
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
internal const string ServiceNotFound = "not_found";
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
{
var probe = TestOverride;
if (probe != null) return probe();
try
{
// The agent is AnyCPU; the client writes the 64-bit view.
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
bool registryFound;
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
registryFound = ccm != null;
string? version;
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
string? siteCode;
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
bool serviceInstalled;
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
serviceInstalled = service != null;
var directoryFound = Directory.Exists(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
return new ConfigMgrClientSnapshot(
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
}
catch (Exception ex)
{
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
return ConfigMgrClientSnapshot.Empty;
}
}
private static string ReadServiceState()
{
try
{
using (var controller = new ServiceController(CcmExecServiceName))
return controller.Status.ToString();
}
catch (InvalidOperationException)
{
return ServiceNotFound; // removed between the key read and the query
}
}
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
internal sealed class ScopedOverride : IDisposable
{
private readonly Func<ConfigMgrClientSnapshot>? _previous;
private int _disposed;
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
{
_previous = TestOverride;
TestOverride = probe;
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
TestOverride = _previous;
}
}
}
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
internal sealed class ConfigMgrClientSnapshot
{
public static readonly ConfigMgrClientSnapshot Empty =
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
{
RegistryFound = registryFound;
DirectoryFound = directoryFound;
ServiceState = serviceState;
Version = version;
SiteCode = siteCode;
}
public bool RegistryFound { get; }
public bool DirectoryFound { get; }
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
public string ServiceState { get; }
public string? Version { get; }
public string? SiteCode { get; }
/// <summary>
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
/// detection from 50 on.
/// </summary>
public int ConfidenceScore =>
(DirectoryFound ? 10 : 0)
+ (RegistryFound ? 20 : 0)
+ (Version != null ? 15 : 0)
+ (SiteCode != null ? 15 : 0)
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
public bool Detected => ConfidenceScore > 0;
/// <summary>Everything the event reports; a change re-emits.</summary>
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
}
}
@@ -20,13 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
internal const string None = "none";
internal const string Unavailable = "unavailable";
internal const string DevicePreparation = "device_preparation";
private const string FailedSuffix = "_failed";
internal enum CategoryState { Absent, Running, Done, Failed }
// ESP order: device preparation, device setup, account setup.
private static readonly (string ValueName, string Phase)[] Categories =
{
("DevicePreparationCategory.Status", "device_preparation"),
("DevicePreparationCategory.Status", DevicePreparation),
("DeviceSetupCategory.Status", "device_setup"),
("AccountSetupCategory.Status", "account_setup"),
};
@@ -60,7 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
if (states[i] == CategoryState.Failed) return Categories[i].Phase + FailedSuffix;
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Running) return Categories[i].Phase;
@@ -69,6 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
}
/// <summary>
/// Whether device preparation can still hold the ESP in a <see cref="Read"/> result: it
/// runs or failed, or the phase is unknown (<see cref="None"/>, <see cref="Unavailable"/>).
/// False once device preparation is done, whichever later phase the ESP is in.
/// </summary>
internal static bool DevicePreparationOpen(string phase) =>
string.IsNullOrEmpty(phase)
|| phase == None
|| phase == Unavailable
|| phase == DevicePreparation
|| phase == DevicePreparation + FailedSuffix;
/// <summary>
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// dispatch-guard exempt), never into decision state.
/// </para>
/// <para>
/// Phase gate: <c>DevicePreparation</c> providers count only while
/// <see cref="EspPhaseSnapshot.DevicePreparationOpen"/> holds. Once device preparation is done
/// their values hold nothing: a ConfigMgr client installed as a Win32 app registers its
/// provider after its own install with <c>InstallationState=1</c>, and nothing completes it.
/// </para>
/// <para>
/// Duplicate suppression: in-process one-shot per provider key, plus a
/// <see cref="StartupEventGate"/> fingerprint over the sorted set of currently-stalled keys so
/// an agent restart into the same stall does not re-report it — while a NEW provider joining
@@ -75,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly IClock _clock;
private readonly StartupEventGate? _startupGate;
private readonly TimeSpan _dwell;
private readonly Func<string> _espPhaseReader;
private readonly Dictionary<string, DateTime> _firstSeenIncompleteUtc =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
@@ -94,7 +101,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
AgentLogger logger,
IClock clock,
StartupEventGate? startupGate = null,
TimeSpan? dwell = null)
TimeSpan? dwell = null,
Func<string>? espPhaseReader = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
@@ -103,6 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_startupGate = startupGate;
_dwell = dwell ?? TimeSpan.FromMinutes(DwellMinutes);
_espPhaseReader = espPhaseReader ?? EspPhaseSnapshot.Read;
}
/// <summary>Host timer callback and test seam — one probe + evaluation round.</summary>
@@ -111,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
var snapshot = EspPolicyProviderProbe.Read(_logger);
var eval = EvaluateStallTransition(
snapshot, _firstSeenIncompleteUtc, _firedKeys,
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell,
EspPhaseSnapshot.DevicePreparationOpen(_espPhaseReader()));
_firstSeenIncompleteUtc.Clear();
foreach (var kv in eval.UpdatedFirstSeenUtc)
@@ -133,6 +143,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
/// <param name="nowUtc">Evaluation timestamp.</param>
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
/// <param name="devicePreparationOpen">Whether device preparation can still hold the ESP (<see cref="EspPhaseSnapshot.DevicePreparationOpen"/>); false drops <c>DevicePreparation</c> providers from arm 1.</param>
internal static StallEvaluation EvaluateStallTransition(
EspPolicyProviderSnapshot snapshot,
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
@@ -140,9 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
DateTime? sidecarMissingSinceUtc,
bool sidecarMissingAlreadyFired,
DateTime nowUtc,
TimeSpan dwell)
TimeSpan dwell,
bool devicePreparationOpen = true)
{
var result = new StallEvaluation();
var result = new StallEvaluation { DevicePreparationOpen = devicePreparationOpen };
// Missing root key or no registered providers = normal early enrollment: clear all
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
@@ -152,8 +164,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Arm 1 — a registered provider continuously incomplete.
foreach (var provider in snapshot.Providers)
{
if (provider.IsComplete)
continue; // completed → falls out of the dwell map (reset)
if (provider.IsComplete || !result.Counts(provider))
continue; // completed or past its phase → falls out of the dwell map (reset)
var firstSeen = firstSeenIncompleteUtc.TryGetValue(provider.Key, out var seen)
? seen
@@ -197,7 +209,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
// new condition joining the stall changes it → re-emitted.
var currentlyStalledKeys = snapshot.Providers
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
.Where(p => !p.IsComplete && eval.Counts(p) && _firedKeys.Contains(p.Key))
.Select(p => p.Key)
.ToList();
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
@@ -259,9 +271,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (eval.SidecarMissingStalledForMinutes != null)
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
// The Setup/Apps wait has no timeout; device preparation gives up after the provider's
// Timeout node (CSP default 15 min).
var setupAppsWait = eval.SidecarMissingStalledForMinutes != null
|| eval.NewlyStalled.Any(s => s.Provider.Kind == EspPolicyProviderProbe.KindSetupApps);
var message =
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
" — the ESP waits on its app-tracking providers without any timeout";
"ESP policy provider stalled: " + string.Join("; ", messageParts) +
(setupAppsWait
? " — the ESP waits on its Setup/Apps providers without any timeout"
: " — device preparation waits on its providers until their timeout");
_post.Emit(new EnrollmentEvent
{
@@ -298,6 +316,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
public double? SidecarMissingStalledForMinutes { get; set; }
/// <summary>The phase gate of this round; the cross-restart fingerprint applies the same filter.</summary>
public bool DevicePreparationOpen { get; set; }
/// <summary>Whether arm 1 considers <paramref name="provider"/> in this round.</summary>
public bool Counts(PolicyProviderState provider)
=> DevicePreparationOpen || provider.Kind != EspPolicyProviderProbe.KindDevicePreparation;
}
internal readonly struct StalledProvider
@@ -517,6 +517,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
startupGate: _startupEventGate));
}
// ConfigMgr client presence (the V1 check) — opt-in per tenant (portal toggle →
// AnalyzerConfiguration.EnableConfigMgrWatcher, default off). When off, the host is
// never created, so devices of other tenants run no check at all.
if (analyzers.EnableConfigMgrWatcher)
{
hosts.Add(new ConfigMgrClientHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
startupGate: _startupEventGate));
}
else
{
logger.Info("DefaultComponentFactory: ConfigMgrClientHost not created (EnableConfigMgrWatcher=false)");
}
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
@@ -0,0 +1,91 @@
#nullable enable
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Drives the <see cref="ConfigMgrClientDetector"/>: a first check shortly after start (the
/// client may already be installed) and one per minute after that (it may arrive later, e.g.
/// as a Win32 app). Created only when the tenant enables the ConfigMgr watcher
/// (<c>AnalyzerConfiguration.EnableConfigMgrWatcher</c>).
/// </summary>
internal sealed class ConfigMgrClientHost : ICollectorHost
{
public string Name => "ConfigMgrClientDetector";
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
private readonly ConfigMgrClientDetector _detector;
private readonly AgentLogger _logger;
private Timer? _tickTimer;
private int _checking;
private int _disposed;
public ConfigMgrClientHost(
string sessionId,
string tenantId,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
StartupEventGate? startupGate)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger;
_detector = new ConfigMgrClientDetector(
sessionId: sessionId,
tenantId: tenantId,
post: new InformationalEventPost(ingress, clock),
logger: logger,
clock: clock,
startupGate: startupGate);
}
public void Start()
{
_tickTimer = new Timer(_ => SafeCheck(), state: null, dueTime: FirstCheckDelay, period: PollInterval);
_logger.Info($"ConfigMgrClientHost: started (check every {PollInterval.TotalSeconds}s).");
}
public void Stop()
{
try
{
_tickTimer?.Dispose();
_tickTimer = null;
}
catch (Exception ex) { _logger.Warning($"ConfigMgrClientHost: timer dispose failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
}
private void SafeCheck()
{
// A slow service query must not stack a second check on top of the first.
if (Interlocked.Exchange(ref _checking, 1) == 1) return;
try
{
_detector.Check();
}
catch (Exception ex)
{
_logger.Error("ConfigMgrClientHost: check failed.", ex);
}
finally
{
Interlocked.Exchange(ref _checking, 0);
}
}
}
}
@@ -40,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// "Completed user session" line fires per processing pass, see
/// <see cref="ImeLogTracker.GetPendingRequiredUserEspInstallApps"/>).</item>
/// <item><c>OnAppStateChanged(app, old, new)</c> → <see cref="DecisionSignalKind.AppInstallCompleted"/>
/// (state=Installed/Skipped/Postponed) oder <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
/// Dedup pro (AppId, terminal-state-tuple).</item>
/// (state=Installed/Skipped/Postponed) or <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
/// Once per app: the first terminal state wins. A later IME flip Installed → Error (0x87D1041C after
/// the check right after the install, D-392) still emits app_install_failed, but no second signal.</item>
/// </list>
/// </para>
/// <para>
@@ -81,6 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<string>? _prevOnPatternMatched;
private readonly Action<string>? _prevOnImeAgentVersion;
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
private readonly Action<AppPackageState, string, bool, DateTime?>? _prevOnPostInstallDetection;
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
private readonly Action<string, string>? _prevOnImeTokenFailure;
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<string> _ourOnPatternMatched;
private readonly Action<string> _ourOnImeAgentVersion;
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
private readonly Action<AppPackageState, string, bool, DateTime?> _ourOnPostInstallDetection;
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
private readonly Action<ScriptRecurrenceSummary>? _prevOnScriptRecurrenceSummary;
@@ -195,6 +198,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_prevOnPatternMatched = _tracker.OnPatternMatched;
_prevOnImeAgentVersion = _tracker.OnImeAgentVersion;
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
_prevOnPostInstallDetection = _tracker.OnPostInstallDetection;
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
_prevOnScriptStarted = _tracker.OnScriptStarted;
_prevOnScriptRecurrenceSummary = _tracker.OnScriptRecurrenceSummary;
@@ -210,6 +214,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_ourOnPatternMatched = OnPatternMatched;
_ourOnImeAgentVersion = OnImeAgentVersion;
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
_ourOnPostInstallDetection = OnPostInstallDetection;
_ourOnScriptCompleted = OnScriptCompleted;
_ourOnScriptStarted = OnScriptStarted;
_ourOnScriptRecurrenceSummary = OnScriptRecurrenceSummary;
@@ -221,6 +226,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnPatternMatched = _ourOnPatternMatched;
_tracker.OnImeAgentVersion = _ourOnImeAgentVersion;
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
_tracker.OnPostInstallDetection = _ourOnPostInstallDetection;
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
_tracker.OnScriptStarted = _ourOnScriptStarted;
_tracker.OnScriptRecurrenceSummary = _ourOnScriptRecurrenceSummary;
@@ -246,6 +252,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnImeAgentVersion = _prevOnImeAgentVersion;
if (ReferenceEquals(_tracker.OnDoTelemetryReceived, _ourOnDoTelemetryReceived))
_tracker.OnDoTelemetryReceived = _prevOnDoTelemetryReceived;
if (ReferenceEquals(_tracker.OnPostInstallDetection, _ourOnPostInstallDetection))
_tracker.OnPostInstallDetection = _prevOnPostInstallDetection;
if (ReferenceEquals(_tracker.OnScriptCompleted, _ourOnScriptCompleted))
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
@@ -292,6 +300,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitDoTelemetry(app, sourceTimestampUtc);
}
private void OnPostInstallDetection(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc)
{
_prevOnPostInstallDetection?.Invoke(app, detection, afterReboot, sourceTimestampUtc);
EmitPostInstallDetection(app, detection, afterReboot, sourceTimestampUtc);
}
private void OnScriptCompleted(ScriptExecutionState script)
{
_prevOnScriptCompleted?.Invoke(script);
@@ -368,6 +382,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
EmitDoTelemetry(app, sourceTimestampUtc);
internal void TriggerPostInstallDetectionFromTest(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc = null) =>
EmitPostInstallDetection(app, detection, afterReboot, sourceTimestampUtc);
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
internal void TriggerImeTokenFailureFromTest(string errorCode, string message) => EmitImeTokenFailure(errorCode, message);
@@ -1217,6 +1233,45 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
$"ImeAdapter: DO telemetry app='{app.Name ?? "?"}' total={app.DoFileSize} peers={app.DoBytesFromPeers} http={app.DoBytesFromHttp} mode={app.DoDownloadMode}");
}
/// <summary>
/// IME's check right after an install (D-392), or after the restart the installer requested.
/// Informational: completion and app state stay with the state events; a NotDetected here is
/// followed by IME's app_install_failed with 0x87D1041C.
/// </summary>
private void EmitPostInstallDetection(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc)
{
if (app == null || string.IsNullOrEmpty(app.Id) || string.IsNullOrEmpty(detection)) return;
var now = ResolveOccurredAt(sourceTimestampUtc, out var derivedFromClock, out var rawSourceTs);
// Historic replay — a check from a previous enrollment's log is not this install's.
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.AppPostInstallDetection, app.Id))
return;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = app.Id,
["appName"] = app.Name ?? string.Empty,
["postInstallDetection"] = detection,
["afterReboot"] = afterReboot ? "true" : "false",
};
if (!string.IsNullOrEmpty(app.AppVersion)) data["appVersion"] = app.AppVersion!;
if (!string.IsNullOrEmpty(app.AppType)) data["appType"] = app.AppType!;
if (app.AttemptNumber > 0) data["attemptNumber"] = app.AttemptNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (!string.IsNullOrEmpty(app.ExitCodeClass)) data["exitCodeClass"] = app.ExitCodeClass!;
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
var label = string.IsNullOrEmpty(app.Name) ? app.Id : app.Name;
_post.Emit(
eventType: SharedEventTypes.AppPostInstallDetection,
source: SourceLabel,
message: $"{label}: detection {(afterReboot ? "after the restart" : "after install")} - {detection}",
severity: string.Equals(detection, "NotDetected", StringComparison.OrdinalIgnoreCase) ? EventSeverity.Warning : EventSeverity.Info,
// One per install, part of the app's story: flush with the state events.
immediateUpload: true,
data: data,
occurredAtUtc: now);
}
private void EmitScriptCompleted(ScriptExecutionState script)
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
@@ -11,16 +11,27 @@
logic stays updatable without re-shipping the MSI, and both delivery channels share one
implementation.
The loader is installed under Program Files, where only administrators and SYSTEM can
create or change anything: the file that a SYSTEM custom action runs must not live in a
folder a standard user could create first (ProgramData lets every user create folders).
Everything of ours on a device lives under ProgramData. The loader copy goes to the agent
folder, which the installer owns: it removes the copy in every outcome. Any standard user
can create that folder first, and Windows Installer keeps a modified unversioned file, so
the custom action never runs the file at that path as such: it reads the file once, checks
that those bytes are the loader this package embeds (SHA-256 baked in at build time, so no
other script of ours, however validly signed, runs here) and carry our Authenticode
signature (the publisher pin the loader applies to the installer), and runs exactly those
bytes. PowerShell starts in System32, never in a folder users can write to. Windows
Installer formats the command: every square bracket and every brace of the PowerShell text
is written as [\[], [\]], [\{] or [\}], because a brace block that holds a bracket is a
conditional block to the formatter and loses its braces.
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
Build: wix build Package.wxs -arch x64 -d LoaderSha256=<lower-case SHA-256 of the loader being embedded> -o bin\AutopilotMonitor-Bootstrap.msi
-->
<?ifndef var.LoaderSha256 ?>
<?error LoaderSha256 is required: the SHA-256 of the loader this package embeds, lower-case (wix build -d LoaderSha256=...). ?>
<?endif ?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Autopilot Monitor Bootstrap"
Manufacturer="Autopilot Monitor"
Version="1.3.0"
Version="1.4.0"
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
Scope="perMachine"
Compressed="yes">
@@ -28,16 +39,16 @@
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
<MediaTemplate EmbedCab="yes" />
<StandardDirectory Id="ProgramFiles64Folder">
<StandardDirectory Id="CommonAppDataFolder">
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
<Directory Id="INSTALLFOLDER" Name="Bootstrap" />
<Directory Id="INSTALLFOLDER" Name="Msi" />
</Directory>
</StandardDirectory>
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
<!-- Source is relative to the build working directory (the project dir), not to this
file. New component GUID because the key path changed (1.3.0: Program Files). -->
<Component Id="RunnerScript" Guid="3C6F0E2D-8B41-4A77-9D5E-2F1B6C8A4D90">
file. New component GUID because the key path changed (1.4.0: back to ProgramData). -->
<Component Id="RunnerScript" Guid="5E8D1B6A-2C47-4F93-A1D0-7B9E3C5F8A21">
<File Id="RunnerPs1"
Source="..\..\..\scripts\Bootstrap\Start-AutopilotMonitor.ps1"
Name="Start-AutopilotMonitor.ps1"
@@ -49,13 +60,14 @@
<ComponentGroupRef Id="MainComponents" />
</Feature>
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
the LOB install green even if the loader hits a transient error (the loader is
additionally fail-soft itself). -LogFileName keeps this channel distinguishable in
a diagnostics package; the diag tooling keys on that name. -->
<!-- Deferred + no-impersonate: runs as SYSTEM after the file lands. Return=ignore keeps
the LOB install green whatever happens (the loader is fail-soft itself). Runs on
every install of the product, upgrades included, so the installer gets to remove the
copy. -LogFileName keeps this channel distinguishable in a diagnostics package; the
diag tooling keys on that name. -->
<CustomAction Id="RunBootstrap"
Directory="INSTALLFOLDER"
ExeCommand="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -ExecutionPolicy Bypass -File &quot;[INSTALLFOLDER]Start-AutopilotMonitor.ps1&quot; -LogFileName bootstrap-msi.log"
Directory="System64Folder"
ExeCommand="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command &quot;$b = [\[]System.IO.File[\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1'); $h = [\[]System.BitConverter[\]]::ToString([\[]System.Security.Cryptography.SHA256[\]]::Create().ComputeHash($b)).Replace('-', '').ToLowerInvariant(); $s = Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'; if ($h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O=glueckkanja AG*') [\{] &amp; ([\[]scriptblock[\]]::Create([\[]System.Text.Encoding[\]]::UTF8.GetString($b))) -LogFileName bootstrap-msi.log [\}]&quot;"
Execute="deferred"
Impersonate="no"
Return="ignore" />
@@ -72,10 +72,19 @@ public class AgentConfigResolverTests
Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer);
Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer);
Assert.False(r.Analyzers.EnableRealmJoinWatcher);
Assert.False(r.Analyzers.EnableConfigMgrWatcher);
Assert.False(r.Analyzers.KeepAwakeDuringUserEsp);
Assert.True(r.Analyzers.EnableConsoleBypassDetection);
}
[Fact]
public void The_tenant_switches_the_ConfigMgr_watcher_on()
{
var r = Build(Tenant(t => t.EnableConfigMgrWatcher = true));
Assert.True(r.Analyzers.EnableConfigMgrWatcher);
}
[Theory]
[InlineData(null, "CustomerSas", "Off", false)]
[InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Apps;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// "IME did not detect the app after its installer finished" is IME's error 0x87D1041C, never the
/// <c>detectionResult</c> field (IME's check before the install, D-392). The IME log writes the
/// code as the signed decimal -2016345060, the ESP path as hex: the stored failure code is
/// canonical so rankings and the apps banner see one code.
/// </summary>
public class AppDetectionFailureTests
{
private const string TenantId = "00000000-0000-0000-0000-000000000fa2";
[Theory]
[InlineData("0x87d1041c", true)]
[InlineData("0x87D1041C", true)]
[InlineData("-2016345060", true)]
[InlineData("esp_apps_detection_failure", true)]
[InlineData("ESP_APPS_DETECTION_FAILURE", true)]
[InlineData("0x87d1041d", false)] // the uninstall twin: still found after the removal
[InlineData("-2016345059", false)]
[InlineData("1603", false)]
[InlineData("esp_apps_timeout", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsDetectionFailure_recognises_every_form_of_0x87D1041C(string? failureCode, bool expected)
=> Assert.Equal(expected, Constants.AppFailureTypes.IsDetectionFailure(failureCode));
private static AppInstallSummary Failed(string? failureCode)
{
var evt = new EnrollmentEvent
{
EventType = "app_install_failed",
Timestamp = new DateTime(2026, 10, 29, 10, 0, 0, DateTimeKind.Utc),
Data = new Dictionary<string, object> { ["appName"] = "Contoso App", ["detectionResult"] = "NotDetected" },
};
if (failureCode != null) evt.Data["errorCode"] = failureCode;
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
EventIngestProcessor.AggregateAppInstallEvent(evt, TenantId, "session", summaries);
return summaries["Contoso App"].Summary;
}
[Theory]
[InlineData("-2016345060", "0x87d1041c")]
[InlineData("0x87D1041C", "0x87d1041c")]
[InlineData("-2145091577", "0x80248007")]
[InlineData("1603", "1603")] // an exit code stays an exit code
[InlineData(" 0x80070643 ", "0x80070643")]
public void Ingest_stores_the_failure_code_canonical(string sent, string stored)
=> Assert.Equal(stored, Failed(sent).FailureCode);
[Fact]
public void Ingest_keeps_the_failure_type_ahead_of_the_code()
{
var evt = new EnrollmentEvent
{
EventType = "app_install_failed",
Timestamp = new DateTime(2026, 10, 29, 10, 0, 0, DateTimeKind.Utc),
Data = new Dictionary<string, object>
{
["appName"] = "Contoso App",
["errorCode"] = "0x87d1041c",
["failureType"] = Constants.AppFailureTypes.EspAppsDetectionFailure,
},
};
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
EventIngestProcessor.AggregateAppInstallEvent(evt, TenantId, "session", summaries);
Assert.Equal(Constants.AppFailureTypes.EspAppsDetectionFailure, summaries["Contoso App"].Summary.FailureCode);
}
private static AppInstallSummary Row(string status, string failureCode = "", string sessionId = "s-1") => new()
{
TenantId = TenantId,
SessionId = sessionId,
AppName = "Contoso App",
Status = status,
FailureCode = failureCode,
StartedAt = DateTime.UtcNow.AddHours(-2),
DurationSeconds = 30,
};
private static async Task<JsonElement> AnalyticsAsync(List<AppInstallSummary> summaries)
{
var sessions = new Mock<ISessionRepository>();
sessions.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((SessionSummary?)null);
var response = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessions.Object, "Contoso App", AppInstallSources.Ime, days: 30);
return TestWire.SerializeToElement(response);
}
[Fact]
public async Task Banner_counts_failed_installs_IME_did_not_detect_afterwards()
{
var root = await AnalyticsAsync(new List<AppInstallSummary>
{
Row("Failed", "0x87d1041c", "s-1"),
Row("Failed", Constants.AppFailureTypes.EspAppsDetectionFailure, "s-2"),
Row("Failed", "0x80248007", "s-3"), // a download error
Row("Failed", "", "s-4"), // no code recorded
Row("Succeeded", "", "s-5"), // installed and detected
});
Assert.Equal(2, root.GetProperty("detectionFailureCount").GetInt32());
}
[Fact]
public async Task Top_failure_codes_rank_both_IME_forms_as_one_code()
{
var root = await AnalyticsAsync(new List<AppInstallSummary>
{
Row("Failed", Failed("-2016345060").FailureCode, "s-1"),
Row("Failed", Failed("0x87D1041C").FailureCode, "s-2"),
Row("Failed", Failed("1603").FailureCode, "s-3"),
});
var top = root.GetProperty("topFailureCodes")[0];
Assert.Equal("0x87d1041c", top.GetProperty("code").GetString());
Assert.Equal(2, top.GetProperty("count").GetInt32());
}
}
@@ -52,7 +52,6 @@ public class AppsProjectionEquivalenceTests
["FailureCode"] = "0x80070643",
["FailureMessage"] = "Fatal error during installation. " + new string('z', 400),
["ExitCode"] = 1603,
["DetectionResult"] = "NotDetected",
// DO telemetry block (dashboard-irrelevant, app-metrics-relevant).
["DoDownloadMode"] = 1,
["DoFileSize"] = 180_000_000L,
@@ -132,7 +131,6 @@ public class AppsProjectionEquivalenceTests
Assert.Equal(fromFull.FailureCode, fromProjected.FailureCode);
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
Assert.Equal(fromFull.AppId, fromProjected.AppId);
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
@@ -1,5 +1,6 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Tests.Offboarding;
using AutopilotMonitor.Shared;
@@ -60,6 +61,7 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
var bindings = new StubAdminIdentityBindingService(bound: true);
bindings.BoundTenants[RemovedAdminUpn] = RemovedAdminHomeTenant;
_globalAdminMock = new Mock<GlobalAdminService>(
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
{ CallBase = false };
@@ -124,7 +126,7 @@ public class AuthFunctionSideEffectTests
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
_signalR,
new LiveStreamRevoker(_signalR, bindings, Mock.Of<ILogger<LiveStreamRevoker>>()),
adminConfigService.Object,
_offboardingRepo,
pushSignIn: pushSignIn,
@@ -155,6 +157,9 @@ public class AuthFunctionSideEffectTests
// ── RemoveGlobalAdmin cuts live SignalR streams (join-time-only group authz) ──
private const string RemovedAdminUpn = "removed@contoso.com";
private const string RemovedAdminHomeTenant = "77777777-7777-7777-7777-777777777777";
[Fact]
public async Task RemoveGlobalAdmin_DisconnectsTheRemovedAdminsSignalRConnections()
{
@@ -163,7 +168,8 @@ public class AuthFunctionSideEffectTests
var response = await _sut.RemoveGlobalAdmin(req, "Removed@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "removed@contoso.com" }, _signalR.DisconnectedUsers);
// A platform role resolves only under the UPN's bound tenant: the cut names that tenant.
Assert.Equal(new[] { SignalRGroupHelper.UserId(RemovedAdminHomeTenant, RemovedAdminUpn) }, _signalR.DisconnectedUsers);
}
[Fact]
@@ -780,14 +786,14 @@ public class AuthFunctionSideEffectTests
public async Task PostDecision_NeedsAutoAdmin_CallsAddTenantAdmin()
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminRow());
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(true);
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
_tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
}
[Fact]
@@ -798,7 +804,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
@@ -818,14 +824,14 @@ public class AuthFunctionSideEffectTests
public async Task PostDecision_AutoAdminAndMetrics_BothExecute()
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminRow());
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(true);
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
_tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
_metricsRepoMock.Verify(
x => x.RecordUserLoginAsync(TenantId, Upn, DisplayName, ObjectId),
Times.Once);
@@ -849,7 +855,7 @@ public class AuthFunctionSideEffectTests
public async Task PostDecision_AutoAdminFailure_Throws()
{
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Setup(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("Table Storage down"));
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
@@ -859,6 +865,21 @@ public class AuthFunctionSideEffectTests
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
}
[Fact]
public async Task PostDecision_AutoAdminSeatAlreadyTaken_DoesNotThrow()
{
// The activation seated the same person a moment earlier (conditional insert lost): nothing to overwrite.
_tenantAdminsMock
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(false);
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
}
// -------------------------------------------------------------------------
// GetCurrentUser end to end: an external identity never claims the tenant
// -------------------------------------------------------------------------
@@ -929,19 +950,46 @@ public class AuthFunctionSideEffectTests
config.DomainName = "contoso.com";
ArrangeAuthMe(config, exists: true, approved: true);
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminRow());
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(true);
var (req, ctx) = BuildRequest(SignedIn(external));
var response = await _sut.GetCurrentUser(req, ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
external ? Times.Never() : Times.Once());
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
}
/// <summary>
/// The activation race: the tenant is activated and its partition is empty for a moment while an
/// ordinary member polls auth/me. The first-user seat belongs to the designated onboarding member
/// alone; everybody else is answered roleless and nothing is written.
/// </summary>
[Theory]
[InlineData("first.member@contoso.com", false)]
[InlineData("USER@CONTOSO.COM", true)]
public async Task GetCurrentUser_ActivatedTenantWithoutAdmins_OnlyTheDesignatedOnboardingMemberTakesTheSeat(
string onboardedBy, bool callerIsTheOnboardingMember)
{
var config = DefaultConfig();
config.DomainName = "contoso.com";
config.OnboardedBy = onboardedBy;
ArrangeAuthMe(config, exists: true, approved: true);
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System")).ReturnsAsync(true);
var (req, ctx) = BuildRequest(SignedIn(external: false));
var response = await _sut.GetCurrentUser(req, ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains(callerIsTheOnboardingMember ? "\"isTenantAdmin\":true" : "\"isTenantAdmin\":false", await ReadBodyAsync(response));
_tenantAdminsMock.Verify(
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
callerIsTheOnboardingMember ? Times.Once() : Times.Never());
}
/// <summary>
/// The sequence behind new tenants' first admins landing on the progress page, over the real role caches.
/// While the tenant waits for activation, this instance resolves the member's role (the feedback-status
@@ -987,6 +1035,8 @@ public class AuthFunctionSideEffectTests
// The promotion already wrote the row: no first-user auto-admin on top of it.
adminRepo.Verify(r => r.AddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
adminRepo.Verify(r => r.TryAddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>()), Times.Never);
}
// -------------------------------------------------------------------------
@@ -545,6 +545,83 @@ public class AuthFunctionTests
Assert.True((bool)body.isTenantAdmin);
}
private static TenantConfiguration ConfigOnboardedBy(string? onboardedBy)
{
var config = DefaultConfig();
config.OnboardedBy = onboardedBy;
return config;
}
[Fact]
public void AutoAdmin_DesignatedOnboardingMember_AnyOtherRolelessCallerStaysRoleless()
{
// The activation race: the tenant is activated, its partition is empty for a moment, and a colleague
// polls auth/me. The first-user seat belongs to the onboarding member alone.
var result = AuthFunction.BuildAuthResult(
ConfigOnboardedBy("first.member@contoso.com"), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.False((bool)body.isTenantAdmin);
Assert.Null((string?)body.role);
Assert.False((bool)body.canManageBootstrapTokens);
}
[Fact]
public void AutoAdmin_DesignatedOnboardingMember_TakesTheSeatWhateverTheCasing()
{
var result = AuthFunction.BuildAuthResult(
ConfigOnboardedBy(Upn.ToUpperInvariant()), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.True(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.True((bool)body.isTenantAdmin);
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
}
[Theory]
[InlineData("System (Global Rate Limit Sync)")]
[InlineData("alice_fabrikam.example#EXT#@contoso.example")]
public void AutoAdmin_RecordedOnboardingMemberActivationCannotSeat_StillBlocksEveryoneElse(string onboardedBy)
{
// A recorded member counts whatever its shape: nobody else takes the seat, and the holder of such a
// value is an external identity (or no member at all) and excluded on its own.
var result = AuthFunction.BuildAuthResult(
ConfigOnboardedBy(onboardedBy), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
Assert.False((bool)ToDynamic(result.Body).isTenantAdmin);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void AutoAdmin_WithoutARecordedOnboardingMember_TheFirstSignInStillBecomesAdmin(string? onboardedBy)
{
// Rows from before OnboardedBy existed.
var result = AuthFunction.BuildAuthResult(
ConfigOnboardedBy(onboardedBy), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.NeedsAutoAdmin);
Assert.True((bool)ToDynamic(result.Body).isTenantAdmin);
}
// -------------------------------------------------------------------------
// MCP Access
// -------------------------------------------------------------------------
@@ -179,7 +179,8 @@ public class BuiltInRulesTests
"healthScriptResult", "healthScriptDetectionResult",
"captureExitCode", "captureHResult",
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
"captureAttemptNumber", "captureDetectionResult", "captureExitCodeClass"
"captureAttemptNumber", "captureDetectionResult", "captureExitCodeClass",
"markPostInstallDetection"
};
var patterns = BuiltInImeLogPatterns.GetAll();
@@ -27,6 +27,12 @@ public class DelegatedAdminServiceTests
private const string TenantC = "33333333-3333-3333-3333-333333333333";
private static (DelegatedAdminService Svc, Mock<IAdminRepository> Repo) Build()
{
var (svc, repo, _) = BuildWithBindings();
return (svc, repo);
}
private static (DelegatedAdminService Svc, Mock<IAdminRepository> Repo, StubAdminIdentityBindingService Bindings) BuildWithBindings()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
@@ -38,14 +44,18 @@ public class DelegatedAdminServiceTests
// Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
// Binding stubbed to "bound" — these tests pin ROW resolution; the identity check has its own
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests).
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests). The binding
// read homes the assignees of these tests in HomeTenant, which owns the self-service group below.
var bindings = new StubAdminIdentityBindingService(bound: true);
bindings.BoundTenants[Upn] = HomeTenant;
bindings.BoundTenants[SelfServiceAppKey] = HomeTenant;
var svc = new DelegatedAdminService(
repo.Object,
new StubAdminIdentityBindingService(bound: true),
bindings,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
return (svc, repo, bindings);
}
/// <summary>The caller identity for a UPN: homed in <see cref="HomeTenant"/> with <see cref="Oid"/>.</summary>
@@ -667,10 +677,12 @@ public class DelegatedAdminServiceTests
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
}
private const string SelfServiceAppKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
[Fact]
public async Task GetScope_SelfServiceGroup_ApplicationPrincipal_IsGatedOnItsAppKeyRow()
{
const string appKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
const string appKey = SelfServiceAppKey;
var (svc, repo) = Build();
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(appKey)).ReturnsAsync(new List<TenantGroupAssignment>
{
@@ -686,10 +698,10 @@ public class DelegatedAdminServiceTests
}
[Fact]
public async Task GetScope_OperatorGroup_NeverReadsTenantMembership()
public async Task GetScope_OperatorGroup_NeverReadsTenantMembershipOrTheBinding()
{
// Operator groups (GUID ids) are granted by a Global Admin to people who are not members of any owner.
var (svc, repo) = Build();
var (svc, repo, bindings) = BuildWithBindings();
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
@@ -697,6 +709,62 @@ public class DelegatedAdminServiceTests
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
repo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
Assert.Equal(0, bindings.GetCalls);
}
// ── ...and only while its assignee is bound to the owner: a self-service grant resolves under the owner alone ──
[Theory]
[InlineData(TenantC)] // a Global Admin rebound the person to their real tenant (the squat chain)
[InlineData(null)] // no binding at all
public async Task GetScope_SelfServiceGroup_AssigneeNotBoundToTheOwner_ConfersNothing(string? boundTenant)
{
var (svc, repo, bindings) = BuildWithBindings();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
OwnerMember(repo, Upn, enabled: true);
if (boundTenant == null)
bindings.BoundTenants.Remove(Upn);
else
bindings.BoundTenants[Upn] = boundTenant;
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
repo.Verify(r => r.GetGroupMembershipAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task GetScope_ReboundSelfServiceAssignee_KeepsOperatorGroupAndDirectGrant()
{
var (svc, repo, bindings) = BuildWithBindings();
Returns(repo, Row(TenantC));
ReturnsGroups(repo, Assignment(SelfServiceGroup), Assignment(GroupId1));
GroupTenants(repo, SelfServiceGroup, TenantA);
GroupTenants(repo, GroupId1, TenantB);
OwnerMember(repo, Upn, enabled: true);
bindings.BoundTenants[Upn] = TenantC;
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Null(scope.RoleFor(TenantA));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantC));
}
[Fact]
public async Task GetFreshScope_ReadsTheBindingFresh_AfterARebind()
{
// The SignalR re-join after a rebind's cut must not be admitted by a row scope cached before it.
var (svc, repo, bindings) = BuildWithBindings();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
OwnerMember(repo, Upn, enabled: true);
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // warms the row cache
bindings.BoundTenants[Upn] = TenantC; // rebound elsewhere
Assert.True((await svc.GetFreshScopeAsync(Id(Upn))).IsEmpty);
}
[Fact]
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -40,6 +41,7 @@ public class DelegationSelfServiceTests
public required Mock<IDelegationInvitationRepository> Invitations { get; init; }
public required Mock<IMaintenanceRepository> Audit { get; init; }
public required FakeSignalRNotificationService SignalR { get; init; }
public required StubAdminIdentityBindingService Bindings { get; init; }
public required List<DelegationInvitation> Rows { get; init; }
public required List<string> GroupTenants { get; init; }
public required Mock<ProConferralService> ProConferral { get; init; }
@@ -110,7 +112,10 @@ public class DelegationSelfServiceTests
var entitlements = homeResolution is EditionResolution resolution
? new StubTenantEntitlementService(t => t == Home ? resolution : new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false), null)
: new StubTenantEntitlementService(t => t == Home ? homeEdition : TenantEdition.Community);
var delegatedAdmins = new DelegatedAdminService(repo.Object, new StubAdminIdentityBindingService(bound: true), entitlements, cache, NullLogger<DelegatedAdminService>.Instance);
// A self-service assignee is homed in the managing tenant (the assignment binds it there).
var bindings = new StubAdminIdentityBindingService(bound: true);
bindings.BoundTenants[MspUser] = Home;
var delegatedAdmins = new DelegatedAdminService(repo.Object, bindings, entitlements, cache, NullLogger<DelegatedAdminService>.Instance);
var slots = new DelegatedSlotService(repo.Object, configRepo.Object, invitations.Object, cache, NullLogger<DelegatedSlotService>.Instance, time);
var audit = new Mock<IMaintenanceRepository>();
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
@@ -124,9 +129,10 @@ public class DelegationSelfServiceTests
ManagedTenantProIndex.None,
NullLogger<ProConferralService>.Instance) { CallBase = false };
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
var streams = new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, streams,
proConferral.Object, NullLogger<DelegationSelfService>.Instance, time);
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Bindings = bindings, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
}
// ── Conferred Pro hooks ───────────────────────────────────────────────────────
@@ -444,7 +450,7 @@ public class DelegationSelfServiceTests
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "customer-removed-by-manager")), Times.Once);
VerifyAudit(h.Audit, Home, "DELETE", "DelegationManagedTenant", Times.Once());
Assert.Contains(MspUser, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
}
[Fact]
@@ -462,6 +468,8 @@ public class DelegationSelfServiceTests
Assert.Equal(Customer, hold.TenantId);
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, CustomerAdmin,
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "customer-revoked")), Times.Once);
// The customer's revoke cuts the assignees' streams under the managing tenant, where the grant resolved.
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
}
// ── Assignees ───────────────────────────────────────────────────────────────
@@ -610,7 +618,24 @@ public class DelegationSelfServiceTests
Assert.True(r.Ok);
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "self-service-unassigned")), Times.Once);
Assert.Equal(new[] { MspUser }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Unassign_OfAnAssigneeRehomedElsewhere_CutsOnlyUnderTheManagingTenant()
{
// The squat chain of the security review: the assignment bound the UPN here, a Global Admin later rebound it
// to its real tenant. The managing tenant's unassign must not reach the UPN's connections there.
var h = Build();
h.GroupTenants.Add(Customer);
AssignedToTheGroup(h);
h.Bindings.BoundTenants[MspUser] = Other;
var r = await h.Svc.UnassignAsync(Home, MspUser, MspAdmin);
Assert.True(r.Ok);
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
Assert.DoesNotContain(SignalRGroupHelper.UserId(Other, MspUser), h.SignalR.DisconnectedUsers);
}
// ── Managers (customer side) ────────────────────────────────────────────────
@@ -156,7 +156,6 @@ public class DiagnosisHeadlineBuilderTests
Assert.Equal("User", culprit.Targeted);
Assert.Equal("WinGet", culprit.AppType);
Assert.Equal("0x80d03805", culprit.ErrorCode);
Assert.Equal("NotDetected", culprit.DetectionResult);
Assert.True(culprit.EspBlocking);
// Phases in ESP order; the failed step carries the code.
@@ -236,12 +235,15 @@ public class DiagnosisHeadlineBuilderTests
}
[Fact]
public void A_detection_failure_without_an_app_code_matches_the_esp_not_detected_error()
public void IMEs_own_detection_failure_report_matches_the_esp_not_detected_error()
{
// The IME shape of a detection failure: no errorCode, hresultFromWin32 and exitCode "0".
// IME's report delta carries EnforcementErrorCode as the signed decimal of 0x87D1041C;
// hresultFromWin32 and exitCode stay "0".
var events = new Timeline()
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("errorCode", "1603")))
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("detectionResult", "NotDetected"), ("exitCode", "0"), ("hresultFromWin32", "0")))
.Add("app_install_completed", App(NotesId, "Contoso Notes", ("state", "Installed"), ("detectionResult", "NotDetected")))
.Add("app_post_install_detection", App(NotesId, "Contoso Notes", ("postInstallDetection", "NotDetected"), ("afterReboot", "false")))
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("errorCode", "-2016345060"), ("detectionResult", "NotDetected"), ("exitCode", "0"), ("hresultFromWin32", "0")))
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
@@ -250,9 +252,25 @@ public class DiagnosisHeadlineBuilderTests
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
var first = h.Culprits![0];
Assert.Equal(NotesId, first.Key);
Assert.Null(first.ErrorCode);
Assert.Equal("-2016345060", first.ErrorCode);
Assert.Equal("0", first.ExitCode);
Assert.Equal("NotDetected", first.DetectionResult);
Assert.Equal("NotDetected", first.PostInstallDetection);
}
[Fact]
public void The_check_before_the_install_does_not_match_the_esp_not_detected_error()
{
// detectionResult NotDetected is IME's check BEFORE the install: every failed app carries
// it. A download error must not be ranked as the ESP's detection failure (D-392).
var events = new Timeline()
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("errorCode", "-2145091577"), ("detectionResult", "NotDetected")))
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
// No app carries the ESP's code, so the latest failure leads.
Assert.Equal(new[] { PortalId, NotesId }, h.Culprits!.Select(c => c.Key));
}
[Fact]
@@ -365,6 +383,51 @@ public class DiagnosisHeadlineBuilderTests
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
}
[Fact]
public void A_device_preparation_provider_is_no_culprit_for_a_later_apps_failure()
{
// A ConfigMgr client installed as a Win32 app registers its device-preparation provider
// after device preparation ended; its stall explains no device-setup failure.
var events = new Timeline()
.Add("esp_policy_provider_stalled", new()
{
["reason"] = "provider_incomplete",
["stalledProviders"] = new List<object>
{
new Dictionary<string, object> { ["name"] = "ConfigMgr", ["scope"] = "device", ["kind"] = "devicePreparation", ["stalledForMinutes"] = 15.0 },
},
})
.Add("enrollment_failed", Failed("DeviceSetup", "Apps"));
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
Assert.Null(h.Culprits);
}
[Fact]
public void Only_device_preparation_providers_explain_a_device_preparation_failure()
{
// Field shape: the ESP never left device preparation (0x800705b4).
var events = new Timeline()
.Add("esp_policy_provider_stalled", new()
{
["reason"] = "sidecar_provider_missing",
["stalledProviders"] = new List<object>
{
new Dictionary<string, object> { ["name"] = "ConfigMgr", ["scope"] = "device", ["kind"] = "devicePreparation", ["stalledForMinutes"] = 40.0 },
new Dictionary<string, object> { ["name"] = "Contoso", ["scope"] = "device", ["kind"] = "setupApps", ["stalledForMinutes"] = 40.0 },
},
})
.Add("enrollment_failed", Failed("DevicePreparation", "EspProviderInstallation", "0x800705b4"));
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
var provider = Assert.Single(h.Culprits!);
Assert.Equal(DiagnosisCulpritKinds.PolicyProvider, provider.Kind);
Assert.Equal("ConfigMgr", provider.Name);
Assert.Equal(40.0, provider.StalledForMinutes);
}
[Fact]
public void The_appx_log_candidate_joins_the_culprits()
{
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
@@ -76,18 +77,21 @@ public class FakeSignalRNotificationService : ISignalRNotificationService
return Task.CompletedTask;
}
public List<string> NegotiatedUserIds { get; } = new();
/// <summary>The SignalR user id each negotiation was bound to, composed like the real service (null: no user id).</summary>
public List<string?> NegotiatedUserIds { get; } = new();
/// <summary>The SignalR user id each cut addressed, composed like the real service.</summary>
public List<string> DisconnectedUsers { get; } = new();
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey)
{
NegotiatedUserIds.Add(userId);
NegotiatedUserIds.Add(string.IsNullOrEmpty(principalKey) ? null : SignalRGroupHelper.UserId(tenantId, principalKey));
return Task.FromResult<(string Url, string AccessToken)?>(("https://fake.signalr.test/client", "fake-token"));
}
public Task DisconnectUserAsync(string userId)
public Task DisconnectUserAsync(string tenantId, string principalKey)
{
DisconnectedUsers.Add(userId);
DisconnectedUsers.Add(SignalRGroupHelper.UserId(tenantId, principalKey));
return Task.CompletedTask;
}
}
@@ -30,6 +30,8 @@ public sealed class FeedbackFunctionSubmitTests
private const string DisplayName = "Alice (Contoso)";
private const string Comment = "The timeline filter could remember my last choice.";
private static readonly DateTime Now = new(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
private const string FeedbackId = "0123456789ab";
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
[Fact]
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
@@ -57,10 +59,11 @@ public sealed class FeedbackFunctionSubmitTests
Assert.Equal(4, details.RootElement.GetProperty("rating").GetInt32());
Assert.Equal(Comment.Length, details.RootElement.GetProperty("commentLength").GetInt32());
Assert.Equal("contoso.invalid", details.RootElement.GetProperty("domainName").GetString());
Assert.Equal(Constants.PortalUserFeedbackPath, details.RootElement.GetProperty("portalPath").GetString());
Assert.Equal(FeedbackId, details.RootElement.GetProperty("feedbackId").GetString());
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", Constants.PortalUserFeedbackPath), Times.Once);
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
}
@@ -142,7 +145,7 @@ public sealed class FeedbackFunctionSubmitTests
using var details = JsonDocument.Parse(ops.Details!);
Assert.Equal(0, details.RootElement.GetProperty("commentLength").GetInt32());
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", Constants.PortalUserFeedbackPath), Times.Once);
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", EntryPath), Times.Once);
}
[Fact]
@@ -156,7 +159,7 @@ public sealed class FeedbackFunctionSubmitTests
Assert.Single(h.Repo.Written);
Assert.Empty(h.OpsEvents);
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", It.IsAny<string>(), Constants.PortalUserFeedbackPath), Times.Once);
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
}
@@ -247,7 +250,10 @@ public sealed class FeedbackFunctionSubmitTests
}
}
/// <summary>In-memory feedback repo holding one person's bubble row; only the in-app members are expected.</summary>
/// <summary>
/// In-memory feedback repo holding one person's bubble row; only the in-app members are expected. Like the table
/// repository it keeps the stored row's id and mints <see cref="FeedbackId"/> for a new row.
/// </summary>
private sealed class FakeFeedbackRepository : IFeedbackRepository
{
private FeedbackEntry? _current;
@@ -264,6 +270,7 @@ public sealed class FeedbackFunctionSubmitTests
if (next != null)
{
next.Type = FeedbackEntryType.InApp;
next.FeedbackId = _current?.FeedbackId ?? FeedbackId;
_current = next;
Written.Add(next);
}
@@ -110,7 +110,6 @@ public class GeoMetricsProjectionEquivalenceTests
// Noise the geo aggregation never reads.
["Status"] = "Succeeded",
["FailureMessage"] = new string('y', 500),
["DetectionResult"] = "Detected",
};
}
@@ -0,0 +1,315 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Counting rules of the growth figures: window membership on the UTC day, activation from the
/// counters, the first enrollment from the daily usage rows (pending while only today's counters
/// moved), activity from the usage rows plus today's counters, hour and weekday in the requested
/// time zone, no approval-time rows in the hours or peaks, and excluded tenants in no figure.
/// </summary>
public class GrowthMetricsBuilderTests
{
// Wednesday; Europe/Berlin switched from CEST to CET on Sunday 2026-10-25.
private static readonly DateTime Now = new(2026, 10, 28, 15, 0, 0, DateTimeKind.Utc);
private static readonly TimeZoneInfo Berlin = ResolveBerlin();
private static TimeZoneInfo ResolveBerlin()
{
Assert.True(GrowthMetricsBuilder.TryResolveTimeZone("Europe/Berlin", out var tz));
return tz;
}
private static readonly string[] NoExclusions = Array.Empty<string>();
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example", string plan = "free")
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt, PlanTier = plan };
private static TenantStats Stats(char id, long total, long succeeded, DateTime lastUpdated)
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, LastUpdated = lastUpdated };
private static TenantActivityDay Active(char id, int year, int month, int day)
=> new() { TenantId = Tid(id), Day = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc), SessionsTotal = 1 };
private static DateTime Utc(int month, int day, int hour, int minute = 0) => new(2026, month, day, hour, minute, 0, DateTimeKind.Utc);
private static GrowthMetricsResponse Scenario(bool includeTenants = false, params char[] excluded)
{
var configs = new List<TenantConfiguration>
{
Config('a', Utc(10, 22, 9), plan: "pro"), // enrolled the same day, still active this week
Config('b', Utc(10, 23, 13, 30)), // first sessions today: pending
Config('c', Utc(10, 24, 7)), // never enrolled
Config('d', Utc(10, 26, 8, 15)), // enrolled the next day, trial started
Config('e', Utc(9, 1, 12)), // older tenant, active 23 days ago
Config('f', Utc(10, 16, 12)), // previous window, enrolled, silent for 30 days
Config('g', Utc(10, 25, 12), domain: ""), // read of an unknown tenant id, not a signup
Config('h', Utc(10, 27, 23, 30)), // 00:30 on Wednesday in Berlin; never enrolled
};
configs[0].PayingCustomer = true;
configs[3].TrialStartedUtc = Utc(10, 26, 10);
configs[3].TrialExpiresUtc = Utc(11, 25, 10);
var stats = new List<TenantStats>
{
Stats('a', 3, 2, Utc(10, 27, 8)),
Stats('b', 1, 0, Utc(10, 28, 10)),
Stats('d', 2, 1, Utc(10, 27, 9)),
Stats('e', 10, 9, Utc(10, 5, 9)),
Stats('f', 5, 5, Utc(9, 1, 9)),
};
var activity = new List<TenantActivityDay>
{
Active('a', 2026, 10, 22),
Active('d', 2026, 10, 27),
Active('e', 2026, 10, 5),
};
return GrowthMetricsBuilder.Build(configs, stats, activity, Now, 7, Berlin, includeTenants, excluded.Select(Tid).ToList());
}
[Fact]
public void Window_is_the_last_n_utc_days_including_today()
{
var r = Scenario();
Assert.Equal("2026-10-22", r.WindowStart);
Assert.Equal("2026-10-28", r.WindowEnd);
Assert.Equal("2026-10-27", r.UsageDaysThrough);
Assert.Equal("Europe/Berlin", r.TimeZone);
Assert.Equal(0, r.ExcludedTenants);
Assert.Equal(5, r.Signups.Total);
Assert.Equal(1, r.Signups.PreviousWindowTotal);
Assert.Equal("day", r.Signups.BucketSize);
Assert.Equal(new[] { 1, 1, 1, 0, 1, 1, 0 }, r.Signups.Buckets.Select(b => b.Count));
Assert.Equal("2026-10-22", r.Signups.Buckets[0].Start);
Assert.Empty(r.Signups.PeakDays);
}
[Fact]
public void Hours_and_weekdays_follow_the_time_zone_across_the_clock_change()
{
var r = Scenario();
var expectedHours = new int[24];
expectedHours[0] = 1; // h: 23:30Z on 10-27 = 00:30 CET
expectedHours[9] = 2; // c: 07:00Z CEST, d: 08:15Z CET
expectedHours[11] = 1; // a: 09:00Z CEST
expectedHours[15] = 1; // b: 13:30Z CEST
Assert.Equal(expectedHours, r.Signups.ByHour);
// Monday first: d Mon, h Wed, a Thu, b Fri, c Sat.
Assert.Equal(new[] { 1, 0, 1, 1, 1, 1, 0 }, r.Signups.ByWeekday);
}
[Fact]
public void Activation_counts_the_first_day_with_sessions_and_holds_todays_first_enrollment_as_pending()
{
var a = Scenario().Activation;
Assert.Equal(3, a.Enrolled);
Assert.Equal(2, a.Succeeded);
Assert.Equal(2, a.NeverEnrolled);
Assert.Equal(1, a.FirstEnrollmentSameDay);
Assert.Equal(1, a.FirstEnrollmentNextDay);
Assert.Equal(0, a.FirstEnrollmentDays2To7);
Assert.Equal(0, a.FirstEnrollmentLater);
Assert.Equal(1, a.FirstEnrollmentPending);
Assert.Equal(0, a.FirstEnrollmentUnknown);
Assert.Equal(0.5, a.MedianDaysToFirstEnrollment);
}
[Fact]
public void Cohorts_are_monday_anchored_utc_weeks()
{
var cohorts = Scenario().Cohorts;
Assert.Equal(new[] { "2026-10-19", "2026-10-26" }, cohorts.Select(c => c.WeekStart));
Assert.Equal(new[] { 3, 2 }, cohorts.Select(c => c.Signups));
Assert.Equal(new[] { 2, 1 }, cohorts.Select(c => c.Enrolled));
Assert.Equal(new[] { 1, 1 }, cohorts.Select(c => c.Succeeded));
Assert.Equal(new[] { 2, 1 }, cohorts.Select(c => c.ActiveLast7Days));
Assert.Equal(new double?[] { 0, 1 }, cohorts.Select(c => c.MedianDaysToFirstEnrollment));
}
[Fact]
public void Segments_cover_every_tenant_with_a_domain()
{
var s = Scenario().AllTenants;
Assert.Equal(7, s.Tenants);
Assert.Equal(2, s.NeverEnrolled);
Assert.Equal(5, s.Enrolled);
Assert.Equal(1, s.EnrolledWithoutSuccess);
Assert.Equal(3, s.Active7Days); // a (10-22), b (counters today), d (10-27)
Assert.Equal(4, s.Active30Days); // plus e (10-05)
Assert.Equal(1, s.Dormant); // f
}
[Fact]
public void Plans_count_tiers_trials_and_paying_customers()
{
var p = Scenario().Plans;
Assert.Equal(new[] { ("free", 6), ("pro", 1) }, p.ByPlanTier.Select(x => (x.PlanTier, x.Tenants)));
Assert.Equal(1, p.TrialsStartedInWindow);
Assert.Equal(1, p.TrialsActive);
Assert.Equal(1, p.PayingCustomers);
}
[Fact]
public void Tenant_rows_appear_only_on_request_in_signup_order()
{
Assert.Null(Scenario().Tenants);
var rows = Scenario(includeTenants: true).Tenants!;
Assert.Equal(new[] { Tid('a'), Tid('b'), Tid('c'), Tid('d'), Tid('h') }, rows.Select(r => r.TenantId));
var b = rows[1];
Assert.True(b.Enrolled);
Assert.Null(b.FirstEnrollmentDay);
Assert.Equal("2026-10-28", b.LastActiveDay);
var c = rows[2];
Assert.False(c.Enrolled);
Assert.Null(c.LastActiveDay);
Assert.Equal("pro", rows[0].PlanTier);
Assert.Equal("2026-10-22", rows[0].FirstEnrollmentDay);
}
[Fact]
public void Excluded_tenants_leave_every_figure_and_only_signed_up_rows_count_as_excluded()
{
// a: in-window pro signup, paying, enrolled the same day; e: older tenant active 23 days ago;
// g: a row without a domain (never a tenant); z: no configuration row at all.
var r = Scenario(true, 'a', 'e', 'g', 'z');
Assert.Equal(2, r.ExcludedTenants);
Assert.Equal(4, r.Signups.Total);
Assert.Equal(new[] { 0, 1, 1, 0, 1, 1, 0 }, r.Signups.Buckets.Select(b => b.Count));
Assert.Equal(0, r.Signups.ByHour[11]);
Assert.Equal(2, r.Activation.Enrolled);
Assert.Equal(0, r.Activation.FirstEnrollmentSameDay);
Assert.Equal(1, r.Activation.MedianDaysToFirstEnrollment);
Assert.Equal(new[] { 2, 2 }, r.Cohorts.Select(c => c.Signups)); // b, c | d, h
Assert.Equal(5, r.AllTenants.Tenants);
Assert.Equal(2, r.AllTenants.Active7Days);
Assert.Equal(2, r.AllTenants.Active30Days);
Assert.Equal(new[] { ("free", 5) }, r.Plans.ByPlanTier.Select(x => (x.PlanTier, x.Tenants)));
Assert.Equal(0, r.Plans.PayingCustomers);
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('d'), Tid('h') }, r.Tenants!.Select(t => t.TenantId));
}
[Theory]
[InlineData(null, true, "")]
[InlineData("", true, "")]
[InlineData(" {AAAAAAAA-0000-4000-8000-000000000000} ,,bbbbbbbb-0000-4000-8000-000000000000", true,
"aaaaaaaa-0000-4000-8000-000000000000,bbbbbbbb-0000-4000-8000-000000000000")]
[InlineData("aaaaaaaa-0000-4000-8000-000000000000,contoso.example", false, "")]
public void Excluded_tenants_are_tenant_ids_in_the_lowercase_d_form(string? raw, bool ok, string expected)
{
Assert.Equal(ok, GrowthMetricsBuilder.TryParseExcludedTenants(raw, out var ids));
if (ok) Assert.Equal(expected, string.Join(",", ids));
}
[Fact]
public void At_most_twenty_tenants_can_be_excluded()
{
static string Ids(int n) => string.Join(",", Enumerable.Range(0, n).Select(i => $"{i:x8}-0000-4000-8000-000000000000"));
Assert.True(GrowthMetricsBuilder.TryParseExcludedTenants(Ids(GrowthMetricsBuilder.MaxExcludedTenants), out _));
Assert.False(GrowthMetricsBuilder.TryParseExcludedTenants(Ids(GrowthMetricsBuilder.MaxExcludedTenants + 1), out _));
}
[Fact]
public void A_first_enrollment_without_a_usage_row_and_without_activity_today_is_unknown()
{
var configs = new[] { Config('a', Utc(10, 20, 9)) };
var stats = new[] { Stats('a', 4, 4, Utc(10, 27, 8)) };
var a = GrowthMetricsBuilder.Build(configs, stats, Array.Empty<TenantActivityDay>(), Now, 30, TimeZoneInfo.Utc, false, NoExclusions).Activation;
Assert.Equal(1, a.FirstEnrollmentUnknown);
Assert.Equal(0, a.FirstEnrollmentPending);
Assert.Null(a.MedianDaysToFirstEnrollment);
}
[Fact]
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
{
var configs = new List<TenantConfiguration>();
var next = 'a';
void Signups(int month, int day, int count)
{
for (var i = 0; i < count; i++) configs.Add(Config(next++, Utc(month, day, 10, i)));
}
// One signup a day from 10-01 to 10-14 sets the baseline median to 1.
for (var day = 1; day <= 14; day++) Signups(10, day, 1);
Signups(10, 15, 3); // 3 >= 2 × 1: peak
Signups(10, 16, 1);
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
new DateTime(2026, 10, 16, 12, 0, 0, DateTimeKind.Utc), 7, TimeZoneInfo.Utc, false, NoExclusions);
var peak = Assert.Single(r.Signups.PeakDays);
Assert.Equal("2026-10-15", peak.Day);
Assert.Equal(3, peak.Count);
Assert.Equal(1, peak.BaselineMedian);
}
[Fact]
public void Approval_time_rows_stay_out_of_the_hours_and_peaks_but_count_as_signups()
{
// Before 2026-03-18 OnboardedAt is the approval time: four rows within milliseconds.
var configs = Enumerable.Range(0, 4)
.Select(i => Config((char)('a' + i), new DateTime(2026, 2, 24, 7, 46, 30, i * 10, DateTimeKind.Utc)))
.Append(Config('k', Utc(10, 20, 14)))
.ToList();
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
Now, 365, TimeZoneInfo.Utc, false, NoExclusions);
Assert.Equal(5, r.Signups.Total);
Assert.Equal(1, r.Signups.ByHour.Sum());
Assert.Equal(1, r.Signups.ByHour[14]);
Assert.Empty(r.Signups.PeakDays);
Assert.Equal("2026-03-18", r.ExactSignupTimesFrom);
}
[Fact]
public void Windows_beyond_90_days_bucket_by_monday_anchored_week_clipped_to_the_window()
{
var configs = new[] { Config('a', Utc(10, 20, 9)), Config('b', Utc(8, 3, 9)) };
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
Now, 120, TimeZoneInfo.Utc, false, NoExclusions);
Assert.Equal("week", r.Signups.BucketSize);
Assert.Equal(r.WindowStart, r.Signups.Buckets[0].Start);
Assert.All(r.Signups.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, DateTime.Parse(b.Start).DayOfWeek));
Assert.Equal(2, r.Signups.Buckets.Sum(b => b.Count));
}
[Fact]
public void Usage_rows_are_read_for_the_window_or_at_least_the_30_day_look_back()
{
var today = Now.Date;
Assert.Equal(today.AddDays(-29), GrowthMetricsBuilder.UsageReadStart(today, 7));
Assert.Equal(today.AddDays(-89), GrowthMetricsBuilder.UsageReadStart(today, 90));
}
[Theory]
[InlineData(null, true, "UTC")]
[InlineData("", true, "UTC")]
[InlineData("Europe/Berlin", true, "Europe/Berlin")]
[InlineData("America/Argentina/Buenos_Aires", true, "America/Argentina/Buenos_Aires")]
[InlineData("../../etc/passwd", false, null)]
[InlineData("/etc/localtime", false, null)]
[InlineData("Europe/Nowhere", false, null)]
public void Time_zones_are_iana_ids_the_host_knows(string? id, bool ok, string? expectedId)
{
Assert.Equal(ok, GrowthMetricsBuilder.TryResolveTimeZone(id, out var tz));
if (ok) Assert.Equal(expectedId, tz.Id);
}
}
@@ -0,0 +1,260 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A live SignalR connection is addressed by its principal (sign-in tenant plus principal key), and a revoke names the
/// tenant the revoked grant resolved under, so an action in one tenant never restarts the same UPN's connections in
/// another. Pins the ends of that chain: negotiate binds the pair from the validated request context,
/// <see cref="LiveStreamRevoker"/> finds the tenant a platform or delegated grant was exercised under, the
/// identity-binding routes cut the previous principal, and the call-site inventory keeps these the only paths.
/// </summary>
public class LiveStreamPrincipalTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
private const string Upn = "person@partner.example";
private const string OperatorUpn = "ga@vendor.example";
// ── negotiate ────────────────────────────────────────────────────────────
[Fact]
public async Task Negotiate_binds_the_connection_to_the_sign_in_tenant_and_principal_key()
{
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = "Person@Partner.Example" });
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
{
// Every revocable join gate needs the key, so such a connection holds nothing a revoke would have to reach;
// a login the portal keeps on token claims alone still gets its Progress Portal live updates.
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = string.Empty });
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new string?[] { null }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_unauthenticated_is_refused_without_negotiating()
{
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = Upn }, authenticated: false);
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Empty(signalR.NegotiatedUserIds);
}
// ── LiveStreamRevoker ────────────────────────────────────────────────────
private static (LiveStreamRevoker Revoker, FakeSignalRNotificationService SignalR, StubAdminIdentityBindingService Bindings) BuildRevoker()
{
var signalR = new FakeSignalRNotificationService();
var bindings = new StubAdminIdentityBindingService();
return (new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance), signalR, bindings);
}
[Fact]
public async Task Cut_addresses_exactly_the_named_tenant()
{
var (revoker, signalR, bindings) = BuildRevoker();
bindings.BoundTenants[Upn] = OtherTenantId;
await revoker.CutAsync(TenantId, Upn);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
Assert.Equal(0, bindings.GetCalls);
}
[Fact]
public async Task CutBoundIdentity_addresses_the_tenant_of_the_binding()
{
var (revoker, signalR, bindings) = BuildRevoker();
bindings.BoundTenants[Upn] = OtherTenantId;
await revoker.CutBoundIdentityAsync(Upn);
Assert.Equal(new[] { SignalRGroupHelper.UserId(OtherTenantId, Upn) }, signalR.DisconnectedUsers);
}
[Fact]
public async Task CutBoundIdentity_without_a_binding_cuts_nothing()
{
// No binding ⇒ no platform or delegated grant of the UPN ever resolved ⇒ no connection holds its groups.
var (revoker, signalR, _) = BuildRevoker();
await revoker.CutBoundIdentityAsync(Upn);
Assert.Empty(signalR.DisconnectedUsers);
}
[Fact]
public async Task CutBoundIdentity_survives_a_failed_binding_read_without_cutting()
{
var (revoker, signalR, bindings) = BuildRevoker();
bindings.GetThrows = new InvalidOperationException("storage unavailable");
await revoker.CutBoundIdentityAsync(Upn);
Assert.Empty(signalR.DisconnectedUsers);
}
// ── identity-binding routes cut the previous principal ───────────────────
private static (IdentityBindingManagementFunction Fn, FakeSignalRNotificationService SignalR, StubAdminIdentityBindingService Bindings) BuildBindingFn()
{
var (revoker, signalR, bindings) = BuildRevoker();
var fn = new IdentityBindingManagementFunction(
NullLogger<IdentityBindingManagementFunction>.Instance, bindings, Mock.Of<IMaintenanceRepository>(), revoker);
return (fn, signalR, bindings);
}
private static RequestContext Operator() => new() { TenantId = OtherTenantId, UserPrincipalName = OperatorUpn, IsGlobalAdmin = true };
[Fact]
public async Task Replacing_a_binding_cuts_the_connections_signed_in_under_the_previous_one()
{
var (fn, signalR, bindings) = BuildBindingFn();
bindings.BoundTenants[Upn] = TenantId;
var req = BuildRequest(Operator(), new { homeTenantId = OtherTenantId });
var response = await fn.PutIdentityBinding(req, Upn, req.FunctionContext);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
}
[Fact]
public async Task Creating_a_binding_cuts_nothing()
{
var (fn, signalR, _) = BuildBindingFn();
var req = BuildRequest(Operator(), new { homeTenantId = TenantId });
var response = await fn.PutIdentityBinding(req, Upn, req.FunctionContext);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(signalR.DisconnectedUsers);
}
[Fact]
public async Task Deleting_a_binding_cuts_the_connections_signed_in_under_it()
{
var (fn, signalR, bindings) = BuildBindingFn();
bindings.BoundTenants[Upn] = TenantId;
var req = BuildRequest(Operator());
var response = await fn.DeleteIdentityBinding(req, Upn, req.FunctionContext);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
}
[Fact]
public async Task Deleting_a_missing_binding_is_404_and_cuts_nothing()
{
var (fn, signalR, _) = BuildBindingFn();
var req = BuildRequest(Operator());
var response = await fn.DeleteIdentityBinding(req, Upn, req.FunctionContext);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Empty(signalR.DisconnectedUsers);
}
// ── call-site inventory ──────────────────────────────────────────────────
[Fact]
public void Only_the_revoker_cuts_streams_and_only_negotiate_binds_connections()
{
// A second cut site would have to decide the principal on its own — the decision this change put in one place.
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var cuts = new List<string>();
var negotiations = new List<string>();
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase))
continue;
var text = File.ReadAllText(file);
if (text.Contains(".DisconnectUserAsync(", StringComparison.Ordinal))
cuts.Add(relative);
if (text.Contains(".NegotiateClientAsync(", StringComparison.Ordinal))
negotiations.Add(relative);
}
Assert.Equal(new[] { "Services/LiveStreamRevoker.cs" }, cuts);
Assert.Equal(new[] { "Functions/Infrastructure/SignalRNegotiateFunction.cs" }, negotiations);
}
// ── harness ──────────────────────────────────────────────────────────────
private static HttpRequestData BuildRequest(RequestContext requestContext, object? body = null, bool authenticated = true)
{
var services = new ServiceCollection();
services.AddOptions();
// Production wire settings, so camelCase test bodies bind to the request models like real requests do.
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { [RequestContext.ItemsKey] = requestContext };
if (authenticated)
items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", requestContext.TenantId) }, "test"));
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body ?? new { }))));
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;
}
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;
}
}
@@ -832,6 +832,136 @@ public class MetricsWireParityTests
});
}
// ---- GetGrowthMetrics ---------------------------------------------------------------
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
{
WindowDays = 7,
WindowStart = "2026-10-22",
WindowEnd = "2026-10-28",
TimeZone = "Europe/Berlin",
ExactSignupTimesFrom = "2026-03-18",
UsageDaysThrough = "2026-10-27",
ExcludedTenants = 1,
Signups = new GrowthSignups
{
Total = 5,
PreviousWindowTotal = 1,
BucketSize = "day",
Buckets = new List<GrowthBucket> { new() { Start = "2026-10-22", Count = 5 } },
PeakDays = new List<GrowthPeakDay> { new() { Day = "2026-10-22", Count = 5, BaselineMedian = 1 } },
ByHour = new int[24],
ByWeekday = new int[7],
},
Cohorts = new List<GrowthCohortWeek>
{
new() { WeekStart = "2026-10-19", Signups = 5, Enrolled = 3, Succeeded = 2, ActiveLast7Days = 3, MedianDaysToFirstEnrollment = median },
},
Activation = new GrowthActivation
{
Enrolled = 3, Succeeded = 2, NeverEnrolled = 2,
FirstEnrollmentSameDay = 1, FirstEnrollmentNextDay = 1, FirstEnrollmentDays2To7 = 0, FirstEnrollmentLater = 0,
FirstEnrollmentPending = 1, FirstEnrollmentUnknown = 0, MedianDaysToFirstEnrollment = median,
},
AllTenants = new GrowthTenantSegments
{
Tenants = 7, NeverEnrolled = 2, Enrolled = 5, EnrolledWithoutSuccess = 1, Active7Days = 3, Active30Days = 4, Dormant = 1,
},
Plans = new GrowthPlans
{
ByPlanTier = new List<GrowthPlanCount> { new() { PlanTier = "free", Tenants = 6 } },
TrialsStartedInWindow = 1,
TrialsActive = 1,
PayingCustomers = 1,
},
Tenants = tenants,
};
private static object GrowthLiteral(double? median, object? tenants) => new
{
windowDays = 7,
windowStart = "2026-10-22",
windowEnd = "2026-10-28",
timeZone = "Europe/Berlin",
exactSignupTimesFrom = "2026-03-18",
usageDaysThrough = "2026-10-27",
excludedTenants = 1,
signups = new
{
total = 5,
previousWindowTotal = 1,
bucketSize = "day",
buckets = new[] { new { start = "2026-10-22", count = 5 } },
peakDays = new[] { new { day = "2026-10-22", count = 5, baselineMedian = 1.0 } },
byHour = new int[24],
byWeekday = new int[7],
},
cohorts = new[]
{
new { weekStart = "2026-10-19", signups = 5, enrolled = 3, succeeded = 2, activeLast7Days = 3, medianDaysToFirstEnrollment = median },
},
activation = new
{
enrolled = 3, succeeded = 2, neverEnrolled = 2,
firstEnrollmentSameDay = 1, firstEnrollmentNextDay = 1, firstEnrollmentDays2To7 = 0, firstEnrollmentLater = 0,
firstEnrollmentPending = 1, firstEnrollmentUnknown = 0, medianDaysToFirstEnrollment = median,
},
allTenants = new
{
tenants = 7, neverEnrolled = 2, enrolled = 5, enrolledWithoutSuccess = 1, active7Days = 3, active30Days = 4, dormant = 1,
},
plans = new
{
byPlanTier = new[] { new { planTier = "free", tenants = 6 } },
trialsStartedInWindow = 1,
trialsActive = 1,
payingCustomers = 1,
},
tenants,
};
[Fact]
public void GrowthMetricsResponse_matches_the_shape_with_tenant_rows()
{
var tenantId = "8bb31d22-0003-4c8d-b2e3-63f4bbbb0001";
var onboardedAt = new DateTime(2026, 10, 22, 9, 0, 0, DateTimeKind.Utc);
AssertParity(
GrowthLiteral(0.5, new[]
{
new
{
tenantId,
onboardedAt,
planTier = "free",
enrolled = true,
succeeded = true,
firstEnrollmentDay = "2026-10-22",
lastActiveDay = "2026-10-27",
},
}),
GrowthResponse(0.5, new List<GrowthTenantRow>
{
new()
{
TenantId = tenantId,
OnboardedAt = onboardedAt,
PlanTier = "free",
Enrolled = true,
Succeeded = true,
FirstEnrollmentDay = "2026-10-22",
LastActiveDay = "2026-10-27",
},
}));
}
[Fact]
public void GrowthMetricsResponse_omits_unknown_medians_and_the_tenant_rows()
{
// No first enrollment known yet and no includeTenants: the three keys vanish identically.
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Storage side of the platform rollup: the platform row is merged with the ETag it was read
/// with and never carries IssuesDetected, the tenant floors land in one write, and the reads
/// that feed the rollup baseline are scoped to exactly the rows they mean.
/// that feed the rollup baseline and the growth figures are scoped to exactly the rows they mean.
/// </summary>
public class PlatformStatsStorageTests
{
@@ -53,6 +53,7 @@ public class PlatformStatsStorageTests
var service = new Mock<TableServiceClient>();
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
}
}
@@ -231,6 +232,7 @@ public class PlatformStatsStorageTests
var stats = Assert.Single(await h.Sut.GetAllTenantStatsAsync());
Assert.Equal("RowKey eq 'current' and PartitionKey ne 'global'", Assert.Single(h.Filters));
Assert.Equal(Tenant, stats.TenantId);
Assert.Equal(7, stats.TotalEnrollments);
Assert.Equal(5, stats.SuccessfulEnrollments);
Assert.Equal(900, stats.TotalEventsProcessed);
@@ -246,6 +248,39 @@ public class PlatformStatsStorageTests
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetAllTenantStatsAsync());
}
// ---------- tenant activity days (growth figures) ----------
[Fact]
public async Task Tenant_activity_days_read_the_tenant_rows_of_the_range_only()
{
var h = new Harness
{
RowsFor = _ => new[]
{
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 3 },
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 0 },
}
};
var days = await h.Sut.GetTenantActivityDaysAsync(
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 27, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal("PartitionKey ge '2026-10-01' and PartitionKey le '2026-10-27' and RowKey ne 'global'", Assert.Single(h.Filters));
var day = Assert.Single(days);
Assert.Equal(Tenant, day.TenantId);
Assert.Equal(new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), day.Day);
Assert.Equal(DateTimeKind.Utc, day.Day.Kind);
Assert.Equal(3, day.SessionsTotal);
}
[Fact]
public async Task A_failing_activity_read_throws_instead_of_reporting_no_activity()
{
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
}
// ---------- seen device models ----------
[Fact]
@@ -42,4 +42,29 @@ public class PreviewWhitelistApprovalCacheTests
Assert.True(await _sut.IsApprovedAsync(TenantId));
_repo.Verify(r => r.IsInPreviewWhitelistAsync(TenantId), Times.Once);
}
// --- RevokeAsync: the revoke record comes first and is fail-loud; it is what stops a later re-activation
// from re-seating an onboarding member the tenant removed.
[Fact]
public async Task Revoke_RecordsTheRevokeBeforeItRemovesTheActivation()
{
var writes = new List<string>();
_repo.Setup(r => r.MarkRevokedAsync(TenantId, "ga@contoso.com")).Callback(() => writes.Add("record")).Returns(Task.CompletedTask);
_repo.Setup(r => r.RemoveFromPreviewWhitelistAsync(TenantId)).Callback(() => writes.Add("remove")).ReturnsAsync(true);
await _sut.RevokeAsync(TenantId, "ga@contoso.com");
Assert.Equal(new[] { "record", "remove" }, writes);
}
[Fact]
public async Task Revoke_WhoseRecordFails_ChangesNothing()
{
_repo.Setup(r => r.MarkRevokedAsync(TenantId, "ga@contoso.com")).ThrowsAsync(new InvalidOperationException("storage"));
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.RevokeAsync(TenantId, "ga@contoso.com"));
_repo.Verify(r => r.RemoveFromPreviewWhitelistAsync(It.IsAny<string>()), Times.Never);
}
}
@@ -21,6 +21,8 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData("first.last@example.de")]
[InlineData("cadm_user@contoso.onmicrosoft.com")]
[InlineData("m.muster_ext_@contoso.example")] // a member naming scheme, not the external marker
[InlineData("systemadmin@contoso.com")] // a common admin-account prefix, not a sentinel
[InlineData("system.ops@contoso.com")]
public void IsPromotableUpn_AcceptsRealUpnShapes(string upn)
{
Assert.True(TenantApprovalService.IsPromotableUpn(upn));
@@ -33,11 +35,13 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData("System (Global Rate Limit Sync)")]
[InlineData("system (global rate limit sync)")]
[InlineData("System (whatever-future-sentinel)")]
public void IsPromotableUpn_RejectsAnySystemPrefixSentinel(string upn)
[InlineData("System (auto-approve) admin@contoso.com")] // an '@' does not rescue a value with spaces
public void IsPromotableUpn_RejectsAnySentinelShape(string upn)
{
// The shape-based prefix check is exactly the defense against the corruption
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
// is rejected without anyone needing to remember to update this method.
// Shape, not enumeration: a sentinel has no '@' or carries spaces, so any new "System (...)"
// sentinel introduced anywhere in the codebase is rejected without anyone needing to remember
// to update this method — while a real member whose name starts with "system" is accepted
// (see the shapes above); a prefix test once locked such members out of their own tenant.
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
}
@@ -367,6 +367,7 @@ public class PushDeliveryServiceTests
Assert.Equal("paused", e.Properties["action"]);
Assert.Equal("owner_inactive", e.Properties["reason"]);
Assert.Equal("tenant", e.Properties["scope"]);
Assert.Equal(TenantId, e.Properties["tenantId"]);
}
[Fact]
@@ -383,6 +384,7 @@ public class PushDeliveryServiceTests
Assert.Equal("resumed", e.Properties["action"]);
Assert.Equal("owner_inactive", e.Properties["reason"]);
Assert.Equal("tenant", e.Properties["scope"]);
Assert.Equal(TenantId, e.Properties["tenantId"]);
}
[Fact]
@@ -398,9 +400,56 @@ public class PushDeliveryServiceTests
Assert.Equal("revoked", e.Properties["action"]);
Assert.Equal("member_removed", e.Properties["reason"]);
Assert.Equal("tenant", e.Properties["scope"]);
Assert.Equal(TenantId, e.Properties["tenantId"]);
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
}
// ── Telemetry: PushDelivery and the inventory ────────────────────────────
[Fact]
public async Task Delivery_events_name_the_tenant_of_a_tenant_device_and_none_for_a_platform_device()
{
var channel = new CapturingChannel();
var h = new Harness(Telemetry(channel));
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
await h.Sut.SendCoreAsync(Tenant, Alert());
var deliveries = channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushDelivery").ToList();
Assert.Equal(2, deliveries.Count);
var tenantEvent = Assert.Single(deliveries, e => e.Properties["scope"] == "tenant");
Assert.Equal(TenantId, tenantEvent.Properties["tenantId"]);
Assert.Equal("delivered", tenantEvent.Properties["outcome"]);
var platformEvent = Assert.Single(deliveries, e => e.Properties["scope"] == "platform");
Assert.Equal(string.Empty, platformEvent.Properties["tenantId"]);
}
[Fact]
public async Task Maintenance_reports_the_inventory_it_found_before_sweeping()
{
var channel = new CapturingChannel();
var h = new Harness(Telemetry(channel));
var now = DateTime.UtcNow;
h.SignIn("admin@contoso.invalid", now.AddDays(-1));
h.AddDevice("d1", Constants.Push.DeviceStatus.Active).LastOpenedUtc = now.AddDays(-2);
h.AddDevice("pending-old", Constants.Push.DeviceStatus.Pending, pairedUtc: now.AddMinutes(-30));
h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
var result = await h.Maintenance.RunAsync(now);
Assert.Equal(1, result.PendingDevicesDeleted);
var e = Assert.Single(channel.Items.OfType<EventTelemetry>(), i => i.Name == PushDeviceInventory.EventName);
Assert.Equal(1, e.Metrics["tenants"]);
Assert.Equal(2, e.Metrics["devices"]); // the Pending row still counts: the state the run found
Assert.Equal(1, e.Metrics["active"]);
Assert.Equal(1, e.Metrics["pending"]);
Assert.Equal(1, e.Metrics["opened7d"]);
Assert.Equal(1, e.Metrics["ios"]);
Assert.Equal(1, e.Metrics["platformDevices"]);
Assert.Empty(e.Properties); // counts only, no ids
}
// ── SendSystemToOwnerAsync: the session-watch fan-out ────────────────────
[Fact]
@@ -490,20 +539,24 @@ public class PushDeliveryServiceTests
private static PushPayload SessionFinished()
=> PushAlertProjector.SystemMessage("session_watch", "Session finished", "DESKTOP-4711 finished enrollment.", "success", Tenant, DateTime.UtcNow);
private static TelemetryClient Telemetry(CapturingChannel channel)
internal static TelemetryClient Telemetry(CapturingChannel channel)
=> new(new TelemetryConfiguration { TelemetryChannel = channel, ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000" });
/// <summary>The PushPairing lifecycle events only — every send also tracks a PushDelivery event.</summary>
private static List<EventTelemetry> PairingEvents(CapturingChannel channel)
=> channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushPairing").ToList();
/// <summary>Captures what the service tracks (the startup-telemetry test keeps its own copy private).</summary>
private sealed class CapturingChannel : ITelemetryChannel
/// <summary>
/// Captures what the service tracks (the startup-telemetry test keeps its own copy private). The fan-out
/// sends to several devices in parallel, each tracking from its own thread: writes lock, reads get a snapshot.
/// </summary>
internal sealed class CapturingChannel : ITelemetryChannel
{
public List<ITelemetry> Items { get; } = new();
private readonly List<ITelemetry> _items = new();
public List<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
public bool? DeveloperMode { get; set; }
public string EndpointAddress { get; set; } = "";
public void Send(ITelemetry item) => Items.Add(item);
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
public void Flush() { }
public void Dispose() { }
}
@@ -599,7 +652,7 @@ public class PushDeliveryServiceTests
Sut = new PushDeliveryService(Repo, Eligibility.Object, Settings, new WebPushSender(new HttpClient(Http)),
new Lazy<OpsEventService>(() => OpsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
NullLogger<PushDeliveryService>.Instance, telemetry);
Maintenance = new PushMaintenanceService(Repo, Settings, Sut, NullLogger<PushMaintenanceService>.Instance);
Maintenance = new PushMaintenanceService(Repo, Settings, Sut, NullLogger<PushMaintenanceService>.Instance, telemetry);
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var p = ua.ExportParameters(false);
@@ -0,0 +1,70 @@
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>The maintenance run's device count: tenant scope only (the platform scope apart), statuses, platforms, open and delivery windows.</summary>
public class PushDeviceInventoryTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private static readonly DateTime Now = new(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Counts_tenants_statuses_platforms_and_the_open_and_delivery_windows()
{
var inventory = PushDeviceInventory.Count(new[]
{
Device(TenantA, Constants.Push.DeviceStatus.Active, "ios-homescreen", opened: Now.AddDays(-1), delivered: Now.AddDays(-6)),
Device(TenantA, Constants.Push.DeviceStatus.Active, "android-chrome", opened: Now.AddDays(-8), delivered: Now.AddDays(-8)),
Device(TenantB, Constants.Push.DeviceStatus.Active, "something-new"),
Device(TenantB, Constants.Push.DeviceStatus.Paused, "ios-homescreen", opened: Now.AddDays(-31)),
Device(TenantB, Constants.Push.DeviceStatus.Stale, "windows-chromium"),
Device(TenantB, Constants.Push.DeviceStatus.Pending, "firefox"),
Device(Constants.Push.PlatformScope, Constants.Push.DeviceStatus.Active, "ios-homescreen", opened: Now),
}, Now);
Assert.Equal(2, inventory.Tenants);
Assert.Equal(6, inventory.Devices);
Assert.Equal(3, inventory.Active);
Assert.Equal(1, inventory.Paused);
Assert.Equal(1, inventory.Stale);
Assert.Equal(1, inventory.Pending);
Assert.Equal(1, inventory.Opened7d);
Assert.Equal(2, inventory.Opened30d);
Assert.Equal(1, inventory.Delivered7d);
Assert.Equal(1, inventory.PlatformDevices);
var m = inventory.ToMeasurements();
Assert.Equal(1, m["ios"]);
Assert.Equal(1, m["android"]);
Assert.Equal(1, m["other"]); // an unknown platform string lands in "other"
Assert.Equal(0, m["windows"]); // only Active devices count per platform
Assert.Equal(0, m["macos"]);
Assert.Equal(0, m["firefox"]);
}
[Fact]
public void A_tenant_with_only_paused_devices_is_not_a_push_tenant_and_no_devices_report_zeros()
{
var paused = PushDeviceInventory.Count(new[] { Device(TenantA, Constants.Push.DeviceStatus.Paused, "ios-homescreen") }, Now);
Assert.Equal(0, paused.Tenants);
Assert.Equal(1, paused.Devices);
var none = PushDeviceInventory.Count(Array.Empty<PushDevice>(), Now).ToMeasurements();
Assert.All(none.Values, v => Assert.Equal(0, v));
Assert.Equal(16, none.Count);
}
private static PushDevice Device(string scope, string status, string platform, DateTime? opened = null, DateTime? delivered = null) => new()
{
Scope = scope,
DeviceId = Guid.NewGuid().ToString("N"),
Status = status,
Platform = platform,
PairedUtc = Now.AddDays(-40),
LastOpenedUtc = opened,
LastDeliveredUtc = delivered,
};
}
@@ -7,6 +7,8 @@ using AutopilotMonitor.Push;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -73,6 +75,24 @@ public class PushPairingServiceTests
Assert.Equal("My iPhone", listed.Label);
}
[Fact]
public async Task Pairing_events_name_the_tenant_and_a_platform_pairing_none()
{
var channel = new PushDeliveryServiceTests.CapturingChannel();
var h = new Harness(telemetry: PushDeliveryServiceTests.Telemetry(channel));
var created = await h.Sut.CreateAsync(Tenant, Admin);
await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
await h.Sut.CreateAsync(NotificationScope.Platform, Admin);
var events = channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushPairing").ToList();
Assert.Equal(new[] { "created", "redeemed", "confirmed", "created" }, events.Select(e => e.Properties["action"]));
Assert.All(events.Take(3), e => Assert.Equal(TenantId, e.Properties["tenantId"]));
Assert.Equal("platform", events[3].Properties["scope"]);
Assert.Equal(string.Empty, events[3].Properties["tenantId"]);
}
[Fact]
public async Task A_code_redeems_exactly_once()
{
@@ -454,7 +474,8 @@ public class PushPairingServiceTests
/// <param name="keys">The platform's key ring; a rotated ring for the key-migration tests.</param>
/// <param name="repo">A repository shared with another harness: the same device rows seen under a new ring.</param>
public Harness(VapidKeyRing? keys = null, InMemoryPushDeviceRepository? repo = null)
/// <param name="telemetry">Optional: a client over a capturing channel to observe what the pairing service tracks.</param>
public Harness(VapidKeyRing? keys = null, InMemoryPushDeviceRepository? repo = null, TelemetryClient? telemetry = null)
{
Keys = keys ?? new VapidKeyRing(VapidKey.Generate());
Repo = repo ?? new InMemoryPushDeviceRepository();
@@ -478,7 +499,7 @@ public class PushPairingServiceTests
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance);
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
@@ -11,9 +11,9 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// The role-loss hooks (K6): a tenant member who is removed, disabled or demoted below Operator
/// loses the paired push devices of that tenant, a removed or disabled Global Admin loses the
/// platform-scope devices, and no other mutation touches a device. The hook is optional — the
/// original constructors without a revoker keep working — and it keys on the NEW role only.
/// loses the paired push devices of that tenant, a removed Global Admin loses the platform-scope
/// devices, and no other mutation touches a device. The hook is optional — the original
/// constructors without a revoker keep working — and it keys on the NEW role only.
/// </summary>
public class PushRoleLossHookTests
{
@@ -130,6 +130,21 @@ public class PushRoleLossHookTests
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Remove_disable_and_enable_of_a_missing_row_return_false_and_revoke_nothing()
{
// A UPN without a row in the tenant is not the tenant's to act on: the routes answer 404 on false.
var h = new TenantHarness();
h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(TenantId, Upn)).ReturnsAsync(false);
h.AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(TenantId, Upn, It.IsAny<bool>())).ReturnsAsync(false);
Assert.False(await h.Sut.RemoveTenantAdminAsync(TenantId, Upn));
Assert.False(await h.Sut.DisableTenantAdminAsync(TenantId, Upn));
Assert.False(await h.Sut.EnableTenantAdminAsync(TenantId, Upn));
AssertNeverRevoked(h.Revoker);
}
[Fact]
public async Task Update_that_changes_no_row_revokes_nothing()
{
@@ -168,16 +183,6 @@ public class PushRoleLossHookTests
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_removed");
}
[Fact]
public async Task Disabling_a_global_admin_revokes_their_platform_devices_once_as_global_admin_disabled()
{
var h = new GlobalHarness();
await h.Sut.DisableGlobalAdminAsync(Upn);
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_disabled");
}
[Fact]
public async Task Global_remove_hands_the_revoker_the_lower_cased_upn()
{
@@ -189,15 +194,13 @@ public class PushRoleLossHookTests
}
[Fact]
public async Task Global_service_without_a_revoker_still_completes_remove_and_disable()
public async Task Global_service_without_a_revoker_still_completes_remove()
{
var h = new GlobalHarness(withRevoker: false);
await h.Sut.RemoveGlobalAdminAsync(Upn);
await h.Sut.DisableGlobalAdminAsync(Upn);
h.AdminRepo.Verify(r => r.RemoveGlobalAdminAsync(Upn), Times.Once);
h.AdminRepo.Verify(r => r.DisableGlobalAdminAsync(Upn), Times.Once);
}
// ── Assertions ───────────────────────────────────────────────────────────
@@ -248,7 +251,6 @@ public class PushRoleLossHookTests
public GlobalHarness(bool withRevoker = true)
{
AdminRepo.Setup(r => r.RemoveGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
AdminRepo.Setup(r => r.DisableGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
var cache = new MemoryCache(new MemoryCacheOptions());
@@ -25,10 +25,7 @@ public class QueryParamGuardTests
["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/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
};
private static string FunctionsRoot()
@@ -136,6 +136,16 @@ public class RawTablePaginationTests
Assert.DoesNotContain("STALE", link);
}
[Fact]
public void BuildNextLink_echoes_fields_so_a_follow_up_page_keeps_the_projection()
{
var query = new NameValueCollection { { "fields", "Status,StartedAt" } };
var link = RawTablePagination.BuildNextLink("Sessions", 200, "abc", query);
Assert.Contains("fields=Status%2CStartedAt", link);
}
[Fact]
public void BuildNextLink_url_encodes_table_name_with_special_chars()
{
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Reports;
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 per-instance cap on concurrent report uploads: a caller past the limit gets 429 with
/// Retry-After, the first refusal of a window raises ReportUploadLimitReached while later ones in
/// that window stay in the trace, and a released slot is free again.
/// </summary>
public class ReportUploadGateTests
{
private const string Upn = "admin@example.com";
private readonly List<OpsEventEntry> _opsEvents = new();
[Fact]
public async Task Caller_past_the_limit_gets_429_and_one_ops_event_per_window()
{
var gate = CreateGate(limit: 1);
using var upload = gate.TryEnter();
Assert.NotNull(upload);
Assert.Null(gate.TryEnter());
var (req, _) = EndpointHarness.Request("tenant-a", Upn);
var refused = await gate.RefuseAsync(req, "sessions/{sessionId}/report", "tenant-a", Upn);
await gate.RefuseAsync(req, "diagnostics/files", "tenant-a", Upn);
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(refused));
Assert.True(refused.Headers.TryGetValues("Retry-After", out var retryAfter));
Assert.Equal("5", retryAfter!.Single());
var alert = Assert.Single(_opsEvents);
Assert.Equal(OpsEventTypes.ReportUploadLimitReached, alert.EventType);
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
Assert.Equal("tenant-a", alert.TenantId);
Assert.Contains("sessions/{sessionId}/report", alert.Details);
}
[Fact]
public void Alert_is_taken_once_per_window()
{
var gate = CreateGate(limit: 1);
var start = new DateTime(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc);
Assert.True(gate.TakeAlert(start));
Assert.False(gate.TakeAlert(start.AddMinutes(59)));
Assert.True(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
Assert.False(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
}
[Fact]
public void Released_slot_is_free_again_and_an_instance_takes_three()
{
var gate = CreateGate(limit: 1);
gate.TryEnter()!.Dispose();
Assert.NotNull(gate.TryEnter());
var production = new ReportUploadGate(CreateOps(), NullLogger<ReportUploadGate>.Instance);
Assert.Equal(3, production.Limit);
var slots = Enumerable.Range(0, 3).Select(_ => production.TryEnter()).ToList();
Assert.All(slots, Assert.NotNull);
Assert.Null(production.TryEnter());
}
private ReportUploadGate CreateGate(int limit) => new(limit, CreateOps(), NullLogger<ReportUploadGate>.Instance);
private OpsEventService CreateOps()
{
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Returns<OpsEventEntry>(e => { lock (_opsEvents) _opsEvents.Add(e); return Task.CompletedTask; });
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
}
}
@@ -1,25 +1,39 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Push;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Backup;
using Azure.Core.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
using BadHttpRequestException = Microsoft.AspNetCore.Http.BadHttpRequestException;
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.
/// The one body reader (D-207): camelCase and PascalCase both bind, string enums bind, an empty,
/// malformed or aborted 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. The cap counts the bytes actually
/// read, so neither a chunked nor a gzip body gets past it; the pipeline tests run the production
/// registration on real Kestrel, through the real push handler, and pin the worker's body limit.
/// </summary>
public class RequestBodyTests
{
@@ -85,6 +99,16 @@ public class RequestBodyTests
await AssertBadRequest(read.Error, "Invalid JSON body");
}
[Fact]
public async Task Aborted_body_is_400_incomplete_not_500()
{
// The client closed the tab mid-body: before this, the handler's catch turned it into a 500.
var req = Build(new ThrowingBodyStream(new BadHttpRequestException("Unexpected end of request content.")));
var read = await req.ReadAsync<SignalRJoinGroupRequest>();
await AssertBadRequest(read.Error, "Request body incomplete");
Assert.Null(read.Value);
}
[Fact]
public async Task Json_null_body_is_400_required()
{
@@ -109,13 +133,78 @@ public class RequestBodyTests
}
[Fact]
public async Task Byte_cap_applies_only_when_given()
public async Task Cap_is_strictly_greater_than()
{
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 atCap = await Build(new MemoryStream(PaddedJson("{\"code\":\"x\"", 8192))).ReadAsync<BeginPairRequest>(maxBytes: 8192);
Assert.Null(atCap.Error);
Assert.Equal("x", atCap.Value!.Code);
var uncapped = await Build("{\"name\":\"x\"}", contentLength: 2_000_000).ReadAsync<CreateTenantGroupRequest>();
Assert.Null(uncapped.Error);
var pastCap = await Build(new MemoryStream(PaddedJson("{\"code\":\"x\"", 8193))).ReadAsync<BeginPairRequest>(maxBytes: 8192);
await AssertBadRequest(pastCap.Error, "Request body too large");
}
[Fact]
public async Task A_site_without_a_cap_reads_under_the_default()
{
var atDefault = await Build(new MemoryStream(PaddedJson("{\"name\":\"x\"", (int)RequestBody.DefaultMaxBytes)))
.ReadAsync<CreateTenantGroupRequest>();
Assert.Null(atDefault.Error);
var pastDefault = await Build(new MemoryStream(PaddedJson("{\"name\":\"x\"", (int)RequestBody.DefaultMaxBytes + 1)))
.ReadAsync<CreateTenantGroupRequest>();
await AssertBadRequest(pastDefault.Error, "Request body too large");
}
[Fact]
public async Task Byte_order_mark_is_honoured_as_before()
{
var withBom = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("{\"name\":\"Group A\"}")).ToArray();
var read = await Build(new MemoryStream(withBom)).ReadAsync<CreateTenantGroupRequest>();
Assert.Null(read.Error);
Assert.Equal("Group A", read.Value!.Name);
}
[Fact]
public async Task Capped_buffer_is_null_past_the_cap_and_rewound_within_it()
{
Assert.Null(await Build(new MemoryStream(new byte[101])).ReadCappedAsync(100));
using var within = await Build("{\"a\":1}").ReadCappedAsync(100);
Assert.NotNull(within);
Assert.Equal(0, within!.Position);
Assert.Equal("{\"a\":1}", Encoding.UTF8.GetString(within.ToArray()));
}
[Fact]
public async Task Chunked_body_is_refused_one_byte_past_the_cap()
{
// Chunked transfer carries no Content-Length: the cap must hold on the bytes actually read.
var body = new CountingStream(new MemoryStream(PaddedJson("{\"code\":\"x\"", 64 * 1024)));
var read = await Build(body).ReadAsync<BeginPairRequest>(maxBytes: 8192);
await AssertBadRequest(read.Error, "Request body too large");
Assert.True(body.BytesRead <= 8192 + 1, $"read {body.BytesRead} bytes against an 8 KiB cap");
}
[Fact]
public async Task Gzip_body_is_capped_on_its_inflated_bytes()
{
// UseRequestDecompression hands the handler the inflating stream, while Content-Length still names
// the compressed size, far below the cap.
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", 1024 * 1024));
Assert.True(compressed.Length < 8192);
var inflated = new CountingStream(new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress));
var read = await Build(inflated, contentLength: compressed.Length).ReadAsync<BeginPairRequest>(maxBytes: 8192);
await AssertBadRequest(read.Error, "Request body too large");
Assert.True(inflated.BytesRead <= 8192 + 1, $"inflated {inflated.BytesRead} bytes against an 8 KiB cap");
}
[Fact]
public async Task Worker_limit_refusal_is_too_large_not_incomplete()
{
// Kestrel and UseRequestDecompression answer the worker's own limit with a 413 BadHttpRequestException.
var read = await Build(new ThrowingBodyStream(new BadHttpRequestException("Request body too large.", 413)))
.ReadAsync<BeginPairRequest>(maxBytes: 8192);
await AssertBadRequest(read.Error, "Request body too large");
}
[Fact]
@@ -150,6 +239,147 @@ public class RequestBodyTests
await AssertBadRequest(array.Error, "Body must be a JSON object");
}
// ── the production pipeline: real Kestrel with AddWorkerHttpPipeline ───────
[Fact]
public async Task Pipeline_push_route_refuses_a_gzip_bomb_before_inflating_it()
{
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", 4 * 1024 * 1024));
var content = new ByteArrayContent(compressed);
content.Headers.ContentEncoding.Add("gzip");
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, PushBeginAsync);
Assert.Equal(HttpStatusCode.BadRequest, status);
Assert.Equal("Request body too large", error);
Assert.True(bytesRead <= 8192 + 1, $"inflated {bytesRead} bytes against the push route's 8 KiB cap");
}
[Fact]
public async Task Pipeline_push_route_refuses_a_chunked_body_at_the_cap()
{
var content = new StreamContent(new MemoryStream(PaddedJson("{\"code\":\"x\"", 64 * 1024)));
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, PushBeginAsync, chunked: true);
Assert.Equal(HttpStatusCode.BadRequest, status);
Assert.Equal("Request body too large", error);
Assert.True(bytesRead <= 8192 + 1, $"read {bytesRead} bytes against the push route's 8 KiB cap");
}
[Fact]
public async Task Pipeline_worker_limit_bounds_what_a_gzip_body_inflates_to()
{
// A read without an effective cap stands in for any read that escapes one: Kestrel's default
// (30,000,000 bytes) would inflate and accept this body; the worker limit refuses it.
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", (int)RequestBody.WorkerMaxBytes + 1024 * 1024));
var content = new ByteArrayContent(compressed);
content.Headers.ContentEncoding.Add("gzip");
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, ReadUncappedAsync);
Assert.Equal(HttpStatusCode.BadRequest, status);
Assert.Equal("Request body too large", error);
Assert.True(bytesRead <= RequestBody.WorkerMaxBytes, $"inflated {bytesRead} bytes past the worker limit");
}
[Fact]
public async Task Pipeline_kestrel_refuses_a_declared_length_past_the_worker_limit_unread()
{
var content = new ByteArrayContent(new byte[RequestBody.WorkerMaxBytes + 1]);
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, ReadUncappedAsync, expectContinue: true);
Assert.Equal(HttpStatusCode.BadRequest, status);
Assert.Equal("Request body too large", error);
Assert.Equal(0, bytesRead);
}
[Fact]
public async Task Pipeline_still_inflates_an_honest_gzip_body()
{
var content = new ByteArrayContent(Gzip(Encoding.UTF8.GetBytes("{\"code\":\"x\"}")));
content.Headers.ContentEncoding.Add("gzip");
var (status, error, _) = await PostThroughPipelineAsync(content, ReadPairCodeAsync);
Assert.Equal(HttpStatusCode.OK, status);
Assert.Null(error);
}
[Fact]
public void Worker_body_limit_survives_the_functions_host_composition()
{
// Program.cs order, plus two would-be overrides: a Kestrel:Limits app setting and a later Configure.
var builder = FunctionsApplication.CreateBuilder(Array.Empty<string>());
builder.Configuration["Kestrel:Limits:MaxRequestBodySize"] = "104857600";
builder.ConfigureFunctionsWebApplication();
builder.Services.AddWorkerHttpPipeline();
builder.Services.Configure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = null);
using var provider = builder.Services.BuildServiceProvider();
Assert.Equal(RequestBody.WorkerMaxBytes, provider.GetRequiredService<IOptions<KestrelServerOptions>>().Value.Limits.MaxRequestBodySize);
Assert.Contains(provider.GetServices<IStartupFilter>(), filter => filter is HttpCompressionStartupFilter);
}
/// <summary>The real push handler; the pairing service is never reached when the read fails first.</summary>
private static Task<HttpResponseData> PushBeginAsync(HttpRequestData req)
=> new PushReceiverFunctions(null!,
new RateLimitService(new MemoryCache(new MemoryCacheOptions()), NullLogger<RateLimitService>.Instance),
NullLogger<PushReceiverFunctions>.Instance)
.Begin(req);
private static async Task<HttpResponseData> ReadUncappedAsync(HttpRequestData req)
=> (await req.ReadAsync<BeginPairRequest>(maxBytes: long.MaxValue)).Error ?? req.CreateResponse(HttpStatusCode.OK);
private static async Task<HttpResponseData> ReadPairCodeAsync(HttpRequestData req)
{
var read = await req.ReadAsync<BeginPairRequest>(maxBytes: 8192);
return read.Error ?? req.CreateResponse(read.Value!.Code == "x" ? HttpStatusCode.OK : HttpStatusCode.Conflict);
}
/// <summary>
/// Posts <paramref name="content"/> through real Kestrel with the production registration
/// (AddWorkerHttpPipeline: the worker body limit, and UseRequestDecompression through the startup
/// filter) to <paramref name="handle"/>, which receives the request body as the Functions worker
/// would hand it over. Returns the status, the error envelope's message, and the body bytes the
/// handler pulled (for a gzip body: inflated bytes).
/// </summary>
private static async Task<(HttpStatusCode Status, string? Error, long BytesRead)> PostThroughPipelineAsync(
HttpContent content, Func<HttpRequestData, Task<HttpResponseData>> handle, bool chunked = false, bool expectContinue = false)
{
CountingStream? body = null;
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddWorkerHttpPipeline();
await using var app = builder.Build();
app.Run(async context =>
{
body = new CountingStream(context.Request.Body);
var response = await handle(Build(body, context.Request.ContentLength));
context.Response.StatusCode = (int)response.StatusCode;
response.Body.Position = 0;
await response.Body.CopyToAsync(context.Response.Body);
});
await app.StartAsync();
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/test") { Content = content };
request.Headers.TransferEncodingChunked = chunked;
request.Headers.ExpectContinue = expectContinue;
using var response = await client.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
await app.StopAsync();
string? error = null;
if (text.Length > 0)
{
using var envelope = JsonDocument.Parse(text);
if (envelope.RootElement.TryGetProperty("error", out var message)) error = message.GetString();
}
return (response.StatusCode, error, body?.BytesRead ?? 0);
}
// ── fakes ─────────────────────────────────────────────────────────────────
private static async Task AssertBadRequest(HttpResponseData? error, string message)
@@ -165,6 +395,9 @@ public class RequestBodyTests
}
private static HttpRequestData Build(string body, long? contentLength = null)
=> Build(new MemoryStream(Encoding.UTF8.GetBytes(body)), contentLength);
private static HttpRequestData Build(Stream body, long? contentLength = null)
{
var services = new ServiceCollection();
services.AddOptions();
@@ -180,11 +413,68 @@ public class RequestBodyTests
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.SetupGet(r => r.Body).Returns(body);
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
return req.Object;
}
/// <summary>Valid JSON: <paramref name="head"/>, spaces, and the closing brace, <paramref name="length"/> bytes in all.</summary>
private static byte[] PaddedJson(string head, int length)
=> Encoding.UTF8.GetBytes(head + new string(' ', length - head.Length - 1) + "}");
private static byte[] Gzip(byte[] data)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize))
gzip.Write(data);
return output.ToArray();
}
/// <summary>
/// What Kestrel's request body throws: a client that disconnects mid-body (400), or the worker's
/// own body limit, raised by Kestrel or UseRequestDecompression (413).
/// </summary>
private sealed class ThrowingBodyStream : MemoryStream
{
private readonly BadHttpRequestException _error;
public ThrowingBodyStream(BadHttpRequestException error) => _error = error;
public override int Read(byte[] buffer, int offset, int count) => throw _error;
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw _error;
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) => throw _error;
}
/// <summary>Counts the bytes the reader pulled from the body (for a gzip body: the inflated bytes).</summary>
private sealed class CountingStream : Stream
{
private readonly Stream _inner;
public CountingStream(Stream inner) => _inner = inner;
public long BytesRead { get; private set; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => BytesRead; set => throw new NotSupportedException(); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => Count(_inner.Read(buffer, offset, count));
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> Count(await _inner.ReadAsync(buffer, cancellationToken));
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
private int Count(int read)
{
BytesRead += read;
return read;
}
}
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -17,7 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// security invariants: (1) a miss (no assignment row) neither audits nor disconnects — a typo must not
/// write false customer-visible "access removed" rows while the real grant stays live; (2) a real
/// revoke/disable/unassign/group-delete always cuts the affected UPN's live streams (group authorization
/// is join-time only); (3) grant validation is fail-closed (GUID tenantId, recognized roles only).
/// is join-time only) under its bound tenant, the only one the grant resolved under; (3) grant validation is
/// fail-closed (GUID tenantId, recognized roles only).
/// </summary>
public class RevokeEnforcementTests
{
@@ -65,14 +67,29 @@ public class RevokeEnforcementTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Never);
/// <summary>The tenant the revoked UPNs are bound to: a platform or delegated grant resolves only under it.</summary>
private const string HomeTenant = "66666666-6666-6666-6666-666666666666";
/// <summary>The revoker the functions cut through, over bindings homing every UPN of these tests in <see cref="HomeTenant"/>.</summary>
private static LiveStreamRevoker Streams(FakeSignalRNotificationService signalR, bool bound)
{
var bindings = new StubAdminIdentityBindingService();
if (bound)
{
foreach (var upn in new[] { UpnLower, "one@partner.example", "two@partner.example" })
bindings.BoundTenants[upn] = HomeTenant;
}
return new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
}
private static (DelegatedAdminManagementFunction Fn, Mock<IAdminRepository> Repo,
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildAdminFn()
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildAdminFn(bool bound = true)
{
var repo = new Mock<IAdminRepository>();
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new DelegatedAdminManagementFunction(
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo));
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, Streams(signalR, bound), BuildSlots(repo));
return (fn, repo, audit, signalR);
}
@@ -90,7 +107,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, Streams(signalR, bound: true), BuildSlots(repo),
proConferral ?? TestProConferral.Inert(), assignees ?? new FakeSelfServiceAssignees(signalR));
return (fn, repo, audit, signalR);
}
@@ -202,7 +219,7 @@ public class RevokeEnforcementTests
}
[Fact]
public async Task RevokeCore_RemovedRow_AuditsAndDisconnectsLowercasedUpn()
public async Task RevokeCore_RemovedRow_AuditsAndDisconnectsUnderTheBoundTenant()
{
var (fn, repo, audit, signalR) = BuildAdminFn();
repo.Setup(r => r.RemoveDelegatedAdminAsync(UpnLower, TenantA)).ReturnsAsync(true);
@@ -210,8 +227,23 @@ public class RevokeEnforcementTests
var removed = await fn.RevokeCoreAsync(Upn, TenantA, "ga@vendor.example");
Assert.True(removed);
// SignalR user ids are lowercased UPNs — a mixed-case route param must still hit the user's connections.
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
// The grant resolved only under the UPN's bound tenant — never the managed tenant it pointed at. User ids
// are lowercase, so a mixed-case route param still hits the user's connections.
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
audit.Verify(a => a.LogAuditEntryAsync(
TenantA, "DELETE", "DelegatedAdmin", UpnLower, "ga@vendor.example", null), Times.Once);
}
[Fact]
public async Task RevokeCore_RemovedRow_WithoutBinding_AuditsButCutsNothing()
{
// No binding ⇒ the grant never resolved for anyone ⇒ no connection holds its groups.
var (fn, repo, audit, signalR) = BuildAdminFn(bound: false);
repo.Setup(r => r.RemoveDelegatedAdminAsync(UpnLower, TenantA)).ReturnsAsync(true);
Assert.True(await fn.RevokeCoreAsync(Upn, TenantA, "ga@vendor.example"));
Assert.Empty(signalR.DisconnectedUsers);
audit.Verify(a => a.LogAuditEntryAsync(
TenantA, "DELETE", "DelegatedAdmin", UpnLower, "ga@vendor.example", null), Times.Once);
}
@@ -240,7 +272,7 @@ public class RevokeEnforcementTests
var ok = await fn.SetEnabledCoreAsync(Upn, TenantA, isEnabled: false, "ga@vendor.example");
Assert.True(ok);
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
}
[Fact]
@@ -287,7 +319,7 @@ public class RevokeEnforcementTests
var unassigned = await fn.UnassignCoreAsync(GroupId, Upn, "ga@vendor.example");
Assert.True(unassigned);
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
// Lost access is audited under EACH of the group's tenants (customer-visible trail).
audit.Verify(a => a.LogAuditEntryAsync(
TenantA, "DELETE", "DelegatedGroupAccess", UpnLower, "ga@vendor.example",
@@ -320,7 +352,11 @@ public class RevokeEnforcementTests
await fn.DeleteGroupCoreAsync(GroupId, "ga@vendor.example");
Assert.Equal(new[] { "one@partner.example", "two@partner.example" }, signalR.DisconnectedUsers);
Assert.Equal(new[]
{
SignalRGroupHelper.UserId(HomeTenant, "one@partner.example"),
SignalRGroupHelper.UserId(HomeTenant, "two@partner.example"),
}, signalR.DisconnectedUsers);
audit.Verify(a => a.LogAuditEntryAsync(
TenantA, "DELETE", "DelegatedGroupAccess", "*", "ga@vendor.example",
It.IsAny<Dictionary<string, string>?>()), Times.Once);
@@ -7,21 +7,22 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Session 080edee9 follow-up — pins the analyze-rule shape for the canonical detection-
/// failure scenario (Microsoft Apps for Enterprise + HRESULT 0x87D1041C).
/// Pins the analyze rules for an app that IME did not detect after its installer finished
/// (HRESULT 0x87D1041C). The verdict is IME's own error code, never <c>detectionResult</c>:
/// that field is IME's check BEFORE the install, and every installed app starts out
/// NotDetected, so it names no detection failure (D-392).
///
/// The code reaches <c>app_install_failed</c> in two shapes:
/// - IME's report delta: the signed decimal the IME log writes, -2016345060, after the app
/// had already been reported installed (IME flips Success → Error after its check).
/// - The ESP promotion of an app still installing when the ESP failed: hex 0x87d1041c plus
/// failureType esp_apps_detection_failure.
///
/// The customer-visible expectation:
/// - ANALYZE-APP-013 fires CRITICAL with the failed app name + HRESULT.
/// - ANALYZE-ENRL-001 fires CRITICAL referencing the same app + HRESULT.
/// - ANALYZE-CORR-001 ("Slow Network") does NOT fire — there was no slow network.
/// - ANALYZE-APP-009 ("App Download Stalled") does NOT fire — the synthetic
/// `download_progress: failed` emitted by the V2 termination handler is a
/// post-mortem signal, not a real download stall.
///
/// Both APP-013 and ENRL-001 must work for HISTORICAL events emitted by agents
/// shipped BEFORE the AppFailureTypes classifier landed (the legacy shape:
/// `detectionResult: NotDetected` + co-located `esp_failure_settle_started`
/// errorCode=0x87d1041c, with the old `failureType: esp_apps_timeout` tag).
/// - ANALYZE-APP-001 names the app in any enrollment outcome.
/// - ANALYZE-APP-013 fires CRITICAL with the app when the ESP terminated on 0x87D1041C.
/// - ANALYZE-ENRL-001 references the same app + HRESULT.
/// - An app that failed for another reason (download, installer) is never a detection failure.
/// </summary>
public class RuleEngineDetectionFailureTests
{
@@ -29,22 +30,72 @@ public class RuleEngineDetectionFailureTests
private const string SessionId = "080edee9-9f2e-47b9-b8f0-59fe18b8a8ad";
private const string FailedAppId = "f9026516-dbec-47cd-ae4e-ec2312a1303c";
private const string FailedAppName = "Microsoft Apps for Enterprise - 64Bit";
private const string OtherAppId = "6c1f0e52-3b8d-4f4e-9a6e-2d7c5b9e1a40";
private const string OtherAppName = "Contoso VPN Client";
[Fact]
public async Task ANALYZE_APP_013_fires_on_legacy_detectionResult_plus_errorCode_shape()
public async Task ANALYZE_APP_001_fires_on_IME_report_after_the_app_was_reported_installed()
{
// The real IME sequence: "Execution state … Success" (app_install_completed), then IME's
// check after the install, then Success → Error with -2016345060. The completed event
// must not silence the finding.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
Assert.Equal("2.0.0", rule.Version);
Assert.True(rule.Enabled);
var events = new List<EnrollmentEvent>
{
AppInstallCompleted(sequence: 134),
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
};
var outcome = await RunAsync(rule, events);
var result = Assert.Single(outcome.Results);
var evidence = AsDict(result.MatchedConditions["detection_failure"]);
Assert.Equal(FailedAppName, AsString(evidence["appName"]));
Assert.Equal("-2016345060", AsString(evidence["errorCode"]));
}
[Fact]
public async Task ANALYZE_APP_001_fires_on_the_ESP_promotion_shape()
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
var outcome = await RunAsync(rule, new List<EnrollmentEvent> { AppInstallFailedEspPromotion() });
var result = Assert.Single(outcome.Results);
var evidence = AsDict(result.MatchedConditions["detection_failure"]);
Assert.Equal("0x87d1041c", AsString(evidence["errorCode"]));
}
[Fact]
public async Task ANALYZE_APP_001_does_not_fire_on_an_install_failure_with_another_code()
{
// A download error carries detectionResult NotDetected like every app that was not
// installed yet; the old rule read that as "not detected after the install".
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
{
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120),
});
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_APP_013_names_the_app_IME_reported_with_0x87D1041C()
{
// Legacy agent shape (pre-classifier): app_install_failed carries
// failureType=esp_apps_timeout (the old default) but ALSO detectionResult=NotDetected,
// and there's a co-located esp_failure_settle_started with errorCode=0x87d1041c.
// The rule must fire and the explanation must interpolate to the Office app.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
Assert.Equal("1.3.0", rule.Version);
Assert.Equal("1.4.0", rule.Version);
Assert.True(rule.Enabled);
Assert.Equal("critical", rule.Severity);
var events = new List<EnrollmentEvent>
{
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120, appId: OtherAppId, appName: OtherAppName),
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
};
@@ -54,22 +105,51 @@ public class RuleEngineDetectionFailureTests
Assert.Equal("ANALYZE-APP-013", result.RuleId);
Assert.Equal("critical", result.Severity);
// Backend stores the raw `{{...}}` template — the web interpolator
// (`interpolateRuleTemplate.ts`) resolves them at render time. So we pin the
// MATERIAL the interpolator needs: the matched-condition evidence dicts must
// carry appName (via auto-field injection on the required condition's match)
// AND errorCode (via the optional byField condition).
Assert.True(result.MatchedConditions.ContainsKey("app_with_detection_failure"));
// Backend stores the raw `{{...}}` template — the web interpolator resolves it at render
// time. Pin the material it needs: the app from the detection-failure match (not the app
// that failed its download) and the ESP code from the optional byField condition.
var detectionEvidence = AsDict(result.MatchedConditions["app_with_detection_failure"]);
Assert.Equal(FailedAppName, AsString(detectionEvidence["appName"]));
Assert.Equal("NotDetected", AsString(detectionEvidence["value"]));
Assert.Equal("-2016345060", AsString(detectionEvidence["value"]));
Assert.True(result.MatchedConditions.ContainsKey("esp_failure_errorcode"));
var errorCodeEvidence = AsDict(result.MatchedConditions["esp_failure_errorcode"]);
Assert.Equal("errorCode", AsString(errorCodeEvidence["field"]));
Assert.Equal("0x87d1041c", AsString(errorCodeEvidence["value"]));
}
[Fact]
public async Task ANALYZE_APP_013_names_the_app_the_ESP_promotion_tagged()
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
{
AppInstallFailedEspPromotion(),
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
});
var result = Assert.Single(outcome.Results);
var detectionEvidence = AsDict(result.MatchedConditions["app_with_detection_failure"]);
Assert.Equal(FailedAppName, AsString(detectionEvidence["appName"]));
}
[Fact]
public async Task ANALYZE_APP_013_does_not_name_an_app_that_failed_for_another_reason()
{
// The ESP failed on 0x87D1041C, but the only failed app the agent saw had a download
// error. Its detectionResult NotDetected is the check before the install, so the rule
// must not blame it.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
{
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120, appId: OtherAppId, appName: OtherAppName),
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
});
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_APP_013_does_not_fire_when_errorCode_is_not_0x87d1041c()
{
@@ -80,7 +160,7 @@ public class RuleEngineDetectionFailureTests
var events = new List<EnrollmentEvent>
{
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
EspFailureSettleStarted(errorCode: "0x8007064a", failedSubcategory: "SecurityPolicies"),
};
@@ -91,15 +171,13 @@ public class RuleEngineDetectionFailureTests
[Fact]
public async Task ANALYZE_APP_013_does_not_fire_without_esp_failure_settle_started()
{
// Defense: a Win32 app with detectionResult=NotDetected but NO ESP terminal
// failure (e.g. a retry that later succeeded, or a non-blocking app) must not
// pull in the critical detection-failure-during-ESP narrative — that rule is
// specifically about ESP-terminating detection failures.
// Defense: a detection failure without an ESP terminal failure (a non-blocking app)
// is ANALYZE-APP-001's finding, not the critical ESP narrative.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
var events = new List<EnrollmentEvent>
{
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
// No esp_failure_settle_started event — precondition fails.
};
@@ -122,7 +200,7 @@ public class RuleEngineDetectionFailureTests
{
EnrollmentFailedEvent(reason: "esp_terminal_failure"),
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
AppInstallFailedEspPromotion(),
};
var outcome = await RunAsync(rule, events);
@@ -174,43 +252,78 @@ public class RuleEngineDetectionFailureTests
Assert.DoesNotContain(BuiltInAnalyzeRules.GetAll(), r => r.RuleId == "ANALYZE-APP-009");
}
// ===== Event builders (faithful to session 080edee9's actual payloads) =====
[Fact]
public void ANALYZE_APP_010_is_removed_from_builtin_catalog()
{
// "App Reverted to Error After Successful Install" matched completed → IME error report.
// That flip is IME's check after the install failing (every one carries 0x87D1041C), so
// since ANALYZE-APP-001 reads the code it would report the same event twice (D-392).
Assert.DoesNotContain(BuiltInAnalyzeRules.GetAll(), r => r.RuleId == "ANALYZE-APP-010");
}
/// <summary>
/// Legacy app_install_failed shape — the one currently sitting in Table Storage
/// for session 080edee9. Note: failureType is the OLD `esp_apps_timeout` tag (the
/// new classifier hadn't shipped yet), errorCode is ABSENT, but detectionResult
/// is populated by ImeLogTracker from the IME log.
/// </summary>
private static EnrollmentEvent AppInstallFailedLegacyShape(string detectionResult) => new()
// ===== Event builders =====
private static Dictionary<string, object> AppData(string appId, string appName) => new()
{
["appId"] = appId,
["appName"] = appName,
["intent"] = "Install",
["targeted"] = "Device",
["runAs"] = "System",
["attemptNumber"] = "1",
// The check before the install: what every app carries until it is installed.
["detectionResult"] = "NotDetected",
};
private static EnrollmentEvent AppInstallCompleted(long sequence)
{
var data = AppData(FailedAppId, FailedAppName);
data["state"] = "Installed";
data["isCompleted"] = "true";
data["exitCode"] = "0";
return AppEvent("app_install_completed", sequence, data);
}
/// <summary>The failed event IME-ERROR-REPORT produces once its errorCode group is captured.</summary>
private static EnrollmentEvent AppInstallFailedImeReport(
string errorCode, long sequence, string appId = FailedAppId, string appName = FailedAppName)
{
var data = AppData(appId, appName);
data["state"] = "Error";
data["isError"] = "true";
data["isCompleted"] = "true";
data["errorPatternId"] = "IME-ERROR-REPORT";
data["errorCode"] = errorCode;
data["errorDetail"] = "[Win32App][ReportingManager] Execution state for app with id: " + appId +
" has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"Success\",\"NewValue\":\"Error\"}," +
"\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":" + errorCode + "}}";
return AppEvent("app_install_failed", sequence, data);
}
/// <summary>The termination handler's promotion of the app still installing at the ESP failure.</summary>
private static EnrollmentEvent AppInstallFailedEspPromotion()
{
var data = AppData(FailedAppId, FailedAppName);
data["state"] = "Error";
data["isError"] = "true";
data["isCompleted"] = "true";
data["errorPatternId"] = "esp_apps_detection_failure";
data["failureType"] = "esp_apps_detection_failure";
data["errorCode"] = "0x87d1041c";
data["confidence"] = "presumed";
data["terminationTrigger"] = "EspTerminalFailure";
return AppEvent("app_install_failed", 168, data);
}
private static EnrollmentEvent AppEvent(string eventType, long sequence, Dictionary<string, object> data) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "app_install_failed",
EventType = eventType,
Timestamp = DateTime.UtcNow,
Sequence = 168,
Data = new Dictionary<string, object>
{
["appId"] = FailedAppId,
["appName"] = FailedAppName,
["state"] = "Error",
["intent"] = "Install",
["targeted"] = "Device",
["runAs"] = "System",
["progressPercent"] = "0",
["bytesDownloaded"] = "10580224",
["bytesTotal"] = "10580224",
["isError"] = "true",
["isCompleted"] = "true",
["attemptNumber"] = "1",
["detectionResult"] = detectionResult,
["errorPatternId"] = "esp_apps_timeout",
["failureType"] = "esp_apps_timeout",
["confidence"] = "presumed",
["errorDetail"] = "Install status unconfirmed — ESP timed out (180 min) while still installing.",
["terminationTrigger"] = "EspTerminalFailure",
}
Sequence = sequence,
Data = data,
};
private static EnrollmentEvent EspFailureSettleStarted(string errorCode, string failedSubcategory) => new()
@@ -277,6 +277,8 @@ public class RulesWireParityTests
MatchCount = 1,
ParseFailureCount = 0,
TimeoutCount = 0,
// null on a complete test (key vanishes); a stopped test carries the reason string.
StopReason = null,
};
result.Lines.Add(new LogPatternLineResult
{
@@ -306,9 +306,9 @@ public class SessionDeletionHandlerOrderingTests
public Task SendGlobalNotificationAsync(object dto) => Task.CompletedTask;
public Task SendGlobalNotificationDismissedAsync(string notificationId) => Task.CompletedTask;
public Task SendGlobalNotificationDismissedAllAsync() => Task.CompletedTask;
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey)
=> Task.FromResult<(string Url, string AccessToken)?>(null);
public Task DisconnectUserAsync(string userId) => Task.CompletedTask;
public Task DisconnectUserAsync(string tenantId, string principalKey) => Task.CompletedTask;
}
}
}
@@ -897,7 +897,7 @@ public class SessionsWireParityTests
state = "Error",
errorCode = "0x80d03805",
exitCode = "1",
detectionResult = "NotDetected",
postInstallDetection = "NotDetected",
installStarts = 4,
lastStartedAt = startedAt,
failedAt,
@@ -969,7 +969,7 @@ public class SessionsWireParityTests
State = "Error",
ErrorCode = "0x80d03805",
ExitCode = "1",
DetectionResult = "NotDetected",
PostInstallDetection = "NotDetected",
InstallStarts = 4,
LastStartedAt = startedAt,
FailedAt = failedAt,
@@ -111,6 +111,36 @@ public sealed class ErrorCodeCatalogTests
Assert.Null(ErrorCodeCatalog.TryLookupDetailed(raw));
}
[Theory]
[InlineData("0x87D1041C", "0x87d1041c")]
[InlineData("0x0087d1041c", "0x87d1041c")]
[InlineData("-2016345060", "0x87d1041c")] // the signed decimal the IME log writes
[InlineData(" -2147024891 ", "0x80070005")]
[InlineData("-2147483648", "0x80000000")]
[InlineData("0x0", "0x00000000")]
[InlineData("1603", "1603")] // exit codes stay decimal
[InlineData("2147483647", "2147483647")]
[InlineData("-2147483649", "-2147483649")] // outside the 32-bit range: no HRESULT
[InlineData("0x800706431", "0x800706431")]
[InlineData("esp_apps_timeout", "esp_apps_timeout")]
public void NormalizeCode_writes_HRESULTs_as_canonical_hex_and_keeps_other_codes(string raw, string expected)
=> Assert.Equal(expected, ErrorCodeCatalog.NormalizeCode(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void NormalizeCode_returns_null_for_blank(string? raw)
=> Assert.Null(ErrorCodeCatalog.NormalizeCode(raw));
[Fact]
public void TryLookup_resolves_the_signed_decimal_of_an_HRESULT()
{
var hex = ErrorCodeCatalog.TryLookup("0x87d1041c");
Assert.NotNull(hex);
Assert.Same(hex, ErrorCodeCatalog.TryLookup("-2016345060"));
}
[Fact]
public void TryLookupDetailed_never_throws_for_any_short_tail_after_a_hex_prefix()
{
@@ -33,6 +33,31 @@ public class SignalRGroupHelperTests
Assert.Equal(expected, SignalRGroupHelper.ExtractTenantIdFromGroupName(group));
}
// ── User ids: the principal a connection is negotiated under and a revoke addresses ──
[Fact]
public void UserId_is_the_lowercased_sign_in_tenant_and_principal_key()
{
Assert.Equal($"{TenantId}:user@contoso.example",
SignalRGroupHelper.UserId(TenantId.ToUpperInvariant(), "User@Contoso.Example"));
}
[Fact]
public void UserId_of_the_same_upn_differs_per_sign_in_tenant()
{
// The reason a revoke in one tenant never reaches the same UPN signed in to another.
Assert.NotEqual(
SignalRGroupHelper.UserId(TenantId, "user@contoso.example"),
SignalRGroupHelper.UserId("99999999-9999-9999-9999-999999999999", "user@contoso.example"));
}
[Fact]
public void UserId_keeps_an_application_principal_key_whole()
{
Assert.Equal($"{TenantId}:app:0a0a0a0a-0000-0000-0000-000000000001",
SignalRGroupHelper.UserId(TenantId, "app:0A0A0A0A-0000-0000-0000-000000000001"));
}
[Fact]
public void IsTenantNotifyAdminGroup_OnlyTrueForAdminSuffix()
{
@@ -22,6 +22,15 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
/// <summary>Every (upn, tenantId, objectId) passed to EnsureBoundAsync / RebindAsync, in call order.</summary>
public List<(string Upn, string TenantId, string? ObjectId)> Bindings { get; } = new();
/// <summary>The home tenant <see cref="GetAsync"/> answers per UPN: seeded by a test, or the latest bind / rebind.</summary>
public Dictionary<string, string> BoundTenants { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>When set, <see cref="GetAsync"/> throws it (a storage failure on the binding read).</summary>
public Exception? GetThrows { get; set; }
/// <summary>How often <see cref="GetAsync"/> was called.</summary>
public int GetCalls { get; private set; }
public StubAdminIdentityBindingService(bool bound = true) : this(_ => bound)
{
}
@@ -42,6 +51,7 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
{
OnEnsureBound?.Invoke();
Bindings.Add((upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId?.ToLowerInvariant()));
BoundTenants[upn] = tenantId.ToLowerInvariant();
return Task.FromResult(new AdminIdentityBinding
{
Upn = upn.ToLowerInvariant(),
@@ -54,5 +64,19 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
public override Task<AdminIdentityBinding> RebindAsync(string upn, string tenantId, string? objectId, string boundBy)
=> EnsureBoundAsync(upn, tenantId, objectId, boundBy);
public override Task RemoveAsync(string upn) => Task.CompletedTask;
public override Task RemoveAsync(string upn)
{
BoundTenants.Remove(upn);
return Task.CompletedTask;
}
public override Task<AdminIdentityBinding?> GetAsync(string upn)
{
GetCalls++;
if (GetThrows != null)
throw GetThrows;
return Task.FromResult(BoundTenants.TryGetValue(upn, out var tenantId)
? new AdminIdentityBinding { Upn = upn.ToLowerInvariant(), TenantId = tenantId }
: null);
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Feedback;
using AutopilotMonitor.Functions.Services;
@@ -54,10 +55,12 @@ public sealed class SubmitGeneralFeedbackFunctionTests
Assert.Equal(NormalizedTenantId, ops.TenantId);
Assert.Equal(Upn, ops.UserId);
Assert.Contains("contoso.invalid", ops.Message);
Assert.Contains(row.FeedbackId!, ops.Details);
using var details = JsonDocument.Parse(ops.Details!);
Assert.Equal("0123456789ab", details.RootElement.GetProperty("feedbackId").GetString());
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Feedback", It.IsAny<string>(), "/admin/reports/user-feedback"), Times.Once);
"feedback", "New Feedback", It.IsAny<string>(), "/admin/reports/user-feedback?feedbackId=0123456789ab"), Times.Once);
}
[Fact]
@@ -268,7 +271,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
{
if (ThrowOnSave is { } ex) throw ex;
entry.Type = FeedbackEntryType.General;
entry.FeedbackId = $"{DateTime.MaxValue.Ticks - entry.InteractedAt!.Value.Ticks:D19}_0123456789ab";
entry.FeedbackId = "0123456789ab";
Saved.Add(entry);
return Task.CompletedTask;
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Admin;
@@ -56,6 +57,11 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
Assert.Equal(TenantId, ops.TenantId);
Assert.Equal(Upn, ops.UserId);
// The alert links the stored entry by the id the repository gave it.
using var details = JsonDocument.Parse(ops.Details!);
Assert.Equal("0123456789ab", details.RootElement.GetProperty("feedbackId").GetString());
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
}
[Fact]
@@ -312,6 +318,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry)
{
if (ThrowOnSaveOffboardingFeedback is { } ex) throw ex;
entry.FeedbackId = "0123456789ab";
PersistedOffboardingFeedback = entry;
return Task.CompletedTask;
}
@@ -61,6 +61,7 @@ public class TableFeedbackRepositoryTests
Assert.False(fetched.Dismissed);
Assert.True(fetched.Submitted);
Assert.Equal(entry.InteractedAt, fetched.InteractedAt);
Assert.Matches(IdShape, fetched.FeedbackId!);
}
[Fact]
@@ -143,6 +144,7 @@ public class TableFeedbackRepositoryTests
Assert.Equal("contoso.invalid", fetched.DomainName);
Assert.Equal("Pricing was too high for our use case.", fetched.Comment);
Assert.Equal(entry.InteractedAt, fetched.InteractedAt);
Assert.Matches(IdShape, fetched.FeedbackId!);
}
[Fact]
@@ -327,6 +329,117 @@ public class TableFeedbackRepositoryTests
private static async Task SaveInApp(Harness harness, FeedbackEntry entry)
=> await harness.Sut.UpdateInAppFeedbackAsync(entry.Upn, _ => entry);
// ── FeedbackId: minted once per row, kept by every later write ───────────
private const string IdShape = "^[0-9a-f]{12}$";
[Fact]
public async Task InApp_SecondAnswer_KeepsTheIdOfTheFirst()
{
// The row is rewritten whole on every answer; the id must survive the Replace.
var harness = new Harness();
var first = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 1,
InteractedAt = DateTime.UtcNow.AddDays(-61),
});
var second = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = 4, Submitted = true, DismissCount = 1,
InteractedAt = DateTime.UtcNow,
});
Assert.Matches(IdShape, first!.FeedbackId!);
Assert.Equal(first.FeedbackId, second!.FeedbackId);
Assert.Equal(first.FeedbackId, (await harness.Sut.GetInAppFeedbackAsync(Upn))!.FeedbackId);
Assert.Equal(first.FeedbackId, harness.SingleRow("InApp").GetString("FeedbackId"));
}
[Fact]
public async Task InApp_RowWithoutId_GetsOneOnItsNextWrite()
{
var harness = new Harness();
harness.Seed(new TableEntity("InApp", Upn)
{
["TenantId"] = TenantId, ["DisplayName"] = "Alice", ["Dismissed"] = true, ["Submitted"] = false,
["DismissCount"] = 1, ["InteractedAt"] = DateTime.UtcNow.AddDays(-61),
});
Assert.Null((await harness.Sut.GetInAppFeedbackAsync(Upn))!.FeedbackId);
var written = await harness.Sut.UpdateInAppFeedbackAsync(Upn, current => new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = current!.DismissCount + 1,
InteractedAt = DateTime.UtcNow,
});
Assert.Matches(IdShape, written!.FeedbackId!);
Assert.Equal(written.FeedbackId, harness.SingleRow("InApp").GetString("FeedbackId"));
}
[Fact]
public async Task InApp_IdFromTheCaller_IsIgnored()
{
// The repository owns the id: a decision that carries one cannot replace the stored id.
var harness = new Harness();
var first = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 1,
InteractedAt = DateTime.UtcNow,
});
var second = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 2,
InteractedAt = DateTime.UtcNow, FeedbackId = "ffffffffffff",
});
Assert.Equal(first!.FeedbackId, second!.FeedbackId);
}
[Fact]
public async Task Offboarding_SecondComment_KeepsTheIdOfTheFirst()
{
var harness = new Harness();
FeedbackEntry Comment(string text) => new()
{
HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, DisplayName = "Alice", Comment = text,
InteractedAt = DateTime.UtcNow,
};
var first = Comment("Too expensive.");
await harness.Sut.SaveOffboardingFeedbackAsync(first);
var second = Comment("Too expensive for our size.");
await harness.Sut.SaveOffboardingFeedbackAsync(second);
Assert.Matches(IdShape, first.FeedbackId!);
Assert.Equal(first.FeedbackId, second.FeedbackId);
var stored = await harness.Sut.GetOffboardingFeedbackAsync(HistoryRowKey);
Assert.Equal("Too expensive for our size.", stored!.Comment);
Assert.Equal(first.FeedbackId, stored.FeedbackId);
}
[Fact]
public async Task GetAll_EveryKind_CarriesItsOwnId()
{
var harness = new Harness();
await SaveInApp(harness, new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = 5, Submitted = true, InteractedAt = DateTime.UtcNow,
});
await harness.Sut.SaveOffboardingFeedbackAsync(new FeedbackEntry
{
HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, Comment = "x", InteractedAt = DateTime.UtcNow,
});
await harness.Sut.SaveGeneralFeedbackAsync(new FeedbackEntry
{
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Comment = "y", InteractedAt = DateTime.UtcNow,
});
var all = await harness.Sut.GetAllAsync();
Assert.Equal(3, all.Count);
Assert.All(all, e => Assert.Matches(IdShape, e.FeedbackId!));
Assert.Equal(3, all.Select(e => e.FeedbackId).Distinct().Count());
}
// ── General (help-menu feedback) ────────────────────────────────────────
[Fact]
@@ -350,8 +463,8 @@ public class TableFeedbackRepositoryTests
var fetched = Assert.Single(await harness.Sut.GetAllAsync());
Assert.Equal(FeedbackEntryType.General, fetched.Type);
Assert.Equal(entry.FeedbackId, fetched.FeedbackId);
Assert.Matches("^[0-9]{19}_[0-9a-f]{12}$", fetched.FeedbackId!);
Assert.StartsWith((DateTime.MaxValue.Ticks - at.Ticks).ToString("D19"), fetched.FeedbackId!);
Assert.Matches(IdShape, fetched.FeedbackId!);
Assert.Equal($"{DateTime.MaxValue.Ticks - at.Ticks:D19}_{fetched.FeedbackId}", harness.SingleRow("General").RowKey);
Assert.Equal("alice@contoso.invalid", fetched.Upn);
Assert.Equal(TenantId, fetched.TenantId);
Assert.Equal("Alice (Contoso)", fetched.DisplayName);
@@ -430,6 +543,9 @@ public class TableFeedbackRepositoryTests
/// <summary>Runs once just before the next Add/Update — a concurrent writer between read and write.</summary>
public Action? BeforeNextWrite { get; set; }
/// <summary>The one stored row of a partition.</summary>
public TableEntity SingleRow(string partitionKey) => _store.Values.Single(e => e.PartitionKey == partitionKey);
/// <summary>Stores a row as another writer would (fresh ETag).</summary>
public void Seed(TableEntity entity)
{
@@ -28,6 +28,8 @@ public class TenantAdminManagementFunctionTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string CallerUpn = "admin@contoso.com";
private const string ForeignTenantId = "f0e1d2c3-b4a5-4697-8899-aabbccddeeff";
private const string ForeignUpn = "ga@fabrikam.example";
// ── TryCanonicalizeRole ─────────────────────────────────────────────────
@@ -144,7 +146,7 @@ public class TenantAdminManagementFunctionTests
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "demoted@contoso.com") }, h.SignalR.DisconnectedUsers);
}
[Fact]
@@ -156,7 +158,7 @@ public class TenantAdminManagementFunctionTests
var response = await h.Function.DisableTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "demoted@contoso.com") }, h.SignalR.DisconnectedUsers);
}
[Fact]
@@ -165,8 +167,9 @@ public class TenantAdminManagementFunctionTests
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
var response = await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(h.SignalR.DisconnectedUsers);
}
@@ -177,11 +180,60 @@ public class TenantAdminManagementFunctionTests
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(demote, TenantId, "User@Contoso.com", ctx1);
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "user@contoso.com") }, h.SignalR.DisconnectedUsers);
var (promote, ctx2) = BuildRequest(new { role = "admin", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(promote, TenantId, "other@contoso.com", ctx2);
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "user@contoso.com") }, h.SignalR.DisconnectedUsers);
}
// ── a member action never reaches the UPN signed in to another tenant (security finding) ──
[Fact]
public async Task Adding_then_removing_disabling_and_demoting_a_foreign_upn_cuts_only_this_tenants_connections()
{
// The attack: a tenant admin adds the UPN of a Global Admin homed elsewhere, then removes, disables or
// demotes it. Every cut must name this tenant; the victim's connections live under their own tenant.
var h = BuildHarness();
var (add, ctx0) = BuildRequest(new { upn = ForeignUpn, role = "admin" });
await h.Function.AddTenantAdmin(add, TenantId, ctx0);
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(demote, TenantId, ForeignUpn, ctx1);
var (disable, ctx2) = BuildRequest(new { });
await h.Function.DisableTenantAdmin(disable, TenantId, ForeignUpn, ctx2);
var (remove, ctx3) = BuildRequest(new { });
await h.Function.RemoveTenantAdmin(remove, TenantId, ForeignUpn, ctx3);
var thisTenant = SignalRGroupHelper.UserId(TenantId, ForeignUpn);
Assert.Equal(new[] { thisTenant, thisTenant, thisTenant }, h.SignalR.DisconnectedUsers);
Assert.DoesNotContain(SignalRGroupHelper.UserId(ForeignTenantId, ForeignUpn), h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Remove_disable_and_enable_of_a_non_member_return_404_without_any_side_effect()
{
var h = BuildHarness();
h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
h.AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync(false);
var (remove, ctx1) = BuildRequest(new { });
var removed = await h.Function.RemoveTenantAdmin(remove, TenantId, ForeignUpn, ctx1);
var (disable, ctx2) = BuildRequest(new { });
var disabled = await h.Function.DisableTenantAdmin(disable, TenantId, ForeignUpn, ctx2);
var (enable, ctx3) = BuildRequest(new { });
var enabled = await h.Function.EnableTenantAdmin(enable, TenantId, ForeignUpn, ctx3);
Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, disabled.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, enabled.StatusCode);
Assert.Empty(h.SignalR.DisconnectedUsers);
Assert.Empty(h.Assignees.Ended);
h.MaintenanceRepo.Verify(r => r.LogAuditEntryAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<Dictionary<string, string>?>()),
Times.Never);
}
// ── removal and disable end the member's self-service delegation, before the stream cut ──
@@ -201,7 +253,7 @@ public class TenantAdminManagementFunctionTests
Assert.Equal(CallerUpn, ended.Actor);
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberRemoved, ended.Reason);
Assert.Equal(0, ended.DisconnectsBefore);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
}
[Fact]
@@ -218,7 +270,7 @@ public class TenantAdminManagementFunctionTests
Assert.Equal("leaver@contoso.com", ended.Upn.ToLowerInvariant());
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberDisabled, ended.Reason);
Assert.Equal(0, ended.DisconnectsBefore);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
}
[Fact]
@@ -248,7 +300,7 @@ public class TenantAdminManagementFunctionTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(h.Assignees.Ended);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
}
// ── harness ─────────────────────────────────────────────────────────────
@@ -257,12 +309,14 @@ public class TenantAdminManagementFunctionTests
{
public TenantAdminManagementFunction Function = default!;
public Mock<IAdminRepository> AdminRepo = default!;
public Mock<IMaintenanceRepository> MaintenanceRepo = default!;
public FakeSignalRNotificationService SignalR = default!;
public FakeSelfServiceAssignees Assignees = default!;
}
private static Harness BuildHarness()
{
// Every member a test names exists unless the test says otherwise (the routes 404 a UPN without a row).
var adminRepo = new Mock<IAdminRepository>(MockBehavior.Loose);
adminRepo.Setup(r => r.AddTenantMemberAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
@@ -270,6 +324,9 @@ public class TenantAdminManagementFunctionTests
adminRepo.Setup(r => r.UpdateMemberPermissionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync(true);
adminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
adminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
.ReturnsAsync(true);
var service = new TenantAdminsService(
adminRepo.Object,
@@ -279,12 +336,17 @@ public class TenantAdminManagementFunctionTests
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
var signalR = new FakeSignalRNotificationService();
var assignees = new FakeSelfServiceAssignees(signalR);
// The Global Admin of another tenant is bound there: no member action of this tenant may follow it.
var bindings = new StubAdminIdentityBindingService();
bindings.BoundTenants[ForeignUpn] = ForeignTenantId;
var streams = new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
return new Harness
{
Function = new TenantAdminManagementFunction(
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR, assignees),
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, streams, assignees),
AdminRepo = adminRepo,
MaintenanceRepo = maintenanceRepo,
SignalR = signalR,
Assignees = assignees,
};
@@ -71,17 +71,6 @@ public class TenantAdminsServiceMembershipTests
_repo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
}
[Theory]
[InlineData(Constants.TenantRoles.Admin, true, true)]
[InlineData(Constants.TenantRoles.Operator, true, false)]
[InlineData(Constants.TenantRoles.Admin, false, false)]
public async Task IsTenantAdmin_MeansAnEnabledAdminRow(string role, bool enabled, bool expected)
{
PointReadsReturn(Row(Upn, role, enabled));
Assert.Equal(expected, await _sut.IsTenantAdminAsync(TenantId, Upn));
}
[Fact]
public async Task Snapshot_TheCallersOwnRowDecides_WhateverTheTokenCasing()
{
@@ -148,4 +137,69 @@ public class TenantAdminsServiceMembershipTests
await Assert.ThrowsAsync<ArgumentException>(() => _sut.GetMembershipSnapshotAsync("x' or PartitionKey ne '", Upn));
_repo.Verify(r => r.GetTenantMembersAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task TryAddTenantAdmin_CreatesTheRowLowercased_AndDropsTheCachedMembership()
{
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system (auto-approve)", Constants.TenantRoles.Admin)).ReturnsAsync(true);
// A cached enabled row first, so the re-read after the seat is visible as a role change.
PointReadsReturn(Row(Upn, role: Constants.TenantRoles.Viewer), Row(Upn));
Assert.Equal(Constants.TenantRoles.Viewer, (await _sut.GetTableMembershipAsync(TenantId, Upn)).Role!.Role);
Assert.True(await _sut.TryAddTenantAdminAsync(TenantId, Upn.ToUpperInvariant(), "System (auto-approve)"));
Assert.Equal(Constants.TenantRoles.Admin, (await _sut.GetTableMembershipAsync(TenantId, Upn)).Role!.Role);
}
[Fact]
public async Task TryAddTenantAdmin_RowExists_AnswersFalseAndNeverUpserts()
{
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(false);
Assert.False(await _sut.TryAddTenantAdminAsync(TenantId, Upn, "System"));
_repo.Verify(r => r.AddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
}
// --- The tenant's audit log shows every first-admin seat like a seat an admin wrote.
private TenantAdminsService WithAudit(Mock<IMaintenanceRepository> audit) =>
new(_repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<TenantAdminsService>.Instance,
Mock.Of<AutopilotMonitor.Functions.Services.Push.IPushDeviceRevoker>(), audit.Object);
[Fact]
public async Task TryAddTenantAdmin_Seated_WritesTheTenantsAuditEntry()
{
var audit = new Mock<IMaintenanceRepository>();
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system (auto-approve)", Constants.TenantRoles.Admin)).ReturnsAsync(true);
Assert.True(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System (auto-approve)"));
audit.Verify(a => a.LogAuditEntryAsync(TenantId, "CREATE", "TenantAdmin", Upn, "system (auto-approve)",
It.Is<Dictionary<string, string>>(d => d["Role"] == Constants.TenantRoles.Admin)), Times.Once);
}
[Fact]
public async Task TryAddTenantAdmin_RowExists_WritesNoAuditEntry()
{
var audit = new Mock<IMaintenanceRepository>();
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(false);
Assert.False(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System"));
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
}
[Fact]
public async Task TryAddTenantAdmin_AuditWriteFails_TheSeatStillCounts()
{
var audit = new Mock<IMaintenanceRepository>();
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<Dictionary<string, string>>())).ThrowsAsync(new InvalidOperationException("storage"));
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(true);
Assert.True(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System"));
}
}
@@ -56,8 +56,8 @@ public class TenantApprovalServiceTests
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
// IsTenantAdminAsync runs for real and reads through the virtual GetTableMembershipAsync,
// which ArrangeNewlyApproved sets up per test.
// TryAddTenantAdminAsync is virtual: ArrangeNewlyApproved answers it per test (true = seated,
// false = the row exists). The unset IsApprovedFreshAsync answers false: not activated yet.
_tenantAdminsMock = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
@@ -84,16 +84,18 @@ public class TenantApprovalServiceTests
}
[Fact]
public async Task AlreadyApproved_ReturnsFalse_AndRunsNoSideEffects()
public async Task AlreadyApproved_ReturnsFalse_AndWritesNothing()
{
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
// The fresh pre-check decides, not the 5-minute activation cache: a re-activation right after a
// revoke on another instance must not be answered as "already activated" from that cache.
_previewMock.Setup(x => x.IsApprovedFreshAsync(TenantId)).ReturnsAsync(true);
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.False(newlyApproved);
// The side-effect block (auto-promote + welcome mail) starts with the config read —
// it must never run for a lost activation race.
_tenantConfigMock.Verify(x => x.GetConfigurationAsync(It.IsAny<string>()), Times.Never);
_previewMock.Verify(x => x.ApproveAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
}
@@ -123,14 +125,13 @@ public class TenantApprovalServiceTests
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, It.IsAny<string?>())).ReturnsAsync(alreadyAdmin
? (TableMemberState.Enabled, new MemberRoleInfo { Role = Constants.TenantRoles.Admin })
: (TableMemberState.NotPresent, null));
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy)).ReturnsAsync(!alreadyAdmin);
}
private void VerifyNoPromotion() =>
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
[Fact]
public async Task NewlyApproved_PromotesTheOnboardingMember()
@@ -141,17 +142,19 @@ public class TenantApprovalServiceTests
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
}
[Fact]
public async Task NewlyApproved_OnboardingMemberAlreadyAdmin_IsNotWrittenAgain()
public async Task NewlyApproved_OnboardingMemberAlreadySeated_IsLeftUntouchedAndActivationProceeds()
{
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", alreadyAdmin: true);
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
VerifyNoPromotion();
// The conditional insert is the only write: a row the tenant holds (disabled, demoted) is never overwritten.
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
}
[Fact]
@@ -175,6 +178,74 @@ public class TenantApprovalServiceTests
VerifyNoPromotion();
}
[Fact]
public async Task NewlyApproved_SeatsTheOnboardingMemberBeforeTheActivationRow()
{
// The invariant behind the activation race: no observable state has the tenant activated while the
// designated admin's row is still missing. The seat is written first, the activation row second.
var writes = new List<string>();
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy))
.Callback(() => writes.Add("seat")).ReturnsAsync(true);
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy))
.Callback(() => writes.Add("activate")).ReturnsAsync(true);
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
Assert.Equal(new[] { "seat", "activate" }, writes);
}
[Fact]
public async Task LostActivationRace_SeatIsTheWinnersRow_ButNoMailAndNoOpsEvent()
{
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
Assert.False(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
Assert.Empty(_opsEvents);
}
[Fact]
public async Task NewlyApproved_SeatWriteFails_ActivationStillProceeds()
{
// Best-effort on purpose: with the seat missing, auth/me seats the designated member on their next
// sign-in and nobody else.
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy))
.ThrowsAsync(new InvalidOperationException("Table Storage down"));
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
}
[Fact]
public async Task NewlyApproved_SeatsAnOnboardingMemberWhoseNameStartsWithSystem()
{
// "system" is a common prefix of real admin accounts; only the sentinel SHAPE (no '@', spaces) is unpromotable.
ArrangeNewlyApproved(onboardedBy: "systemadmin@contoso.example");
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "systemadmin@contoso.example", ApprovedBy), Times.Once);
}
[Fact]
public async Task ReactivationAfterARevoke_SeatsNobody_ButActivates()
{
// The seat belongs to the first activation: a member the tenant removed stays removed.
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
_previewMock.Setup(x => x.WasRevokedAsync(TenantId)).ReturnsAsync(true);
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
VerifyNoPromotion();
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
}
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
// notification-email save path. Ordering contract under test: the sent-marker must be
// consumed strictly AFTER the address check (a marker consumed with no address would
@@ -11,12 +11,17 @@ namespace AutopilotMonitor.Functions.Tests;
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
/// via the SHARED CmTraceLogParser, and the format=text hint.
/// via the SHARED CmTraceLogParser, and the format=text hint — plus the bounds that keep the
/// caller's regex from occupying the shared Function App (D-379): first timeout ends the
/// test, the time budget, cancellation, the pattern-length cap and the per-instance gate.
/// </summary>
public class TestLogPatternFunctionTests
{
private static Regex AgentRegex(string pattern)
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static Regex AgentRegex(string pattern) => TestLogPatternFunction.BuildAgentRegex(pattern);
/// <summary>The production call minus the bounds: full budget, no cancellation.</summary>
private static LogPatternTestResult Evaluate(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
=> TestLogPatternFunction.EvaluatePattern(pattern, isTextMode, lines, TestLogPatternFunction.EvaluationBudget, CancellationToken.None);
private const string CmTraceLine =
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
@@ -27,7 +32,7 @@ public class TestLogPatternFunctionTests
[Fact]
public void TextMode_NamedGroups_LandInGroups()
{
var result = TestLogPatternFunction.EvaluatePattern(
var result = Evaluate(
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
isTextMode: true,
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
@@ -38,6 +43,7 @@ public class TestLogPatternFunctionTests
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
Assert.Equal("no_match", result.Lines[1].Outcome);
Assert.Null(result.StopReason);
}
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
@@ -45,8 +51,7 @@ public class TestLogPatternFunctionTests
[Fact]
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
var result = Evaluate(AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
Assert.Equal(0, result.MatchCount);
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
@@ -55,8 +60,7 @@ public class TestLogPatternFunctionTests
[Fact]
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
var result = Evaluate(AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
Assert.Equal(1, result.MatchCount);
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
@@ -67,8 +71,7 @@ public class TestLogPatternFunctionTests
[Fact]
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
var result = Evaluate(AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(1, result.MatchCount);
var row = result.Lines[0];
@@ -84,8 +87,7 @@ public class TestLogPatternFunctionTests
{
// "component=" only exists in the CMTrace envelope, never in the message the
// agent matches against — a pattern targeting it must NOT match.
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
var result = Evaluate(AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(0, result.MatchCount);
Assert.Equal("no_match", result.Lines[0].Outcome);
@@ -94,8 +96,7 @@ public class TestLogPatternFunctionTests
[Fact]
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
var result = Evaluate(AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
Assert.Equal(2, result.ParseFailureCount);
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
@@ -146,6 +147,45 @@ public class TestLogPatternFunctionTests
}
}
/// <summary>The cap is the MCP schema's value; it is checked in validation, i.e. before
/// the handler ever constructs a Regex from the caller's text.</summary>
[Fact]
public void Validate_RejectsOversizedPattern_AndAcceptsTheCapExactly()
{
var oversized = new TestLogPatternRequest
{
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength + 1),
SampleLines = new List<string> { "x" },
};
var error = TestLogPatternFunction.ValidateRequest(oversized);
Assert.NotNull(error);
Assert.Contains("pattern", error);
Assert.Contains(TestLogPatternFunction.MaxPatternLength.ToString(), error);
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternRequest
{
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength),
SampleLines = new List<string> { "x" },
}));
}
// ===== the regex construction =====
/// <summary>Agent parity minus Compiled: the semantic options and the 1 s match timeout
/// are the agent's; Compiled (speed only) is deliberately absent for the one-shot pattern.</summary>
[Fact]
public void BuildAgentRegex_IsCaseSensitive_Interpreted_WithTheAgentsMatchTimeout()
{
var regex = TestLogPatternFunction.BuildAgentRegex("ERROR");
Assert.Equal(RegexOptions.None, regex.Options);
Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout);
Assert.Equal(TestLogPatternFunction.MatchTimeout, regex.MatchTimeout);
Assert.DoesNotMatch(regex, "error");
// RegexParseException derives from ArgumentException — the handler's catch clause.
Assert.ThrowsAny<ArgumentException>(() => TestLogPatternFunction.BuildAgentRegex("(unclosed"));
}
// ===== hostile sample lines: the built-in CMTrace parser must stay bounded =====
/// <summary>A maximal request (200 x 8192-char lines) built from the reported worst case:
@@ -160,11 +200,117 @@ public class TestLogPatternFunctionTests
var lines = Enumerable.Repeat(line, TestLogPatternFunction.MaxSampleLines).ToArray();
var sw = Stopwatch.StartNew();
var result = TestLogPatternFunction.EvaluatePattern(AgentRegex("error"), isTextMode: false, lines);
var result = Evaluate(AgentRegex("error"), isTextMode: false, lines);
sw.Stop();
Assert.Equal(TestLogPatternFunction.MaxSampleLines, result.ParseFailureCount);
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
Assert.Null(result.StopReason);
Assert.True(sw.ElapsedMilliseconds < 1000, $"maximal worst-case request took {sw.ElapsedMilliseconds} ms");
}
// ===== hostile pattern: the caller's regex must stay bounded (D-379) =====
/// <summary>The reported attack: a catastrophic pattern over 200 non-matching lines used to
/// cost one 1 s timeout per line (~200 s of a worker thread). The first timeout now ends the
/// test — one row, the reason, and the unevaluated range named in a note.</summary>
[Fact]
public void TextMode_CatastrophicPattern_StopsAtTheFirstTimeout()
{
var hostile = new string('a', 60) + "!";
var lines = Enumerable.Repeat(hostile, TestLogPatternFunction.MaxSampleLines).ToArray();
var sw = Stopwatch.StartNew();
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, lines);
sw.Stop();
Assert.Equal(1, result.TimeoutCount);
Assert.Single(result.Lines);
Assert.Equal("regex_timeout", result.Lines[0].Outcome);
Assert.Equal(TestLogPatternFunction.StopReasonRegexTimeout, result.StopReason);
Assert.Contains(result.Notes, n => n.Contains("line 1 hit") && n.Contains("lines 2-200 were not evaluated"));
Assert.DoesNotContain(result.Notes, n => n.Contains("case-SENSITIVE"));
Assert.True(sw.ElapsedMilliseconds < 4000, $"catastrophic request took {sw.ElapsedMilliseconds} ms");
}
/// <summary>A timeout on the LAST line is a complete test: its row and note stay, but
/// nothing was skipped, so there is no stop reason.</summary>
[Fact]
public void TextMode_TimeoutOnTheLastLine_IsNotAStop()
{
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, new[] { "a b", new string('a', 60) + "!" });
Assert.Equal(1, result.MatchCount);
Assert.Equal(1, result.TimeoutCount);
Assert.Equal(2, result.Lines.Count);
Assert.Equal("regex_timeout", result.Lines[1].Outcome);
Assert.Null(result.StopReason);
Assert.Contains(result.Notes, n => n.Contains("line 2 hit") && !n.Contains("not evaluated"));
}
/// <summary>The budget is checked before every line but the first, so a spent budget
/// (here: zero) still yields exactly one evaluated line and names the rest.</summary>
[Fact]
public void BudgetSpent_StopsBeforeTheNextLine_AndNamesTheRest()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two", "ERROR three" },
TimeSpan.Zero, CancellationToken.None);
Assert.Single(result.Lines);
Assert.Equal("matched", result.Lines[0].Outcome);
Assert.Equal(TestLogPatternFunction.StopReasonTimeBudget, result.StopReason);
Assert.Contains(result.Notes, n => n.Contains("after line 1") && n.Contains("lines 2-3 were not evaluated"));
var twoLines = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two" },
TimeSpan.Zero, CancellationToken.None);
Assert.Contains(twoLines.Notes, n => n.Contains("line 2 was not evaluated"));
}
/// <summary>A cancelled invocation (client gone, host stopping) ends the loop before the
/// next line without throwing — a thrown OperationCanceledException would surface as a
/// failed invocation in App Insights.</summary>
[Fact]
public void CancelledToken_StopsBeforeTheFirstLine_WithoutThrowing()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one" },
TestLogPatternFunction.EvaluationBudget, cts.Token);
Assert.Empty(result.Lines);
Assert.Equal(TestLogPatternFunction.StopReasonCancelled, result.StopReason);
Assert.Equal(0, result.MatchCount);
}
// ===== the per-instance gate =====
[Fact]
public void Gate_HandsOutLimitSlots_AndTakesThemBackOnDispose()
{
var gate = new TestLogPatternGate(1);
Assert.Equal(1, gate.Limit);
var first = gate.TryEnter();
Assert.NotNull(first);
Assert.Null(gate.TryEnter());
first.Dispose();
first.Dispose(); // idempotent: a double release must not widen the gate
var second = gate.TryEnter();
Assert.NotNull(second);
Assert.Null(gate.TryEnter());
second.Dispose();
}
[Fact]
public void Gate_DefaultLimit_IsTwoPerInstance()
{
var gate = new TestLogPatternGate();
Assert.Equal(2, gate.Limit);
Assert.Equal(TestLogPatternGate.DefaultLimit, gate.Limit);
}
}
@@ -551,7 +551,7 @@ public class TypedContractCompletionWireParityTests
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
historyRowKey = (string?)null, // in-app entry → key vanishes
domainName = (string?)null,
feedbackId = (string?)null,
feedbackId = (string?)"a1b2c3d4e5f6", // every kind carries its id
contactEmail = (string?)null,
dismissCount = (int?)1,
},
@@ -568,7 +568,7 @@ public class TypedContractCompletionWireParityTests
interactedAt = (string?)"2026-10-05T09:30:00.0000000Z",
historyRowKey = (string?)null,
domainName = (string?)"fabrikam.invalid",
feedbackId = (string?)"2516114433936827287_0123456789ab",
feedbackId = (string?)"0123456789ab",
contactEmail = (string?)"bob.support@fabrikam.invalid",
dismissCount = (int?)null, // general entry → no count
},
@@ -591,6 +591,7 @@ public class TypedContractCompletionWireParityTests
InteractedAt = "2026-08-30T10:00:00.0000000Z",
HistoryRowKey = null,
DomainName = null,
FeedbackId = "a1b2c3d4e5f6",
DismissCount = 1,
},
new FeedbackEntryWire
@@ -605,7 +606,7 @@ public class TypedContractCompletionWireParityTests
Submitted = false,
InteractedAt = "2026-10-05T09:30:00.0000000Z",
DomainName = "fabrikam.invalid",
FeedbackId = "2516114433936827287_0123456789ab",
FeedbackId = "0123456789ab",
ContactEmail = "bob.support@fabrikam.invalid",
},
},
@@ -742,7 +743,7 @@ public class TypedContractCompletionWireParityTests
versionBreakdown = Array.Empty<object>(),
installerPhaseBreakdown = Array.Empty<object>(),
topFailureCodes = Array.Empty<object>(),
detectionLiesCount = 0,
detectionFailureCount = 0,
deviceModelBreakdown = Array.Empty<object>(),
versionRegressions = regressions,
},
@@ -775,7 +776,7 @@ public class TypedContractCompletionWireParityTests
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
DetectionLiesCount = 0,
DetectionFailureCount = 0,
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
VersionRegressions = regressions,
});
@@ -20,6 +20,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// 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).
/// E keeps every body read inside RequestBody's counted cap (the body size is counted after
/// decompression, whatever Content-Length claims) and the worker's body limit in one place.
/// </summary>
public class TypedRequestGuardTests
{
@@ -171,19 +173,89 @@ public class TypedRequestGuardTests
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
[Fact]
public void E_EveryBodyReadIsCounted()
{
// The whole project, not only Functions/: a middleware that buffers the body is the same exposure.
var violations = new List<string>();
var allowedSeen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (relative, original) in ProjectSources())
{
if (relative.Equals(BodyReaderFile, StringComparison.OrdinalIgnoreCase)) continue;
var text = original;
foreach (var shape in AllowedBodyReads.Where(s => original.Contains(s, StringComparison.Ordinal)))
{
allowedSeen.Add(shape);
text = text.Replace(shape, string.Empty, StringComparison.Ordinal);
}
var requestNames = RequestDeclaration.Matches(text).Select(m => m.Groups["name"].Value).Distinct(StringComparer.Ordinal);
foreach (var name in requestNames)
{
var direct = Regex.Matches(text, $@"\b{Regex.Escape(name)}\.(Body|BodyReader)\b").Count;
if (direct > 0)
violations.Add($"{relative}: {direct}× {name}.Body — read it through RequestBody (ReadAsync<T>, or ReadCappedAsync for a foreign parser).");
}
foreach (Match raw in RawBodyRead.Matches(text))
violations.Add($"{relative}: '{raw.Value}' reads a body outside RequestBody's counted cap.");
if (!relative.Equals(BodyLimitFile, StringComparison.OrdinalIgnoreCase) && text.Contains("MaxRequestBodySize", StringComparison.Ordinal))
violations.Add($"{relative}: MaxRequestBodySize is set only in {BodyLimitFile} (RequestBody.WorkerMaxBytes).");
}
Assert.True(violations.Count == 0, "Request bodies read outside the counted reader:\n " + string.Join("\n ", violations));
var stale = AllowedBodyReads.Where(s => !allowedSeen.Contains(s)).ToList();
Assert.True(stale.Count == 0, "Stale allowed body reads (remove them):\n " + string.Join("\n ", stale));
}
/// <summary>The counted reader itself, and the one place that sets the worker's body limit.</summary>
private const string BodyReaderFile = "Helpers/RequestBody.cs";
private const string BodyLimitFile = "Middleware/HttpCompressionStartupFilter.cs";
/// <summary>
/// Body reads with their own counted cap, allowed by call shape so the rest of their file stays
/// scanned. Each entry names why.
/// </summary>
private static readonly string[] AllowedBodyReads =
{
// Telemetry ingest: buffers through RequestBody.ReadWithinAsync under the tenant's payload cap.
"ReadBodyWithSizeCapAsync(req.Body",
};
/// <summary>A name declared as a request: a handler parameter, a typed local, a middleware lookup.</summary>
private static readonly Regex RequestDeclaration = new(
@"\bHttpRequest(?:Data)?\??\s+(?<name>\w+)\b|\bvar\s+(?<name>\w+)\s*=\s*await\s+[\w.]+GetHttpRequestDataAsync\(",
RegexOptions.Compiled);
/// <summary>
/// Body reads that need no declared name: the HttpRequestData string/JSON readers (an HttpClient
/// response's Content is fine), the ASP.NET request stream, form and buffering, and MVC body binding,
/// which the Functions extension supports and which only Kestrel's limit would bound.
/// </summary>
private static readonly Regex RawBodyRead = new(
@"(?<!Content)\.ReadAsStringAsync\(|(?<!Content)\.ReadFromJsonAsync\b|\.Request\.Body(Reader)?\b|\bReadFormAsync\(|\bEnableBuffering\(|\[FromBody\]",
RegexOptions.Compiled);
private static IEnumerable<(string Relative, string Text)> HandlerSources()
=> ProjectSources().Where(s => s.Relative.StartsWith("Functions/", StringComparison.OrdinalIgnoreCase));
private static IEnumerable<(string Relative, string Text)> ProjectSources()
{
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))
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
if (relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
if (IsBuildOutput(relative))
continue;
yield return (relative, File.ReadAllText(file));
}
}
private static bool IsBuildOutput(string relative)
=> relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) || relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase)
|| relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase);
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
@@ -0,0 +1,112 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the hardware lists of the usage metrics: ranked and shared by sessions, with the distinct
/// devices behind them keyed like device journeys (normalized serial per tenant) and a session
/// without a usable serial counting as its own device.
/// </summary>
public class UsageMetricsHardwareTests
{
private const string TenantA = "00000000-0000-0000-0000-00000000000a";
private const string TenantB = "00000000-0000-0000-0000-00000000000b";
private static SessionSummary Session(string id, string serial, string manufacturer = "Contoso",
string model = "Book 14", string tenantId = TenantA) => new()
{
SessionId = id,
TenantId = tenantId,
SerialNumber = serial,
Manufacturer = manufacturer,
Model = model,
StartedAt = DateTime.UtcNow.AddHours(-1),
Status = SessionStatus.Succeeded,
};
[Fact]
public void A_reinstalled_device_counts_once_per_session_and_once_as_a_device()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
{
Session("s1", "SN-1001"),
Session("s2", "SN-1001"),
Session("s3", "SN-1001"),
});
var row = Assert.Single(m.TopModels);
Assert.Equal(3, row.Count);
Assert.Equal(1, row.DeviceCount);
Assert.Equal(100, row.Percentage);
Assert.Equal(1, Assert.Single(m.TopManufacturers).DeviceCount);
}
[Fact]
public void Serials_differing_in_case_or_whitespace_are_one_device()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
{
Session("s1", "sn-1001"),
Session("s2", " SN-1001 "),
});
Assert.Equal(1, Assert.Single(m.TopModels).DeviceCount);
}
[Fact]
public void Sessions_without_a_usable_serial_count_as_one_device_each()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
{
Session("s1", "To be filled by O.E.M."),
Session("s2", "To be filled by O.E.M."),
Session("s3", ""),
Session("s4", "SN-1001"),
});
var row = Assert.Single(m.TopModels);
Assert.Equal(4, row.Count);
Assert.Equal(4, row.DeviceCount);
}
[Fact]
public void The_same_serial_in_two_tenants_is_two_devices()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
{
Session("s1", "SN-1001", tenantId: TenantA),
Session("s2", "SN-1001", tenantId: TenantB),
});
Assert.Equal(2, Assert.Single(m.TopModels).DeviceCount);
}
[Fact]
public void Lists_rank_by_sessions_not_devices()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
{
// One device enrolled three times outranks two devices enrolled once each.
Session("s1", "SN-1001", "Fabrikam", "Desk 5"),
Session("s2", "SN-1001", "Fabrikam", "Desk 5"),
Session("s3", "SN-1001", "Fabrikam", "Desk 5"),
Session("s4", "SN-2001"),
Session("s5", "SN-2002"),
});
Assert.Equal(new[] { "Desk 5", "Book 14" }, m.TopModels.Select(r => r.Name));
Assert.Equal(new[] { 3, 2 }, m.TopModels.Select(r => r.Count));
Assert.Equal(new[] { 1, 2 }, m.TopModels.Select(r => r.DeviceCount));
Assert.Equal(new[] { 60.0, 40.0 }, m.TopModels.Select(r => r.Percentage));
}
[Fact]
public void Empty_window_yields_empty_lists()
{
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>());
Assert.Empty(m.TopManufacturers);
Assert.Empty(m.TopModels);
}
}
@@ -39,6 +39,7 @@ public class UsageMetricsProjectionEquivalenceTests
["StartedAt"] = new DateTimeOffset(startedAt),
["Manufacturer"] = "Contoso",
["Model"] = "Model-X",
["SerialNumber"] = "SN-FULL",
["IsUserDriven"] = true,
["IsPreProvisioned"] = isPreProvisioned,
["EnrollmentType"] = enrollmentType,
@@ -48,7 +49,6 @@ public class UsageMetricsProjectionEquivalenceTests
["RemediationScriptCount"] = 2,
// Representative noise the usage compute never reads — present on a real wide row,
// absent on the projected one. Proves the projection drops it without changing results.
["SerialNumber"] = "SN-FULL",
["DeviceName"] = "PC-FULL",
["OsName"] = "Windows 11",
["GeoCountry"] = "DE",
@@ -88,6 +88,7 @@ public class UsageMetricsProjectionEquivalenceTests
Assert.Equal(fromFull.Status, fromProjected.Status);
Assert.Equal(fromFull.Manufacturer, fromProjected.Manufacturer);
Assert.Equal(fromFull.Model, fromProjected.Model);
Assert.Equal(fromFull.SerialNumber, fromProjected.SerialNumber);
Assert.Equal(fromFull.IsPreProvisioned, fromProjected.IsPreProvisioned);
Assert.Equal(fromFull.EnrollmentType, fromProjected.EnrollmentType);
Assert.Equal(fromFull.IsHybridJoin, fromProjected.IsHybridJoin);
@@ -128,28 +128,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return true;
}
public async Task<bool> DisableGlobalAdminAsync(string upn)
{
upn = upn.ToLowerInvariant();
try
{
var result = await _globalAdminsTableClient.GetEntityAsync<GlobalAdminEntity>(
"GlobalAdmins", upn);
var entity = result.Value;
if (entity != null)
{
entity.IsEnabled = false;
await _globalAdminsTableClient.UpdateEntityAsync(entity, ETag.All);
}
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
// --- MCP Users ---
public async Task<McpUserEntry?> GetMcpUserAsync(string upn)
@@ -825,12 +803,33 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
{
await _tenantAdminsTableClient.UpsertEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens));
return true;
}
public async Task<bool> TryAddTenantMemberAsync(string tenantId, string upn, string addedBy, string role)
{
try
{
// Conditional INSERT, never upsert: a row the tenant already holds for this UPN
// (disabled, demoted) stays as the tenant set it, and two first-admin writers for
// the same UPN cannot both land.
await _tenantAdminsTableClient.AddEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens: false));
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
return false;
}
}
private static TenantAdminEntity NewMemberEntity(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
addedBy = addedBy.ToLowerInvariant();
var entity = new TenantAdminEntity
return new TenantAdminEntity
{
PartitionKey = tenantId,
RowKey = upn,
@@ -838,21 +837,20 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
AddedBy = addedBy,
AddedBy = addedBy.ToLowerInvariant(),
Role = role,
CanManageBootstrapTokens = canManageBootstrapTokens
};
await _tenantAdminsTableClient.UpsertEntityAsync(entity);
return true;
}
public async Task<bool> RemoveTenantMemberAsync(string tenantId, string upn)
{
tenantId = tenantId.ToLowerInvariant();
upn = upn.ToLowerInvariant();
await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
return true;
// A missing row does NOT throw, DeleteEntityAsync returns the 404 response (see
// RemoveDelegatedAdminAsync): report it, so removing a non-member 404s instead of auditing.
var response = await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
return response.Status != 404;
}
public async Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens)
@@ -921,14 +919,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> IsTenantAdminAsync(string tenantId, string upn)
{
var member = await GetTenantMemberAsync(tenantId, upn);
if (member == null || !member.IsEnabled) return false;
// Only true for Admin role (null Role = Admin for backward compat)
return member.Role == null || member.Role == Constants.TenantRoles.Admin;
}
// --- Helpers ---
private static DelegatedAdminEntry MapToDelegatedEntry(DelegatedAdminEntity entity)
@@ -751,6 +751,32 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task MarkRevokedAsync(string tenantId, string revokedBy)
{
var entity = new TableEntity(tenantId, "revoked")
{
{ "RevokedAt", DateTime.UtcNow },
{ "RevokedBy", revokedBy }
};
// Upsert (a second revoke refreshes the record); errors throw, because the revoke must
// not proceed without its record — the seat guard of a later re-activation reads it.
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
}
public async Task<bool> WasRevokedAsync(string tenantId)
{
try
{
var entity = await _previewWhitelistTableClient.GetEntityAsync<TableEntity>(tenantId, "revoked");
return entity?.Value != null;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
// --- Tenant Configuration Entity Mapping ---
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
@@ -833,6 +859,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer },
{ "EnableIntegrityBypassAnalyzer", config.EnableIntegrityBypassAnalyzer },
{ "EnableRealmJoinWatcher", config.EnableRealmJoinWatcher },
{ "EnableConfigMgrWatcher", config.EnableConfigMgrWatcher },
{ "KeepAwakeDuringUserEsp", config.KeepAwakeDuringUserEsp },
{ "EnableConsoleBypassDetection", config.EnableConsoleBypassDetection },
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
@@ -954,6 +981,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"),
EnableIntegrityBypassAnalyzer = entity.GetBoolean("EnableIntegrityBypassAnalyzer"),
EnableRealmJoinWatcher = entity.GetBoolean("EnableRealmJoinWatcher"),
EnableConfigMgrWatcher = entity.GetBoolean("EnableConfigMgrWatcher"),
KeepAwakeDuringUserEsp = entity.GetBoolean("KeepAwakeDuringUserEsp"),
EnableConsoleBypassDetection = entity.GetBoolean("EnableConsoleBypassDetection"),
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
@@ -68,6 +68,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
next.Type = FeedbackEntryType.InApp;
next.Upn = rowKey;
next.FeedbackId = stored?.GetString("FeedbackId") ?? NewFeedbackId();
try
{
// Conditional on what was read: two tabs answering at once must not let a dismissal
@@ -117,6 +118,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
"HistoryRowKey required for offboarding feedback", nameof(entry));
try
{
// A second comment in the same offboarding replaces the row and keeps its id.
entry.FeedbackId = (await GetOffboardingFeedbackAsync(entry.HistoryRowKey))?.FeedbackId ?? NewFeedbackId();
await _tableClient.UpsertEntityAsync(StoreOffboarding(entry), TableUpdateMode.Replace);
}
catch (Exception ex)
@@ -134,7 +137,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
entry.Type = FeedbackEntryType.General;
entry.InteractedAt ??= DateTime.UtcNow;
entry.Upn = entry.Upn.ToLowerInvariant();
entry.FeedbackId = $"{RowKeyCodec.InvertedTicks(entry.InteractedAt.Value)}_{Guid.NewGuid().ToString("N")[..12]}";
entry.FeedbackId = NewFeedbackId();
try
{
// Insert, never upsert: every submission is its own row.
@@ -209,6 +212,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["Submitted"] = e.Submitted,
["DismissCount"] = e.DismissCount,
["InteractedAt"] = e.InteractedAt,
["FeedbackId"] = e.FeedbackId,
};
private static FeedbackEntry MapInApp(TableEntity e)
@@ -228,6 +232,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// Rows from before the count existed: one recorded dismissal at most.
DismissCount = e.GetInt32("DismissCount") ?? (dismissed && !submitted ? 1 : 0),
InteractedAt = e.GetDateTime("InteractedAt"),
FeedbackId = e.GetString("FeedbackId"),
};
}
@@ -240,6 +245,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["DomainName"] = e.DomainName,
["Comment"] = e.Comment,
["InteractedAt"] = e.InteractedAt,
["FeedbackId"] = e.FeedbackId,
};
private static FeedbackEntry MapOffboarding(TableEntity e) => new()
@@ -252,10 +258,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
DomainName = e.GetString("DomainName"),
Comment = e.GetString("Comment"),
InteractedAt = e.GetDateTime("InteractedAt"),
FeedbackId = e.GetString("FeedbackId"),
};
private static TableEntity StoreGeneral(FeedbackEntry e) =>
new(FeedbackEntryType.General, e.FeedbackId!)
new(FeedbackEntryType.General, $"{RowKeyCodec.InvertedTicks(e.InteractedAt!.Value)}_{e.FeedbackId}")
{
["TenantId"] = e.TenantId,
["Upn"] = e.Upn,
@@ -264,12 +271,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["Comment"] = e.Comment,
["ContactEmail"] = e.ContactEmail,
["InteractedAt"] = e.InteractedAt,
["FeedbackId"] = e.FeedbackId,
};
private static FeedbackEntry MapGeneral(TableEntity e) => new()
{
Type = FeedbackEntryType.General,
FeedbackId = e.RowKey,
FeedbackId = e.GetString("FeedbackId"),
TenantId = e.GetString("TenantId") ?? string.Empty,
Upn = e.GetString("Upn") ?? string.Empty,
DisplayName = e.GetString("DisplayName") ?? string.Empty,
@@ -289,6 +297,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
TenantId = e.GetString("TenantId") ?? string.Empty,
Comment = e.GetString("Comment"),
InteractedAt = e.GetDateTime("InteractedAt"),
FeedbackId = e.GetString("FeedbackId"),
};
// Same shape as a session report id. Minted once per row; every later write of the row keeps it.
private static string NewFeedbackId() => Guid.NewGuid().ToString("N")[..12];
}
}
@@ -20,9 +20,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics)
=> _storage.SaveUsageMetricsSnapshotAsync(metrics);
public Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(
string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100)
=> _storage.GetUsageMetricsSnapshotAsync(tenantId, startDate, endDate, maxResults);
public Task<List<TenantActivityDay>> GetTenantActivityDaysAsync(DateTime fromDay, DateTime toDay)
=> _storage.GetTenantActivityDaysAsync(fromDay, toDay);
public Task<bool> HasUsageMetricsSnapshotAsync(string date)
=> _storage.HasUsageMetricsSnapshotAsync(date);
@@ -27,7 +27,7 @@ public class DelegatedAdminManagementFunction
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
private readonly LiveStreamRevoker _streams;
private readonly DelegatedSlotService _slots;
public DelegatedAdminManagementFunction(
@@ -35,14 +35,14 @@ public class DelegatedAdminManagementFunction
DelegatedAdminService delegatedAdminService,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService,
LiveStreamRevoker streams,
DelegatedSlotService slots)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
_streams = streams;
_slots = slots;
}
@@ -159,7 +159,7 @@ public class DelegatedAdminManagementFunction
await _maintenanceRepo.LogAuditEntryAsync(
tenantId.ToLowerInvariant(), "DELETE", "DelegatedAdmin", upn.ToLowerInvariant(), currentUpn ?? "");
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
await _streams.CutBoundIdentityAsync(upn);
_logger.LogInformation("Delegated revoke: {Upn} -> {TenantId} by {By}", upn, tenantId, currentUpn);
return true;
@@ -210,7 +210,7 @@ public class DelegatedAdminManagementFunction
new Dictionary<string, string> { { "IsEnabled", isEnabled.ToString() } });
if (!isEnabled)
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
await _streams.CutBoundIdentityAsync(upn);
_logger.LogInformation("Delegated {Action}: {Upn} -> {TenantId} by {By}",
isEnabled ? "enable" : "disable", upn, tenantId, currentUpn);
@@ -17,7 +17,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
/// Grants create a binding implicitly and REFUSE to overwrite one (409); this surface is the explicit, audited
/// path for the two legitimate changes: re-homing a UPN in another tenant, and re-pinning the object id after
/// the UPN was re-assigned to a new account (PUT without objectId clears the pin so the next sign-in from the
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows.
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows. Replacing or
/// deleting a binding cuts the live streams signed in under the previous one.
///
/// Reads are GlobalReadOrAdmin; mutations are GlobalAdminOnly (route catalog). Audited under the binding's
/// HOME tenant with entity <c>AdminIdentityBinding</c>.
@@ -27,17 +28,20 @@ public class IdentityBindingManagementFunction
private readonly ILogger<IdentityBindingManagementFunction> _logger;
private readonly AdminIdentityBindingService _bindings;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly LiveStreamRevoker _streams;
private const string AuditEntity = "AdminIdentityBinding";
public IdentityBindingManagementFunction(
ILogger<IdentityBindingManagementFunction> logger,
AdminIdentityBindingService bindings,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
LiveStreamRevoker streams)
{
_logger = logger;
_bindings = bindings;
_maintenanceRepo = maintenanceRepo;
_streams = streams;
}
/// <summary>GET /api/global/identity-bindings — every binding. GlobalReadOrAdmin.</summary>
@@ -93,6 +97,11 @@ public class IdentityBindingManagementFunction
previous == null ? "Created" : "Replaced", upn, binding.TenantId,
binding.IsObjectIdPinned ? binding.ObjectId : "(unpinned)", currentUpn);
// Every platform and delegated grant of the UPN resolved under the previous binding: cut those
// connections, so their re-join meets the new one.
if (previous != null)
await _streams.CutAsync(previous.TenantId, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new IdentityBindingResponse { Binding = binding });
return response;
@@ -128,6 +137,7 @@ public class IdentityBindingManagementFunction
});
_logger.LogWarning("[IdentityBinding] Removed binding for {Upn} (was tenant {TenantId}) by {By}",
upn, previous.TenantId, currentUpn);
await _streams.CutAsync(previous.TenantId, upn);
return await req.OkAsync(new MessageResponse { Message = "Binding removed" });
}
@@ -183,7 +183,7 @@ public class SubmitOffboardingFeedbackFunction
try
{
await _opsEvents.RecordOffboardingFeedbackReceivedAsync(
normalizedTenantId, upn, domainName, marker.OffboardingHistoryRowKey);
normalizedTenantId, upn, domainName, marker.OffboardingHistoryRowKey, entry.FeedbackId!);
}
catch (Exception ex)
{

Some files were not shown because too many files have changed in this diff Show More