Compare commits

..

68 Commits

Author SHA1 Message Date
Oliver Kieselbach 640a4fec04 bump pointer: internal (D-415 SWA CLI lockfile, package gate)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:50:16 +01:00
Oliver Kieselbach 96d2e6610a deploy-web: SWA CLI from a committed lockfile; gate against package fetches at unfixed versions
The deploy job holds the SWA deployment token, and it ran the SWA CLI through
`npx --yes --package`: only the top package was pinned, its dependency tree came
from the registry at run time, install scripts included. The job now installs the
CLI from .github/tools/swa-cli/package-lock.json with `npm ci --ignore-scripts`,
right after the checkout and before the build artifact arrives, and runs that
binary with the same arguments. keytar, the one package with an install script,
only serves the keychain, which --no-use-keychain keeps unloaded. Dependabot
proposes bumps with the 7-day cooldown of the other tooling; deploy-web watches
the folder, so a merged bump redeploys.

action-pins.yml gets a fourth job, packages. It allows npx and npm exec only as
`--no -- <tool>` (without --no, CI installs a missing package unasked; without --,
npx hands the tool's flags to npm), .NET tools only with an exact --version and
PowerShell modules only with an exact version. zizmor's adhoc-packages audit
reports `npm install <pkg>` but none of these forms. CI's three npx calls and the
Pester install of the bootstrap gates (6.2.0, the version they resolve today)
follow the rule.

Decision D-415.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:48:27 +01:00
Oliver Kieselbach a510418623 bump pointer: internal (D-414 keep-awake page-closed latch)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:43:13 +01:00
Oliver Kieselbach 0d43abf249 Keep-awake: no re-engage after an agent restart once the user-ESP page closed
After an agent restart the AccountSetup phase reaches the rail again: the IME
adapter's phase dedup lives per process and IME logs the phase again, for example
at the next sign-in. The keep-awake host then took the hold up once more and kept
the display on until its cap or the agent stopped, although the user ESP was over.

The host now latches the user-ESP page exit (EspExiting after AccountSetup progress)
in the StartupEventGate, which keeps per-enrollment state across restarts, and
Engage checks that latch first. The exit is evaluated in every hold state - idle,
capped and waiting for RealmJoin included - so the latch is set whenever the page
closes. A restart during the user ESP has no latch yet and still engages. A hold
that was waiting for RealmJoin is not taken up again after the restart. Without a
gate nothing changes.

Decision D-414.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:43:09 +01:00
Oliver Kieselbach a867fea5bf bump pointer: internal (D-413 keep-awake hand-over to RealmJoin)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:45:54 +01:00
Oliver Kieselbach 1a0147c942 Keep-awake hands the hold over to RealmJoin; realmjoin_detected carries the current phase
With the RealmJoin Watcher on, the User-ESP keep-awake no longer ends when the
user-ESP page closes while RealmJoin has not started its deployment: the hold lasts
until RealmJoin leaves Blank (deployment phase 100+), at most 30 minutes after the
page closed. Release reasons realmjoin_started (waitedSeconds) and
realmjoin_not_started (waitMinutes) join the existing ones. RealmJoin leaving Blank
ends any hold, and once it has, the host does not engage again in that process.
Without RealmJoin signals nothing changes; no new setting.

RealmJoinWatcher reads the deployment phase when the service key attaches and passes
it to Detected. Detected reported Blank even when the agent restarted mid-deployment,
which reset the engine's persisted phase and would have hidden the start from the
keep-awake host. A Blank report is now the baseline, so a first value of 100+
surfaces as PhaseChanged instead of silently.

The keep-awake host is created before the RealmJoin host, so it sees a Detected that
the watcher posts from its own start (pinned in DefaultComponentFactoryOrderingTests).
Portal toggle text and the keep_awake_released event-type description updated; the
description still named the retired account_setup_complete reason.

Decision D-413.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:44:04 +01:00
Oliver Kieselbach 4ba5a2af55 bump pointer: internal (D-412)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:56:32 +01:00
Oliver Kieselbach 327b5b07d0 Agent tests: placeholder tenant domain, comments without customer or session
The PolicyJsonCache fixture uses contoso.onmicrosoft.com; the comments
keep the device and build that describe the case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:56:31 +01:00
Oliver Kieselbach 141b3ca737 bump pointer: internal (D-410 released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:33:18 +01:00
Oliver Kieselbach 9b9ec8586e bump pointer: internal, .claude/commands (D-410 limits)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:43:18 +01:00
Oliver Kieselbach 877c50ce6c Rule evaluation: gates above the measured peaks, 2 s regex match timeout
The rule dry-run gate now admits 4 concurrent tests per instance (was 2),
"Analyze now" 5 (was 4) and the logparser pattern test 3 (was 2). AI
clients fire tool calls in parallel, three dry-runs at once were measured
on one instance, so each limit sits above its measured peak.

An analyze rule's regex match now times out after 2 s instead of 1 s,
above the longest process stall measured (1.85 s), so a pause no longer
stops a legitimate rule. The pattern test keeps the agent's 1 s. The
portal banner, the MCP authoring guide and the ops-event text follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:42:36 +01:00
Oliver Kieselbach dcefbda2bf bump pointer: internal (lessons)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:58:41 +01:00
Oliver Kieselbach e10f1ffda9 bump pointer: internal, .claude/commands (D-410 review follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:57:52 +01:00
Oliver Kieselbach a4f9d89c02 Rule evaluation budget: a share per rule; catalog stops keep their own alarm budget
Follow-up to 20bf50c9 after review:
- Each rule gets a 2 s share of the run's 5 s. A rule that spends its share
  stops alone and the run goes on; only a spent run budget skips the later
  rules. One expensive rule (a device can slow catalog rules by flooding its
  own session) can no longer skip every rule after it. The run bound stays
  5 s.
- RuleEvaluationStopped is throttled per class: catalog-rule stops keep
  their own 20 per instance and hour, custom-rule stops at most 5 per tenant
  and 50 in all, so one tenant's custom rules cannot crowd out other alarms.
  The event now says whether the stop ended the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:57:14 +01:00
Oliver Kieselbach 943087a026 bump pointer: internal, .claude/commands (D-410/D-411)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:47:40 +01:00
Oliver Kieselbach 20bf50c935 Analyze rules and hardware whitelist: tenant patterns run on a budget
Tenant-authored regex conditions could make the shared backend spend one
second per event and condition: a match timeout read as "no match" and the
next event paid again, with no limit per request or per analysis run.

Analyze rules (D-410):
- Every evaluation pass has a time budget: 5 s for all rules of an analysis
  run (queue, interim, Analyze now), 2 s plus the request token for a
  dry-run. It is checked before every operator and in the evaluator scans
  that run without one.
- The first regex match timeout stops that rule; a spent budget stops the
  run. A rule that does not finish keeps its stored result; Analyze now
  writes those rows back after its rebuild and names them (stoppedRuleIds).
- The dry-run answers verdict "stopped" with stopReason and stopNote.
- Dry-run, Analyze now and the pattern test take a slot per instance
  (2 / 4 / 2) and answer 429 with Retry-After when full. A full gate and a
  stopped rule raise throttled ops events (RuleEvaluationLimitReached,
  RuleEvaluationStopped).
- Rules are capped at 20 conditions, 10 preconditions and 2,000-character
  regex values (condition, filter, event A filter and precondition values);
  every regex must parse. Checked on dry-run, create, update and template
  copy; the schema carries both counts.
- Portal: the dry-run panel shows the stop, a failed Analyze now click is
  reported, and a banner lists rules that stopped. MCP: both dry-run tools
  wait out a full gate once.

Hardware whitelist (D-411): wildcard entries are matched without building
a regex per entry and request; a device value over 256 characters only
meets "*".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:45:51 +01:00
Oliver Kieselbach a82a82757a bump pointer: .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:40:51 +01:00
Oliver Kieselbach 6676565d5f bump pointer: internal (D-409 verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:33:00 +01:00
Oliver Kieselbach b9083c93df bump pointer: internal (D-409 released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:14:50 +01:00
Oliver Kieselbach 2c79d61003 bump pointer: internal (B-4r6 done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:54:21 +01:00
Oliver Kieselbach bb8ac2181e Dashboard: the Global Admin mode refetch fires only on a real change
The effect that clears and reloads the session list when Global Admin mode changes skipped its first run with a latch. In development, StrictMode runs every effect twice on mount, so the second run looked like a toggle and fetched before sign-in was ready. Every dashboard load under next dev then reported a false "Backend Not Reachable". The effect now compares against the previous mode. Production behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:53:34 +01:00
Oliver Kieselbach 684f66f560 bump pointer: internal (B-4r6 cause)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:41:55 +01:00
Oliver Kieselbach 86e98f39d5 bump pointer: internal (D-409 visual check, B-4r6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:32:55 +01:00
Oliver Kieselbach 0be30de253 bump pointer: internal (D-409, B-4uz, B-ayc)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:55:09 +01:00
Oliver Kieselbach 942650890a Group tag: the Autopilot registration rides on the session, and Fleet Health shows and hides by group tag
The Autopilot device validation already received the device's registration from Microsoft Graph and kept only its id. The session now stores the group tag, purchase order identifier, Autopilot device name, object id and whether a user is assigned; the user's name is never stored. These are server facts: they travel beside the agent's registration, which no longer carries a validator field.

- Session detail shows an Autopilot Registration (Intune) block, the session list has a Group Tag column, the search grammar knows grouptag= on both sides, and the session CSV export appends the fields.
- MCP: groupTag on search_sessions and query_raw_sessions, the registration in get_session_summary.
- Fleet Health shows the success rate per group tag and can hide tags from the view (excludeGroupTags, excludeUntagged). The filter lives in the request and the page address. A chip names it and how many sessions it takes out, and sections whose data has no group tag say so. Nothing is stored, and alerts keep counting every session.
- The device-property search path applies the same column filters as the scan path. The unused unpaged search is removed.
- Privacy page: the new data source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:49:31 +01:00
Oliver Kieselbach a8f2fb2368 Sessions: an agent restart keeps every column the registration does not own
StoreSessionAsync rewrites the row with Replace and carried only a hand-picked list. Columns written by other writers were lost on every agent restart until their next merge: the IME version, the last ingest time, the excessive-events latch, the API latency figures, the block record and queued server actions.

The registration now names the columns it owns. Every other column of the index manifest and the primary-only list is carried from the fresh read, which now reads the whole row. A CAS retry carries the values of its own read. A test walks the manifest, so a column added later is carried without touching StoreSessionAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:57:12 +01:00
Oliver Kieselbach 924ca6c916 bump pointer: internal (D-408 live), .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:21:45 +01:00
Oliver Kieselbach 3f40531b33 bump pointer: internal (D-408, B-juf, B-qu9), .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:59:04 +01:00
Oliver Kieselbach 7b336c8cf0 Device validation: the Cloud PC add-on notice counts only self-reported Cloud PCs
The Cloud PC lookup sees every device that missed the Autopilot and
corporate identifier lookups, and with CloudPC.Read.All missing each of
them counted toward the add-on notice, although the grant admits only
Cloud PCs. A laptop that is registered in Autopilot only during
enrollment, or a Hyper-V test VM, could raise "Cloud PCs are rejected"
in a tenant without a single Cloud PC.

RegisterSession now hands the agent's own Cloud PC verdict
(SessionRegistration.IsCloudPc, two local Windows 365 markers) to the
validator, and only then does a blocked Cloud PC lookup count.
Manufacturer and model cannot tell a Cloud PC from a Hyper-V VM, the
markers can. Without the grant a Cloud PC is never admitted, so each of
its runs registers and the register request alone is enough. Admission,
status codes and the Intune Enrollment part stay unchanged; the flag is
never an admission input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:17 +01:00
Oliver Kieselbach 74a0bc13a3 bump pointer: internal (distress report search, B-r6t)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:55:18 +01:00
Oliver Kieselbach c0e91ca0f9 Distress reports: searchable in the portal, list holds the newest reports
The Distress Reports view under Admin > Reports gets a search field. It
matches serial number, tenant id, manufacturer, model, error type (raw and
badge label), agent version, OS build, message, source IP and certificate
thumbprint/subject, case-insensitively by substring. Sort and pagination
run on the matches, and a counter shows "N of M reports".

The list endpoint read the table without a partition filter and stopped
after 500 rows. Table Storage returns such a query in tenant order, so
past 500 rows whole tenants, including their newest reports, were left
out without notice, and a search over that list would have missed them.
It now reads the whole table (bounded by the 14-day cleanup), keeps the
newest 2,000 by IngestedAt in a min-heap (memory stays at the cap however
large the table grows), and returns them newest first. The new
`truncated` field says when the cap cut older rows; the view then names
the limit next to the search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:51:35 +01:00
Oliver Kieselbach ccbc754b0e bump pointer: internal (D-407 live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:38:11 +01:00
Oliver Kieselbach 15cd78d81a bump pointer: internal (D-407, B-dnb)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:30:23 +01:00
Oliver Kieselbach d95da2eeab MCP: @hono/node-server 2.1.6 closes the open Dependabot alert GHSA-rmxm-3fg6-px4f
The mcp group bump (#196) moved @modelcontextprotocol/node to 2.1.2, which
widens its @hono/node-server range from ^1.19.9 to ^1.19.9 || ^2.1.3, but it
kept the locked 1.19.17. This moves it to 2.1.6 and raises the declared
@modelcontextprotocol/server range to ^2.4.0, the peer range node 2.1.2
requires (the lockfile already had 2.4.0).

2.1.3 already fixes GHSA-rmxm-3fg6-px4f (serveStatic decodes the path twice).
2.1.6 is two days old and inside the 7-day cooldown, but it is the security
release for GHSA-jpvv-r467-5p4w, GHSA-7v5p-43qr-wch5 and GHSA-qv5m-9pm6-hqp3,
which hono has published but the advisory database does not list yet; 2.1.5
would raise those alerts as soon as it does. Lockfile written with npm 11.21.1.

npm audit: the remaining 11 moderate findings are all minimatch < 11.0.1
(GHSA-g3hv-75wg-m249) under eslint and typescript-eslint, which no parent
allows yet.
MCP: tsc, build, lint (0 warnings), knip clean; 1064 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:29:26 +01:00
dependabot[bot] 967d38c7cc chore(deps): bump the mcp group (#196)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/node](https://github.com/modelcontextprotocol/typescript-sdk) | `2.1.1` | `2.1.2` |
| [@modelcontextprotocol/server](https://github.com/modelcontextprotocol/typescript-sdk) | `2.3.1` | `2.4.0` |
| [jose](https://github.com/panva/jose) | `6.2.12` | `6.2.13` |
| [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk) | `2.3.1` | `2.4.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.42.1` | `6.43.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.72.0` | `8.72.1` |


Updates `@modelcontextprotocol/node` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/node@2.1.1...@modelcontextprotocol/node@2.1.2)

Updates `@modelcontextprotocol/server` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/server@2.3.1...@modelcontextprotocol/server@2.4.0)

Updates `jose` from 6.2.12 to 6.2.13
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.12...v6.2.13)

Updates `@modelcontextprotocol/client` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/client@2.3.1...@modelcontextprotocol/client@2.4.0)

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

Updates `typescript-eslint` from 8.72.0 to 8.72.1
- [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.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/node"
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/server"
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: jose
  dependency-version: 6.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/client"
  dependency-version: 2.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.43.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.72.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 15:04:40 +01:00
dependabot[bot] b47487617a chore(deps): bump the web group (#197)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.4.2` | `16.4.3` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.4.2` | `16.4.3` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.6.1` | `10.6.2` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.4.2` | `16.4.3` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.42.1` | `6.43.0` |


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

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

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

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

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

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.43.0
  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-11-04 15:04:26 +01:00
dependabot[bot] f2b86dfc73 chore(deps-dev): bump jscpd (#198)
Bumps the quality-tooling group in /tools/quality with 1 update: [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd).


Updates `jscpd` from 5.4.1 to 5.4.2
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Commits](https://github.com/kucherenko/jscpd/commits/v5.4.2/rust/jscpd)

---
updated-dependencies:
- dependency-name: jscpd
  dependency-version: 5.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: quality-tooling
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 15:04:14 +01:00
Oliver Kieselbach 0c7b533107 bump pointer: internal (D-406 live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:26:35 +01:00
Oliver Kieselbach 5eec2d7e60 bump pointer: internal (D-406), .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:31:37 +01:00
Oliver Kieselbach e9cb51205e Device validation: a missing add-on permission becomes visible
Cloud PC and Intune Enrollment Validation need an optional Graph permission
that admin consent does not grant. Settings now shows a "Permission missing"
card at the switch, with Grant permission and Turn off, as soon as the switch
is on without it, and the section badge no longer reads Enabled.

Once a device was kept out for that reason and the permission is still missing
at least 15 minutes later, the tenant gets a dashboard notice and the platform
an AddOnPermissionMissing ops event. A fresh token carrying the role, the
switch turned off or an app-homing flip ends the episode. The state rides on
the Graph access recorder, its token hook and its maintenance recheck; the
answers the agent gets stay the same.

The grant page pre-ticks the features a link names and treats a missing
granted value as unknown. The app-homing flip now closes a confirmed Graph
access episode with GraphAccessRecovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:31:35 +01:00
Oliver Kieselbach cc5dd7d6c2 bump pointer: internal (B-9ha), .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:06:29 +01:00
Oliver Kieselbach 11731db5e4 bump pointer: .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:38:16 +01:00
Oliver Kieselbach 6866fe79af bump pointer: .claude/commands (platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:33:33 +01:00
Oliver Kieselbach 6e34fa517c bump pointer: internal (graph access checks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:32:47 +01:00
Oliver Kieselbach dc05428dae bump pointer: internal (graph access live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:20:32 +01:00
Oliver Kieselbach cd0c750e80 bump pointer: internal (D-404, D-405 live), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:10:28 +01:00
Oliver Kieselbach cede4be654 bump pointer: internal (D-405)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:41:37 +01:00
Oliver Kieselbach 0d63057725 App-version radar reads one tenant at a time
Holding every tenant's install summaries for the whole horizon made the pass's
memory grow with the fleet; now it follows the largest tenant. A tenant without
rows stays unevaluated as before, and a tenant whose read fails no longer costs
the others their pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:41:36 +01:00
Oliver Kieselbach 7dd151b714 bump pointer: internal (D-404), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:32:28 +01:00
Oliver Kieselbach 778eb44a34 Backend memory: pressure metric against the GC's limits, memory per maintenance step
BackendMemoryPct reports committed heap and memory load each second as a
percentage of the limits the GC works against, so a reading keeps its meaning
at any instance size. MaintenanceCompleted now carries the peak working set and
the allocation per step plus the GC limits, which names the step that set a
peak without depending on when a counter sampled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:18:47 +01:00
Oliver Kieselbach 00f37d869a bump pointer: internal (graph-token-rejection-visible)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:39:32 +01:00
Oliver Kieselbach d88a118903 bump pointer: internal (graph-token-rejection-visible), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:34:39 +01:00
Oliver Kieselbach 0ed3258d4f Graph access: a refused app token becomes visible, with the repair in the portal
When Microsoft Entra refuses a tenant's app token, its agents only see 503 and nobody is told. The refusal now:

- names its Entra code in the log, at most every 15 minutes per tenant and app
- becomes tenant state in two stages: a silent suspicion on an agent route, confirmed by a further refusal at least 15 minutes later, from an agent request or a recheck in the maintenance run
- shows a red dashboard banner and a repair card in Settings once confirmed, and writes GraphAccessRejected
- ends with the next freshly issued token that carries an application role. A cached token proves nothing, and on the agent path a token without a role counts as a refusal, because a sign-in consent re-creates a removed enterprise application without any permission

A rejected secret of our own raises GraphAppCredentialRejected and touches no tenant. The new consent trigger "restore" repairs access without changing a validation gate. A proven missing service principal of the previous app no longer blocks the switch to the new one when the budget cuts the retries. Agent behaviour does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:34:38 +01:00
Oliver Kieselbach 3941c3b984 bump pointer: internal (D-401, D-402 live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:26:56 +01:00
Oliver Kieselbach 3d831d16d7 bump pointer: internal (B-vm9), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:59:10 +01:00
Oliver Kieselbach 46b895a70d bump pointer: internal (B-vm9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:44:39 +01:00
Oliver Kieselbach 77fae059ed bump pointer: internal (D-401, D-402), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:43:54 +01:00
Oliver Kieselbach fd309da51d Notifications: retire channel provider 40; ops channels come from the stored list
Value 40 stays reserved and a stored one is refused, like value 1. The three fixed ops
slots and their synthesis are gone. A customer submission reaches a channel through its
ops event and an alert rule, so the functions that raise one now require the service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:43:51 +01:00
Oliver Kieselbach 9c4039b3c3 bump pointer: internal (B-vm9, B-fdd), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:31:15 +01:00
Oliver Kieselbach b0fb8eb5fa bump pointer: internal (B-vm9), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:48:29 +01:00
Oliver Kieselbach dd2432fa62 bump pointer: internal (D-400 live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:41:41 +01:00
Oliver Kieselbach d9b67e444a bump pointer: internal (D-400)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:17:48 +01:00
Oliver Kieselbach 47b91f76e7 Agent: a proxy value with credentials is replaced as a whole
Taking it apart left part of a password containing =, ; or :// in the log and the event.
A proxy string has no defined form for credentials, so there is nothing safe to parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:16:59 +01:00
Oliver Kieselbach 9c6b55a8bc bump pointer: internal (D-400), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:05:27 +01:00
Oliver Kieselbach f00b475f59 Agent: the log names the upload route and when the proxy settings changed
The agent log had no proxy line at all, and proxy_configuration could not tell auto-detect
from direct.

ProxyObserver logs the settings and the route .NET chooses before the first request, every
change with its time, the route in each failed upload, one finding per failure series that
ties it to a change or says nothing changed, and a summary when the run stops while failing.
It compares with the start instead of judging a value.

proxy_configuration uses the same reader. It knows auto-detect and the real WinHTTP proxy,
drops credentials and PAC query strings, and is sent again on a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:04:37 +01:00
Oliver Kieselbach f8743dcae0 bump pointer: internal (B-3gb done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:24:21 +01:00
Oliver Kieselbach bd94382edb Web: panel heads without nested buttons, session page fits a phone
The Download and Install heads were one button holding the skipped, pending
and no-download toggles: invalid HTML that hid them from screen readers. The
head is now a div that takes the mouse click, so the whole area still toggles,
with the chevron as the real button (ProgressPanelHeader). The tab order
changes: the panel toggle now follows the pills.

Community submissions had the same flaw with span role=button, which the
keyboard could not reach at all. Title, Show all and Pending only are sibling
buttons now.

At 390 px the session page ran up to 630 px wide. The event type, the event
message and the install error detail hold long words without a break point;
the event row wraps now and those texts break inside a word. Desktop rows stay
on one line. The clone between the two panel heads is gone from the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:24:21 +01:00
dependabot[bot] 8abd3a8848 chore(deps): bump the actions group with 6 updates (#195)
Bumps the actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.4` | `0.8.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` |


Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.8.0
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...53290badfa369cb0bf7ad16342cb62c2fa47ac7b)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `github/codeql-action/init` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 16:46:27 +01:00
288 changed files with 16286 additions and 2734 deletions
+17
View File
@@ -98,6 +98,23 @@ updates:
applies-to: security-updates
patterns: ['*']
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
# the production deploy token: a version reaches that job only through a reviewed bump here.
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
- package-ecosystem: npm
directory: /.github/tools/swa-cli
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
swa-cli:
patterns: ['*']
swa-cli-security:
applies-to: security-updates
patterns: ['*']
# Every action is referenced by commit SHA with the version in a trailing comment;
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "autopilot-monitor-swa-cli",
"private": true,
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
"devDependencies": {
"@azure/static-web-apps-cli": "2.0.10"
}
}
+52 -2
View File
@@ -1,6 +1,6 @@
name: Workflow gates
# Three checks over the workflow files and their helper scripts:
# Four checks over the workflow files and their helper scripts:
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
# version comment (stricter than zizmor's unpinned-uses audit).
# * zizmor -- static analysis of every workflow (permissions, credential
@@ -10,6 +10,8 @@ name: Workflow gates
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
# registry (the C#, web and MCP registries have guard tests of their own).
# * packages -- no workflow or helper fetches a package at a version the repository
# does not fix, in the forms zizmor's adhoc-packages audit does not see.
#
# Every third-party action must be referenced by a full commit SHA, never by a tag.
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
@@ -105,7 +107,7 @@ jobs:
# the local install so the gate and the pre-commit run agree on the finding set.
# Informational findings (template expansion of our own step outputs in pwsh) are
# visible locally but do not fail the gate.
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
- uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
with:
version: 1.30.1
min-severity: low
@@ -123,3 +125,51 @@ jobs:
- name: No host literal outside .github/scripts/Get-Endpoints.ps1
shell: pwsh
run: ./.github/scripts/Assert-HostsFromRegistry.ps1
packages:
name: Packages only at a version the repository fixes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# npm packages come from a committed lockfile via npm ci; zizmor's adhoc-packages flags
# `npm install <pkg>` (also yarn, pnpm, gem), but not the forms below, which also fetch at
# run time:
# * npx / npm exec without --no: in CI they install a missing package without asking.
# The -- is load-bearing: without it npx hands the tool's own flags to npm.
# * a .NET tool without an exact --version, a PowerShell module without -RequiredVersion.
# Other package managers (pip, cargo, dlx runners) are covered by neither check.
- name: No package fetched at a version the repository does not fix
run: |
set -uo pipefail
failed=0
fail() { echo "::error file=$1,line=$2::$3"; failed=1; }
while IFS=: read -r file line text; do
# YAML, shell and PowerShell comments alike start the line with #.
[[ "$text" =~ ^[[:space:]]*# ]] && continue
if [[ "$text" =~ (^|[^[:alnum:]_./-])(npx|npm[[:space:]]+(exec|x))([[:space:]]|$) ]] &&
! [[ "$text" =~ (npx|npm[[:space:]]+(exec|x))[[:space:]]+--no[[:space:]]+--([[:space:]]|$) ]]; then
fail "$file" "$line" "Write 'npx --no -- <tool>' (or 'npm exec --no -- <tool>'): without --no a missing package is fetched from the registry, without -- the tool's flags go to npm."
fi
if [[ "$text" =~ dotnet[[:space:]]+tool[[:space:]]+(install|update) ]] &&
! [[ "$text" =~ --version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
fail "$file" "$line" "Give the .NET tool an exact --version X.Y.Z on the same line; a range or a missing version resolves at run time."
fi
if [[ "$text" =~ Install-(Module|PSResource)[[:space:]] ]] &&
! [[ "$text" =~ -RequiredVersion[[:space:]]+[^[:space:]] || "$text" =~ -Version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
fail "$file" "$line" "Give the PowerShell module an exact version on the same line, -RequiredVersion for Install-Module, -Version X.Y.Z for Install-PSResource; -MinimumVersion resolves at run time."
fi
# -H is load-bearing, as in verify-pins: with a single matching file grep omits the name.
done < <(grep -nHE 'npx|npm[[:space:]]+(exec|x)([[:space:]]|$)|dotnet[[:space:]]+tool[[:space:]]+(install|update)|Install-(Module|PSResource)' \
.github/workflows/*.yml .github/scripts/*.ps1)
if [ "$failed" -ne 0 ]; then
echo "::error::Packages fetched at a version the repository does not fix -- fix them before this reaches main."
exit 1
fi
echo "Every package the workflows and their helpers fetch has a version the repository fixes."
+2 -2
View File
@@ -65,11 +65,11 @@ jobs:
persist-credentials: false
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
- name: Install Pester 5+
- name: Install Pester
shell: powershell
run: |
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -RequiredVersion 6.2.0
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
# platform scripts, so guard/relax logic is verified without resetting a device.
+4 -4
View File
@@ -247,7 +247,7 @@ jobs:
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
- name: Upload unsigned build output
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: agent-unsigned
path: ${{ runner.temp }}/agent-unsigned
@@ -293,7 +293,7 @@ jobs:
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download unsigned build output
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: agent-unsigned
path: ${{ runner.temp }}/agent-unsigned
@@ -790,13 +790,13 @@ jobs:
staging/agent/AutopilotMonitor.SummaryDialog.exe
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
+6 -6
View File
@@ -127,7 +127,7 @@ jobs:
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
- name: Upload signed loader
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: bootstrap-loader-signed
path: ${{ env.RUNNER_SCRIPT }}
@@ -148,7 +148,7 @@ jobs:
persist-credentials: false
- name: Download signed loader
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: bootstrap-loader-signed
path: ${{ runner.temp }}/loader
@@ -187,7 +187,7 @@ jobs:
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
- name: Upload unsigned MSI
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: bootstrap-msi-unsigned
path: ${{ env.STABLE_MSI }}
@@ -215,7 +215,7 @@ jobs:
persist-credentials: false
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
@@ -310,7 +310,7 @@ jobs:
"version=$version" >> $env:GITHUB_OUTPUT
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
@@ -442,7 +442,7 @@ jobs:
subject-path: ${{ env.STABLE_MSI }}
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
path: ${{ env.STABLE_MSI }}
+8 -8
View File
@@ -134,7 +134,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
@@ -145,10 +145,10 @@ jobs:
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
run: npx --no -- next typegen
- name: Typecheck
run: npx tsc --noEmit
run: npx --no -- tsc --noEmit
# Errors fail outright, and so does ANY warning: the codebase is
# warning-clean and must stay that way — fix new findings, never
@@ -176,7 +176,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
@@ -187,7 +187,7 @@ jobs:
# tsconfig.json covers sources, tests and scripts; the build emits via
# tsconfig.build.json.
- name: Typecheck
run: npx tsc --noEmit
run: npx --no -- tsc --noEmit
# Same rule as the web job: any warning fails — fix new findings, never
# raise the cap.
@@ -222,7 +222,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
@@ -245,7 +245,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
@@ -276,7 +276,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
+2 -2
View File
@@ -112,7 +112,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -123,6 +123,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: /language:${{ matrix.language }}
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
@@ -66,7 +66,7 @@ jobs:
# `git push` needs.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
+2 -2
View File
@@ -99,7 +99,7 @@ jobs:
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
- name: Upload publish output
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: backend-publish
path: ${{ runner.temp }}/backend-publish.tgz
@@ -128,7 +128,7 @@ jobs:
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download publish output
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: backend-publish
path: ${{ runner.temp }}/backend-artifact
+26 -19
View File
@@ -1,14 +1,14 @@
name: Deploy Web (Static Web App)
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
# and the exported site is attested like the agent ZIP and the MSI.
#
# Three jobs, each holding only what it needs: build runs all third-party code without any
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
# manifest. A failed record job is re-run on its own; it writes only once the live site
# serves this commit.
# Three jobs, each holding only what it needs: build runs the third-party build code without
# any credential, deploy holds the SWA token alone and runs nothing but the locked CLI, record
# attests, verifies and writes the manifest. A failed record job is re-run on its own; it
# writes only once the live site serves this commit.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
@@ -19,6 +19,7 @@ on:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/tools/swa-cli/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
@@ -32,9 +33,6 @@ permissions:
# repository variable.
env:
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
# Build zone: no login, no id-token. The one secret here is the Application Insights
@@ -74,7 +72,7 @@ jobs:
id: docs
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
@@ -149,14 +147,15 @@ jobs:
sha256sum "$archive"
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
if-no-files-found: error
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
# is third-party npm code whose dependencies resolve at run time.
# Holds the SWA deployment token and nothing else -- deliberately no id-token. The token alone
# can replace the portal, so the SWA CLI, the one third-party code here, comes from a committed
# lockfile: a version published after the last reviewed bump never runs next to it.
deploy:
name: Deploy to Static Web App
needs: build
@@ -164,13 +163,21 @@ jobs:
environment: production
steps:
# For staticwebapp.config.json only.
# For staticwebapp.config.json and the CLI's lockfile.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# npm ci, never npx or npm install: the lockfile pins every transitive package by hash.
# No lifecycle scripts: keytar, the only package with one, serves the keychain, which
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
# build zone can touch the install.
- name: Install the SWA CLI from its lockfile
working-directory: .github/tools/swa-cli
run: npm ci --ignore-scripts
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
@@ -186,7 +193,7 @@ jobs:
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
.github/tools/swa-cli/node_modules/.bin/swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
@@ -215,7 +222,7 @@ jobs:
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
# when someone wants to look at the file.
- name: Upload signed scripts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-scripts
path: ${{ runner.temp }}/signed-scripts
+3 -1
View File
@@ -89,12 +89,14 @@
"type": "array",
"description": "Conditions evaluated against the event stream. All required conditions must match for the rule to fire.",
"items": { "$ref": "#/$defs/ruleCondition" },
"minItems": 1
"minItems": 1,
"maxItems": 20
},
"preconditions": {
"type": "array",
"description": "Optional device-fact gates evaluated BEFORE conditions. ALL preconditions must pass; if any fails the rule is silently skipped — no result, no UI card. Useful for filtering out hardware/OS profiles where the rule does not apply (e.g. virtual machines).",
"items": { "$ref": "#/$defs/rulePrecondition" },
"maxItems": 10,
"default": []
},
"baseConfidence": {
@@ -27,6 +27,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// already in flight misses its wake.</item>
/// <item>DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
/// host's RequestResetForRealUserSwitch callback, so the target must exist first.</item>
/// <item>UserEspKeepAwakeHost before RealmJoinHost — after an agent restart the RealmJoin
/// watcher posts realmjoin_detected from its own Start; the keep-awake host hands its
/// hold over to RealmJoin (D-413) and must already observe the rail.</item>
/// </list>
/// </summary>
public sealed class DefaultComponentFactoryOrderingTests : IDisposable
@@ -143,6 +146,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
}
[Fact]
public void Keep_awake_host_subscribes_before_the_RealmJoin_watcher_starts()
{
var hosts = CreateSurfaces(new AnalyzerConfiguration
{
EnableRealmJoinWatcher = true,
KeepAwakeDuringUserEsp = true,
}).Hosts;
try
{
AssertBefore<UserEspKeepAwakeHost, RealmJoinHost>(
hosts,
"D-413 — a realmjoin_detected posted from the watcher's Start (agent restart with " +
"RealmJoin installed) must reach the keep-awake host, or it waits for a start it missed.");
}
finally
{
foreach (var host in hosts)
{
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
}
}
}
[Theory]
[InlineData(false, 0)]
[InlineData(true, 1)]
@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>The watchers only listen on the real keys; settings are played in, none is changed.</summary>
public sealed class ProxySettingsHostTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _ingress = new FakeSignalIngressSink();
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 53, 43, DateTimeKind.Utc));
private readonly AgentLogger _logger;
private readonly ProxyObserver _observer;
private ProxySettings _settings = new ProxySettings(true, null, null, null, null);
private string _route = "proxy.example.test:8080";
public ProxySettingsHostTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_observer = new ProxyObserver(() => _settings, () => _route, _logger, _clock, TimeSpan.FromSeconds(30));
_observer.CaptureBaseline();
}
private ProxySettingsHost NewHost() => new ProxySettingsHost(_observer, _ingress, _clock, _logger);
private void SwitchToNoProxy()
{
_settings = new ProxySettings(false, null, null, null, null);
_route = "direct";
}
private List<FakeSignalIngressSink.PostedSignal> ProxyEvents() =>
_ingress.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
&& eventType == Constants.EventTypes.ProxyConfiguration)
.ToList();
private string Log()
{
var text = string.Empty;
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
{
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
text += reader.ReadToEnd();
}
return text;
}
public void Dispose() => _tmp.Dispose();
[Fact]
public void A_change_goes_on_the_timeline_as_a_warning_with_what_moved()
{
using var host = NewHost();
host.Start();
SwitchToNoProxy();
_observer.CheckForChange();
var signal = Assert.Single(ProxyEvents());
Assert.Equal(nameof(EventSeverity.Warning), signal.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("Proxy settings changed: IE settings of this account: auto-detect -> no proxy", signal.Payload[SignalPayloadKeys.Message]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
Assert.Equal("changed", data["trigger"]);
Assert.Equal("IE settings of this account: auto-detect -> no proxy", data["change"]);
Assert.Equal("direct", data["route"]);
Assert.Equal("Direct", data["proxyType"]);
}
[Fact]
public void A_change_between_the_baseline_and_the_start_is_noticed_at_the_start()
{
SwitchToNoProxy();
using var host = NewHost();
host.Start();
Assert.Single(ProxyEvents());
}
[Fact]
public void No_change_means_no_event()
{
using var host = NewHost();
host.Start();
_observer.CheckForChange();
Assert.Empty(ProxyEvents());
}
[Fact]
public void A_stopped_ingress_does_not_reach_the_upload_path()
{
using var host = NewHost();
host.Start();
_ingress.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
SwitchToNoProxy();
_observer.OnSendFailed(TimeSpan.Zero);
Assert.Contains("uploads are failing", Log());
}
[Fact]
public void Stopping_while_uploads_fail_says_so_once()
{
var host = NewHost();
host.Start();
_observer.OnSendFailed(TimeSpan.Zero);
host.Stop();
host.Stop();
host.Dispose();
var log = Log();
var first = log.IndexOf("stopping with uploads still failing", StringComparison.Ordinal);
Assert.True(first >= 0);
Assert.Equal(-1, log.IndexOf("stopping with uploads still failing", first + 1, StringComparison.Ordinal));
}
[Fact]
public void A_change_after_the_stop_no_longer_posts()
{
var host = NewHost();
host.Start();
host.Stop();
SwitchToNoProxy();
_observer.CheckForChange();
Assert.Empty(ProxyEvents());
host.Dispose();
}
[Fact]
public void A_host_that_never_started_stays_silent_on_stop()
{
var host = NewHost();
_observer.OnSendFailed(TimeSpan.Zero);
host.Dispose();
Assert.DoesNotContain("stopping with uploads still failing", Log());
}
}
}
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
using Xunit;
// ManualResetEventSlim.Wait inside the controller is driven deterministically here; SpinUntil is
@@ -52,10 +53,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
private AgentLogger NewLogger() => new AgentLogger(_tmp.Path);
private SignalIngress BuildIngress(VirtualClock clock) =>
private SignalIngress BuildIngress(VirtualClock clock, string signalLogName = "signal-log.jsonl") =>
new SignalIngress(
engine: new DecisionEngine(),
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
signalLog: new SignalLogWriter(_tmp.File(signalLogName)),
traceCounter: new SessionTraceOrdinalProvider(),
processor: new FakeDecisionStepProcessor(),
clock: clock);
@@ -300,6 +301,365 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
ing.Stop();
}
// -------- RealmJoin hand-over (D-413) --------
/// <summary>Records keep_awake_released payloads from the rail (posted on ThreadPool threads).</summary>
private sealed class ReleaseRecorder
{
private readonly object _lock = new object();
private readonly List<Dictionary<string, string>> _released = new List<Dictionary<string, string>>();
public ReleaseRecorder(SignalIngress ing) => ing.SignalPosted += OnPosted;
private void OnPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|| eventType != SharedEventTypes.KeepAwakeReleased) return;
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var kv in payload) copy[kv.Key] = kv.Value;
lock (_lock) { _released.Add(copy); }
}
public List<Dictionary<string, string>> Snapshot()
{
lock (_lock) { return new List<Dictionary<string, string>>(_released); }
}
}
private static void PostRealmJoin(SignalIngress ing, DecisionSignalKind kind, int? phase)
{
var payload = new Dictionary<string, string>();
if (phase.HasValue) payload[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = phase.Value.ToString();
ing.Post(kind, At, "RealmJoinWatcher", Raw("rj"), payload: payload);
}
private UserEspKeepAwakeHost NewHost(
SignalIngress ing,
VirtualClock clock,
KeepAwakeController controller,
Func<bool>? progress = null,
TimeSpan? safetyCap = null,
TimeSpan? realmJoinWait = null,
StartupEventGate? gate = null) =>
new UserEspKeepAwakeHost(ing, clock, NewLogger(),
controller: controller,
safetyCapOverride: safetyCap,
espTimeoutProvider: () => null,
accountSetupProgressProbe: progress ?? (() => true),
realmJoinStartWaitOverride: realmJoinWait,
startupGate: gate);
[Fact]
public void Page_exit_with_RealmJoin_still_Blank_holds_until_RealmJoin_starts()
{
// The common RealmJoin shape: detected during the ESP, deployment starts after the
// user-ESP page closed. The gap in between is what the hold now covers.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var recorder = new ReleaseRecorder(ing);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
PostEspExiting(ing);
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest), "the page exit must start the RealmJoin wait");
PostEspExiting(ing); // a repeated exit changes nothing
Assert.False(WaitFor(() => api.AllowCount > 0, 500), "the hold must outlive the page exit while RealmJoin is Blank");
Assert.True(controller.IsEngaged);
clock.Advance(TimeSpan.FromSeconds(90));
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
Assert.True(WaitFor(() => api.AllowCount == 1), "RealmJoin leaving Blank must release the hold");
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
var released = recorder.Snapshot()[0];
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
Assert.Equal("90", released["waitedSeconds"]);
host.Dispose();
ing.Stop();
}
[Fact]
public void RealmJoin_wait_bound_releases_when_RealmJoin_never_starts()
{
// RealmJoin may never leave Blank in the session — the wait is bounded.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var recorder = new ReleaseRecorder(ing);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMilliseconds(150));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
PostEspExiting(ing);
Assert.True(WaitFor(() => api.AllowCount == 1), "the RealmJoin wait must end the hold");
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
var released = recorder.Snapshot()[0];
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinNotStarted, released["reason"]);
Assert.True(released.ContainsKey("waitMinutes"));
host.Dispose();
ing.Stop();
}
[Fact]
public void Page_exit_replaces_the_ESP_safety_cap_with_the_RealmJoin_wait()
{
// One timer, one bound per phase of the hold: once the page closed, the ESP-sized cap
// must not end the RealmJoin wait.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var recorder = new ReleaseRecorder(ing);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller,
safetyCap: TimeSpan.FromSeconds(1), realmJoinWait: TimeSpan.FromMinutes(5));
ing.Start();
host.Start();
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostEspExiting(ing);
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest));
Assert.False(WaitFor(() => api.AllowCount > 0, 1500), "the superseded ESP cap must not fire");
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
Assert.True(WaitFor(() => api.AllowCount == 1));
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, recorder.Snapshot()[0]["reason"]);
host.Dispose();
ing.Stop();
}
[Fact]
public void Page_exit_without_RealmJoin_releases_as_before()
{
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var recorder = new ReleaseRecorder(ing);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostEspExiting(ing);
Assert.True(WaitFor(() => api.AllowCount == 1));
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
Assert.Equal(UserEspKeepAwakeHost.ReasonUserEspExited, recorder.Snapshot()[0]["reason"]);
Assert.False(host.AwaitingRealmJoinForTest);
host.Dispose();
ing.Stop();
}
[Theory]
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 100)]
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 210)]
[InlineData(DecisionSignalKind.RealmJoinResolved, null)]
public void RealmJoin_leaving_Blank_before_the_page_exit_releases_the_hold(DecisionSignalKind kind, int? phase)
{
// RealmJoin can start shortly before the page closes; it keeps the device awake from
// there. Resolved carries no phase and still means "left Blank".
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var recorder = new ReleaseRecorder(ing);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
Assert.False(WaitFor(() => api.AllowCount > 0, 300), "detection alone must not release");
PostRealmJoin(ing, kind, phase);
Assert.True(WaitFor(() => api.AllowCount == 1));
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
var released = recorder.Snapshot()[0];
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
Assert.False(released.ContainsKey("waitedSeconds"));
host.Dispose();
ing.Stop();
}
[Fact]
public void RealmJoin_already_deploying_before_AccountSetup_prevents_the_engage()
{
// Agent restart: the watcher reports the running phase from its start, and the
// AccountSetup phase is posted again afterwards — there is nothing to hand over.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
ing.Start();
host.Start();
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 101);
PostAccountSetupPhase(ing);
Assert.False(WaitFor(() => api.PreventCount > 0, 500), "no hold once RealmJoin deploys");
Assert.False(controller.IsEngaged);
host.Dispose();
ing.Stop();
}
// -------- Page-closed latch across agent restarts (D-414) --------
/// <summary>
/// One agent run: its own ingress and host on the shared state folder. A later run is a new
/// <see cref="StartupEventGate"/> loading the same folder, exactly what a restart does.
/// </summary>
private sealed class AgentRun : IDisposable
{
public SignalIngress Ingress { get; }
public FakeKeepAwakeApi Api { get; } = new FakeKeepAwakeApi();
public KeepAwakeController Controller { get; }
public StartupEventGate Gate { get; }
public UserEspKeepAwakeHost Host { get; }
public AgentRun(UserEspKeepAwakeHostTests t, string stateDir, string runName, Func<bool> progress)
{
var clock = new VirtualClock(At);
Ingress = t.BuildIngress(clock, $"signal-log-{runName}.jsonl");
Controller = new KeepAwakeController(t.NewLogger(), Api);
Gate = new StartupEventGate(stateDir, t.NewLogger());
Host = t.NewHost(Ingress, clock, Controller, progress: progress,
realmJoinWait: TimeSpan.FromMinutes(5), gate: Gate);
Ingress.Start();
Host.Start();
}
public void Dispose()
{
Host.Dispose();
Ingress.Stop();
Ingress.Dispose();
}
}
[Fact]
public void Closed_page_keeps_a_later_agent_run_from_engaging()
{
// After an agent restart the AccountSetup phase reaches the rail again (IME logs it at
// the next sign-in, the adapter's dedup lives per process). The page is gone, so no hold.
using var stateDir = new TempDirectory();
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
{
PostAccountSetupPhase(first.Ingress);
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
PostEspExiting(first.Ingress);
Assert.True(WaitFor(() => first.Api.AllowCount == 1));
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
}
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
PostAccountSetupPhase(second.Ingress);
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500), "the page already closed in this enrollment");
Assert.False(second.Controller.IsEngaged);
}
[Fact]
public void Restart_before_the_page_closed_engages_again()
{
// A reboot in the middle of the user ESP: the page is still to come, so the next run holds.
using var stateDir = new TempDirectory();
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
{
PostAccountSetupPhase(first.Ingress);
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
}
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
PostAccountSetupPhase(second.Ingress);
Assert.True(WaitFor(() => second.Api.PreventCount == 1), "the user ESP is still running");
Assert.True(second.Controller.IsEngaged);
}
[Fact]
public void Handoff_exit_without_AccountSetup_progress_sets_no_latch()
{
using var stateDir = new TempDirectory();
using (var first = new AgentRun(this, stateDir.Path, "1", () => false))
{
PostAccountSetupPhase(first.Ingress);
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
PostEspExiting(first.Ingress); // the Device→Account handoff
Assert.False(WaitFor(() => first.Api.AllowCount > 0, 300));
Assert.False(first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey));
}
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
PostAccountSetupPhase(second.Ingress);
Assert.True(WaitFor(() => second.Api.PreventCount == 1));
}
[Fact]
public void Page_exit_while_waiting_for_RealmJoin_is_latched()
{
// The hold outlived the page while RealmJoin was Blank, then the agent restarted: after
// the reboot RealmJoin needs the next sign-in anyway, so the next run does not hold.
using var stateDir = new TempDirectory();
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
{
PostRealmJoin(first.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
PostAccountSetupPhase(first.Ingress);
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
PostEspExiting(first.Ingress);
Assert.True(WaitFor(() => first.Host.AwaitingRealmJoinForTest));
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
}
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
PostRealmJoin(second.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
PostAccountSetupPhase(second.Ingress);
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500));
}
[Fact]
public void Page_exit_seen_while_idle_is_latched_and_blocks_a_later_engage()
{
// The exit counts in every hold state — here the host never engaged before it.
using var stateDir = new TempDirectory();
using var run = new AgentRun(this, stateDir.Path, "1", () => true);
PostEspExiting(run.Ingress);
Assert.True(WaitFor(() => run.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)),
"an exit seen while idle must still be latched");
PostAccountSetupPhase(run.Ingress);
Assert.False(WaitFor(() => run.Api.PreventCount > 0, 500));
}
// -------- KeepAwakeController: late-confirm (ready-timeout) edge --------
[Fact]
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Regression coverage for the IsHybridJoin detection that runs at session-registration
/// time. Field evidence (session c4c8d206-…, Lenovo ThinkPad T14 Gen 5, Win11 23H2
/// time. Field evidence (Lenovo ThinkPad T14 Gen 5, Win11 23H2
/// 22631.4317): the AutopilotPolicyCache key on this device exposes
/// <c>CloudAssignedDomainJoinMethod</c> only inside the embedded <c>PolicyJsonCache</c>
/// blob, not as a top-level registry value. Without the JSON fallback the agent registered
@@ -47,11 +47,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void Missing_TopLevel_falls_back_to_PolicyJsonCache_one_returns_true()
{
// Real-world payload from the Bayer Lenovo T14 Gen 5 / Win11 23H2 22631.4317
// device that triggered this fix. Truncated for readability — the only relevant
// Shape of a real PolicyJsonCache payload (Win11 23H2 22631.4317) with a
// placeholder tenant domain. Truncated for readability — the only relevant
// key is CloudAssignedDomainJoinMethod=1.
const string policyJson =
"{\r\n \"CloudAssignedTenantDomain\": \"bayergroup.onmicrosoft.com\",\r\n" +
"{\r\n \"CloudAssignedTenantDomain\": \"contoso.onmicrosoft.com\",\r\n" +
" \"CloudAssignedDomainJoinMethod\": 1,\r\n" +
" \"AutopilotMode\": 2\r\n}";
@@ -600,6 +600,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
}
// ---- Detected carries the current phase (service-key attach) -------------------------
private sealed class PhaseHarness : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public int? Phase;
public RealmJoinWatcher Watcher { get; }
public RealmJoinWatcherAdapter Adapter { get; }
public PhaseHarness()
{
var registry = new FakeRealmJoinRegistry();
Watcher = new RealmJoinWatcher(
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
enumeratePackageIds: registry.Enumerate,
tryReadPackage: registry.TryRead,
keyExists: registry.KeyExists,
readVersionInfo: () => RealmJoinVersionInfo.Empty,
readUninstallDisplayVersion: () => null,
readDeploymentPhase: () => Phase);
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
}
public string DetectedPhase() =>
Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected)
.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase];
public void Dispose()
{
Adapter.Dispose();
Watcher.Dispose();
_tmp.Dispose();
}
}
[Theory]
[InlineData(101)]
[InlineData(200)]
public void Detected_carries_the_phase_RealmJoin_is_in_when_the_service_key_attaches(int phase)
{
// Agent restarted while RJ deploys. Detected used to say Blank, which reset the
// engine's persisted LastDeploymentPhase and hid from the keep-awake host that RJ had
// started — no PhaseChanged follows while the phase stays put.
using var h = new PhaseHarness { Phase = phase };
h.Watcher.TriggerServiceStageAttachedFromTest();
Assert.Equal(phase.ToString(), h.DetectedPhase());
Assert.True(h.Watcher.PackageWatchersArmedForTest);
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
}
[Fact]
public void Detected_with_a_completed_phase_resolves_in_the_same_pass()
{
using var h = new PhaseHarness { Phase = RealmJoinInfo.PhaseCompletedFirstDeployment };
h.Watcher.TriggerServiceStageAttachedFromTest();
Assert.Equal("110", h.DetectedPhase());
var kinds = h.Ingress.Posted
.Where(p => p.Kind == DecisionSignalKind.RealmJoinDetected || p.Kind == DecisionSignalKind.RealmJoinResolved)
.Select(p => p.Kind)
.ToList();
Assert.Equal(new[] { DecisionSignalKind.RealmJoinDetected, DecisionSignalKind.RealmJoinResolved }, kinds);
}
[Fact]
public void Phase_not_written_at_attach_reports_Blank_and_the_first_value_surfaces_as_PhaseChanged()
{
// Detected says Blank while Parameters\DeploymentPhase is missing; that report is the
// baseline, so a first value of 101 must not be swallowed silently.
using var h = new PhaseHarness { Phase = null };
h.Watcher.TriggerServiceStageAttachedFromTest();
Assert.Equal("0", h.DetectedPhase());
Assert.False(h.Watcher.PackageWatchersArmedForTest);
h.Phase = 101;
h.Watcher.TriggerCheckParametersFromTest();
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
Assert.Equal("101", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
Assert.Equal("0", changed.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
Assert.True(h.Watcher.PackageWatchersArmedForTest);
}
[Fact]
public void Blank_at_attach_then_running_fires_one_PhaseChanged()
{
// The common first-run shape (RJ installed during the ESP, deployment starts after
// the user-ESP page closed): Blank on attach, 0 -> 101 later.
using var h = new PhaseHarness { Phase = 0 };
h.Watcher.TriggerServiceStageAttachedFromTest();
h.Watcher.TriggerCheckParametersFromTest(); // same value again — no change
h.Phase = 101;
h.Watcher.TriggerCheckParametersFromTest();
Assert.Equal("0", h.DetectedPhase());
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
Assert.Equal("0", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
}
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
@@ -12,8 +12,8 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
{
/// <summary>
/// Regression coverage for the duplicate <c>performance_snapshot</c> events observed on
/// session c4c8d206-… (Bayer / Lenovo T14 Gen 5): two snapshots fired with timestamps
/// Regression coverage for duplicate <c>performance_snapshot</c> events observed in the
/// field (Lenovo T14 Gen 5): two snapshots fired with timestamps
/// 0–1ms apart, the second one carrying a fresh-baseline net delta of 0. Root cause:
/// <see cref="System.Threading.Timer"/> is documented to dispatch callbacks on different
/// ThreadPool threads when a callback exceeds the period, and the
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using Xunit;
using AutopilotMonitor.Shared.Models;
@@ -35,6 +36,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
requiresImmediateFlush: false,
enqueuedAtUtc: At);
private static ProxyObserver NewProxyObserver(AgentLogger logger) =>
new ProxyObserver(
() => new ProxySettings(false, null, null, null, null),
() => "direct",
logger,
new VirtualClock(At),
TimeSpan.FromSeconds(30));
private static string ReadLogs(string path)
{
// AgentLogger writes to a dated file; scan the whole dir to stay robust to format.
@@ -235,5 +244,126 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.DoesNotContain("flushing", logContent);
Assert.DoesNotContain("ingest", logContent);
}
[Fact]
public async Task Network_failure_names_the_route_and_opens_a_series()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
var logContent = ReadLogs(tmp.Path);
Assert.Contains("ingest NETWORK fail after", logContent);
Assert.Contains("items=1, route=direct): ", logContent);
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
}
[Fact]
public async Task Timeout_names_the_route_and_opens_a_series()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueThrow(new TaskCanceledException("A task was canceled."));
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
var logContent = ReadLogs(tmp.Path);
Assert.Contains("ingest TIMEOUT after", logContent);
Assert.Contains("items=1, route=direct): ", logContent);
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
}
[Fact]
public async Task Interception_names_the_route_and_opens_a_series()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.True(result.IsIntercepted);
var logContent = ReadLogs(tmp.Path);
Assert.Contains(", route=direct).", logContent);
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
}
[Fact]
public async Task A_successful_upload_ends_the_series()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
handler.QueueStatus(HttpStatusCode.OK);
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.Contains("ProxyObserver: uploads work again after 1 failed attempt(s)", ReadLogs(tmp.Path));
}
[Fact]
public async Task An_answer_from_the_backend_that_is_not_a_success_leaves_the_series_alone()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueStatus(HttpStatusCode.InternalServerError);
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.DoesNotContain("ProxyObserver: uploads", ReadLogs(tmp.Path));
}
[Fact]
public async Task Without_an_observer_the_failure_line_stays_as_it_was()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var handler = new RecordingHttpMessageHandler();
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
var sut = new BackendTelemetryUploader(
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger);
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
var logContent = ReadLogs(tmp.Path);
Assert.Contains("items=1): ", logContent);
Assert.DoesNotContain("route=", logContent);
}
}
}
@@ -0,0 +1,366 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
public sealed class ProxyObserverTests : IDisposable
{
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
private ProxySettings _settings = AutoDetect;
private string _route = "proxy.example.test:8080";
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
new ProxyObserver(
readSettings ?? (() => _settings),
readRoute ?? (() => _route),
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
_clock,
routeBound ?? TimeSpan.FromSeconds(30));
private ProxyObserver StartedObserver()
{
var observer = NewObserver();
observer.CaptureBaseline();
return observer;
}
private void SwitchToNoProxy()
{
_settings = NoProxy;
_route = "direct";
}
private List<string> LogLines()
{
var lines = new List<string>();
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
{
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
}
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
}
public void Dispose() => _tmp.Dispose();
[Fact]
public void The_baseline_names_settings_and_route_at_info()
{
StartedObserver();
var line = Assert.Single(LogLines());
Assert.Contains("[INFO]", line);
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
}
[Fact]
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
foreach (var _ in new[] { 1, 2 })
{
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
}
Assert.Single(LogLines());
}
[Fact]
public void A_change_warns_once_with_both_sides()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.CheckForChange();
observer.CheckForChange();
var line = Assert.Single(LogLines().Skip(1));
Assert.Contains("[WARN]", line);
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
}
[Fact]
public void Rewriting_the_same_content_says_nothing()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, null);
observer.CheckForChange();
Assert.Single(LogLines());
}
[Fact]
public void A_winhttp_change_says_uploads_do_not_use_it()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, "p:8080");
observer.CheckForChange();
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
Assert.Contains("do not use WinHTTP", line);
}
[Fact]
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
{
var observer = StartedObserver();
_settings = new ProxySettings(true, null, null, null, "p:8080");
observer.CheckForChange();
observer.OnSendFailed(TimeSpan.Zero);
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
}
[Fact]
public void A_change_reaches_the_subscriber_with_settings_and_route()
{
var observer = StartedObserver();
ProxySettings? seen = null;
string? seenWhat = null;
string? seenRoute = null;
observer.SettingsChanged += (settings, what, route) =>
{
seen = settings;
seenWhat = what;
seenRoute = route;
};
SwitchToNoProxy();
observer.CheckForChange();
Assert.Equal(NoProxy, seen);
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
Assert.Equal("direct", seenRoute);
}
[Fact]
public void A_subscriber_that_throws_does_not_reach_the_caller()
{
var observer = StartedObserver();
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
SwitchToNoProxy();
observer.CheckForChange();
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
}
[Fact]
public void The_first_failure_ties_the_series_to_the_change()
{
var observer = StartedObserver();
_clock.Advance(TimeSpan.FromMinutes(23));
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromSeconds(90));
SwitchToNoProxy();
observer.CheckForChange();
_clock.Advance(TimeSpan.FromSeconds(62));
observer.OnSendFailed(TimeSpan.FromSeconds(21));
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
}
[Fact]
public void A_failure_notices_a_change_the_watcher_did_not_report()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
var lines = LogLines();
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
Assert.Contains("after the proxy settings changed", lines.Last());
}
[Fact]
public void A_series_writes_its_finding_once()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
}
[Fact]
public void A_failure_without_a_change_says_the_settings_are_unchanged()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
var line = LogLines().Last();
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
}
[Fact]
public void A_change_before_the_last_success_is_not_blamed()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.CheckForChange();
_clock.Advance(TimeSpan.FromMinutes(1));
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromMinutes(1));
observer.OnSendFailed(TimeSpan.Zero);
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
}
[Fact]
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
_clock.Advance(TimeSpan.FromMinutes(9));
observer.OnSendSucceeded();
var line = LogLines().Last();
Assert.Contains("[INFO]", line);
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
observer.OnSendFailed(TimeSpan.Zero);
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
}
[Fact]
public void Recovery_reports_settings_that_came_back()
{
var observer = StartedObserver();
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
_settings = AutoDetect;
_route = "proxy.example.test:8080";
observer.OnSendSucceeded();
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
}
[Fact]
public void A_success_without_a_series_stays_silent()
{
var observer = StartedObserver();
observer.OnSendSucceeded();
Assert.Single(LogLines());
}
[Fact]
public void Stopping_while_failing_sums_it_up()
{
var observer = StartedObserver();
observer.OnSendSucceeded();
_clock.Advance(TimeSpan.FromMinutes(1));
SwitchToNoProxy();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendFailed(TimeSpan.Zero);
observer.LogOpenFailures();
var line = LogLines().Last();
Assert.Contains("[WARN]", line);
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
}
[Fact]
public void Stopping_without_a_series_stays_silent()
{
var observer = StartedObserver();
observer.OnSendFailed(TimeSpan.Zero);
observer.OnSendSucceeded();
var before = LogLines().Count;
observer.LogOpenFailures();
Assert.Equal(before, LogLines().Count);
}
[Fact]
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
{
var throwing = false;
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
observer.CaptureBaseline();
throwing = true;
observer.CheckForChange();
observer.OnSendFailed(TimeSpan.Zero);
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
}
[Fact]
public void An_unreadable_state_is_not_a_change()
{
var observer = StartedObserver();
_settings = ProxySettings.Unreadable(5);
observer.CheckForChange();
Assert.Single(LogLines());
}
[Fact]
public void A_route_read_that_throws_reads_as_unknown()
{
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
Assert.Equal("unknown", observer.RouteText());
}
[Fact]
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
{
using var release = new ManualResetEventSlim(false);
var reads = 0;
var observer = NewObserver(
readRoute: () =>
{
Interlocked.Increment(ref reads);
release.Wait();
return "direct";
},
routeBound: TimeSpan.FromMilliseconds(50));
Assert.Equal("unknown", observer.RouteText());
Assert.Equal("unknown", observer.RouteText());
Assert.Equal(1, Volatile.Read(ref reads));
release.Set();
}
}
}
@@ -0,0 +1,167 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class ProxySettingsTests
{
private static ProxySettings Settings(
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
[Fact]
public void Each_kind_of_setting_describes_itself()
{
Assert.Equal("no proxy", Settings().Describe());
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
Assert.Equal("direct", Settings().DescribeWinHttp());
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
}
[Theory]
[InlineData(false, null, null, "Direct")]
[InlineData(true, null, null, "AutoDetect")]
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
{
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
}
[Fact]
public void An_unreadable_state_has_its_own_type()
{
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
}
[Fact]
public void Equal_content_is_equal_and_any_field_makes_it_different()
{
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
Assert.NotEqual(Settings(autoDetect: true), Settings());
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
}
[Fact]
public void An_empty_string_counts_as_not_set()
{
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
}
[Theory]
[InlineData("user:secret@p:8080")]
[InlineData("http://user:secret@p:8080")]
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
[InlineData("user:se=cret@p:8080")]
[InlineData("user:se;cret@p:8080")]
[InlineData("http=user:se;cret@p:8080")]
[InlineData("user:se://cret@p:8080")]
public void Credentials_never_reach_logs_or_events(string configured)
{
var settings = Settings(proxy: configured, winHttp: configured);
var data = settings.ToEventData();
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
{
Assert.DoesNotContain("user", text);
Assert.DoesNotContain("cret", text);
}
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
Assert.True((bool)data["proxyEnabled"]);
Assert.True((bool)data["winHttpProxyConfigured"]);
}
[Theory]
[InlineData("p:8080")]
[InlineData("http://p:8080")]
[InlineData("http=p:8080;https=q:8443")]
public void A_value_without_credentials_stays_as_it_is(string configured)
{
Assert.Equal(configured, Settings(proxy: configured).Proxy);
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
}
[Theory]
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
[InlineData("not a url with secret", "(unparsable URL)")]
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
{
Assert.Equal(expected, Settings(pac: configured).PacUrl);
}
[Fact]
public void A_change_names_what_moved()
{
var before = Settings(autoDetect: true);
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
Assert.Equal(
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
Settings(winHttp: "p:1").DescribeChangeFrom(before));
Assert.Equal(
"IE settings of this account: proxy bypass list changed",
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
}
[Fact]
public void Only_the_account_settings_count_as_a_change_for_uploads()
{
var before = Settings(autoDetect: true);
Assert.True(Settings().DiffersForUploadsFrom(before));
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
}
[Fact]
public void The_event_tells_auto_detect_from_direct()
{
var data = Settings(autoDetect: true).ToEventData();
Assert.Equal("AutoDetect", data["proxyType"]);
Assert.True((bool)data["autoDetect"]);
Assert.False((bool)data["proxyEnabled"]);
Assert.Null(data["proxyServer"]);
Assert.False((bool)data["winHttpProxyConfigured"]);
Assert.Null(data["winHttpProxy"]);
}
[Fact]
public void The_event_carries_a_real_winhttp_proxy()
{
var data = Settings(winHttp: "p:8080").ToEventData();
Assert.True((bool)data["winHttpProxyConfigured"]);
Assert.Equal("p:8080", data["winHttpProxy"]);
}
[Fact]
public void The_real_settings_of_this_account_can_be_read()
{
var settings = ProxySettingsReader.Read();
Assert.False(settings.IsUnreadable, settings.Describe());
}
[Fact]
public async Task The_real_route_can_be_read()
{
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
Assert.False(string.IsNullOrEmpty(await read));
}
}
}
@@ -114,6 +114,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// fire once; <see cref="RealmJoinPackageStarted"/> / <see cref="RealmJoinPackageCompleted"/>
/// fire once per <c>(scope, packageId)</c>.
/// </para>
/// <para>
/// <see cref="RealmJoinDetected"/> carries the phase RJ is in when the service key is first
/// seen in this process — Blank (0) only while the value is not written yet. After an agent
/// restart RJ may already be deploying: the engine's restart path and the keep-awake
/// hand-over to RJ both read the phase from Detected, because no PhaseChanged follows when
/// the phase does not change again.
/// </para>
/// </remarks>
internal sealed class RealmJoinWatcher : IDisposable
{
@@ -127,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
internal delegate string? ReadUninstallDisplayVersionDelegate();
internal delegate int? ReadDeploymentPhaseDelegate();
/// <summary>
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
@@ -141,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly KeyExistsDelegate _keyExists;
private readonly ReadVersionInfoDelegate _readVersionInfo;
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
private readonly ReadDeploymentPhaseDelegate _readDeploymentPhase;
private readonly object _stateLock = new object();
// Auto-update stage (armed once Detected fired, held until Stop).
@@ -204,7 +213,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
TryReadPackageDelegate tryReadPackage,
KeyExistsDelegate keyExists,
ReadVersionInfoDelegate? readVersionInfo = null,
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null,
ReadDeploymentPhaseDelegate? readDeploymentPhase = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
@@ -212,6 +222,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
_readDeploymentPhase = readDeploymentPhase ?? (() => RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine));
}
public void Start()
@@ -452,14 +463,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
// The appearance of Services\realmjoin is itself the RJ-detected signal —
// surface it even if Parameters\DeploymentPhase isn't populated yet. The package
// watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here:
// NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin
// is observed. Before that the same subtree may still receive in-flight IME
// package writes that are not part of RJ's lifecycle.
NotifyRealmJoinPresence(phase: null);
CheckParameters();
NotifyServiceStageAttached();
}
/// <summary>
/// The appearance of Services\realmjoin is itself the RJ-detected signal — surfaced even
/// if Parameters\DeploymentPhase isn't populated yet. One read feeds Detected, so it
/// carries the phase RJ is in right now (see the class remarks). The package watchers
/// (HKLM + HKU SOFTWARE\RealmJoin) stay disarmed below PhaseRunningThresholdMin: before
/// that the same subtree may still receive in-flight IME package writes that are not
/// part of RJ's lifecycle.
/// </summary>
private void NotifyServiceStageAttached()
{
int? phase = null;
try { phase = _readDeploymentPhase(); }
catch (Exception ex) { _logger.Warning($"RealmJoinWatcher: DeploymentPhase read at attach threw: {ex.Message}"); }
NotifyRealmJoinPresence(phase);
}
private void AttachMachineRealmJoinSubtreeWatcher()
@@ -550,7 +570,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
try
{
var phase = RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine);
var phase = _readDeploymentPhase();
if (phase == null) return;
NotifyRealmJoinPresence(phase);
}
@@ -580,6 +600,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
fireDetected = !_detectedFired;
if (fireDetected) _detectedFired = true;
// Detected reports Blank (0) while the value is not written yet. That report is
// the baseline, so a first real value of 100+ surfaces as PhaseChanged instead of
// silently setting _lastPhase.
if (fireDetected && !phase.HasValue && !_lastPhase.HasValue) _lastPhase = 0;
if (phase.HasValue)
{
if (_lastPhase.HasValue && _lastPhase.Value != phase.Value)
@@ -926,6 +951,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Test seams — drive the read paths deterministically without touching the real registry.
internal void TriggerCheckParametersFromTest() => CheckParameters();
internal void TriggerServiceStageAttachedFromTest() => NotifyServiceStageAttached();
internal void TriggerCheckMachinePackagesFromTest() => CheckMachinePackages();
internal void TriggerCheckUserPackagesFromTest() => CheckUserPackages();
@@ -0,0 +1,41 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
internal static class WinHttpNativeMethods
{
public const int ERROR_FILE_NOT_FOUND = 2;
public const uint WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3;
[StructLayout(LayoutKind.Sequential)]
public struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG
{
[MarshalAs(UnmanagedType.Bool)]
public bool fAutoDetect;
public IntPtr lpszAutoConfigUrl;
public IntPtr lpszProxy;
public IntPtr lpszProxyBypass;
}
[StructLayout(LayoutKind.Sequential)]
public struct WINHTTP_PROXY_INFO
{
public uint dwAccessType;
public IntPtr lpszProxy;
public IntPtr lpszProxyBypass;
}
[DllImport("winhttp.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WinHttpGetIEProxyConfigForCurrentUser(ref WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config);
[DllImport("winhttp.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool WinHttpGetDefaultProxyConfiguration(ref WINHTTP_PROXY_INFO info);
// WinHTTP allocates the strings of both structs with GlobalAlloc; the caller frees them.
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GlobalFree(IntPtr hMem);
}
}
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{
try
{
var data = new Dictionary<string, object>();
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
{
if (key != null)
{
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
}
}
// Also check WinHTTP proxy
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
{
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
}
var proxyType = "Direct";
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
proxyType = "Proxy";
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
proxyType = "PAC";
data["proxyType"] = proxyType;
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
var settings = ProxySettingsReader.Read();
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
}
catch (Exception ex)
{
@@ -96,6 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
private readonly DateTime? _previousRunLastAliveUtc;
private readonly Transport.ProxyObserver? _proxyObserver;
public DefaultComponentFactory(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
@@ -105,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Persistence.StartupEventGate? startupEventGate = null,
DateTime? previousBootUtc = null,
string? previousExitType = null,
DateTime? previousRunLastAliveUtc = null)
DateTime? previousRunLastAliveUtc = null,
Transport.ProxyObserver? proxyObserver = null)
{
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
@@ -116,6 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_previousBootUtc = previousBootUtc;
_previousExitType = previousExitType;
_previousRunLastAliveUtc = previousRunLastAliveUtc;
_proxyObserver = proxyObserver;
}
/// <summary>
@@ -352,28 +356,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(aadJoinHost);
aadJoinHostRef = aadJoinHost;
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
// never armed and no RealmJoin signals are produced. The compile-time const
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
// the effective enable is (remote flag AND const).
var analyzers = _remoteConfig.Analyzers ?? new AnalyzerConfiguration();
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
{
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
hosts.Add(realmJoinHost);
}
else
{
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
}
// Keep-awake during User-ESP — opt-in per tenant (portal toggle → AnalyzerConfiguration.
// KeepAwakeDuringUserEsp, default off). The host observes the signal rail and holds the
// device awake (system + display) from AccountSetup entry until provisioning completes
// (or a safety cap), so idle standby cannot stall app installs / account setup. Reboots
// device awake (system + display) from AccountSetup entry until the user-ESP page
// closes — with the RealmJoin Watcher on, until RealmJoin starts its deployment (D-413) —
// or a safety cap, so idle standby cannot stall app installs / account setup. Reboots
// are unaffected; the OS auto-clears the hold on process exit.
// ORDER: before the RealmJoinHost — after an agent restart the watcher posts
// realmjoin_detected from its own Start, and this host must already be subscribed.
if (analyzers.KeepAwakeDuringUserEsp)
{
hosts.Add(new UserEspKeepAwakeHost(
@@ -382,7 +374,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger: logger,
// q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives
// after the AccountSetup registry left notStarted — never on the handoff exit.
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress));
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress,
// D-414: that exit is latched here, so a later agent run does not engage again.
startupGate: _startupEventGate));
}
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
// never armed and no RealmJoin signals are produced. The compile-time const
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
// the effective enable is (remote flag AND const).
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
{
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
hosts.Add(realmJoinHost);
}
else
{
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
}
// OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle →
@@ -666,6 +676,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
clock: clock,
logger: logger));
// The exact time of a proxy change for the agent log and the timeline. Always on: when
// this matters, nothing else from the device reaches the backend.
if (_proxyObserver != null)
{
hosts.Add(new ProxySettingsHost(
observer: _proxyObserver,
ingress: ingress,
clock: clock,
logger: logger));
}
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
// crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based
@@ -0,0 +1,137 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Tells <see cref="ProxyObserver"/> the moment proxy settings change, so the log has the exact
/// time. A watcher that fails costs only that time: the observer compares again on every failed
/// upload.
/// </summary>
internal sealed class ProxySettingsHost : ICollectorHost
{
private const string Source = "ProxySettingsHost";
private const int DebounceMs = 1000;
// The whole subtree: "Connections" does not exist yet in an account nothing has written to.
// HKCU of a SYSTEM process is HKU\S-1-5-18.
private const string AccountSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
// Holds WinHttpSettings, which netsh winhttp writes.
private const string MachineConnectionsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections";
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
RegistryNativeMethods.RegChangeNotifyFilter.Name |
RegistryNativeMethods.RegChangeNotifyFilter.LastSet;
private readonly ProxyObserver _observer;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
private readonly Timer _debounce;
private int _started;
private int _stopped;
public string Name => "ProxySettingsWatcher";
public ProxySettingsHost(
ProxyObserver observer,
ISignalIngressSink ingress,
IClock clock,
AgentLogger logger)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_observer = observer ?? throw new ArgumentNullException(nameof(observer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_post = new InformationalEventPost(ingress, clock);
_debounce = new Timer(_ => OnDebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
AddWatcher(RegistryHive.CurrentUser, AccountSettingsKey, watchSubtree: true, "account settings");
AddWatcher(RegistryHive.LocalMachine, MachineConnectionsKey, watchSubtree: false, "WinHTTP settings");
}
private void AddWatcher(RegistryHive hive, string subKey, bool watchSubtree, string label)
{
var watcher = new RegistryWatcher(hive, subKey, watchSubtree, RegistryView.Registry64, Filter);
watcher.Changed += (_, _) => _debounce.Change(DebounceMs, Timeout.Infinite);
watcher.Error += (_, ex) => _logger.Warning($"ProxySettingsHost: {label} watcher error - {ex.GetType().Name}: {ex.Message}");
_watchers.Add(watcher);
}
public void Start()
{
if (Interlocked.Exchange(ref _started, 1) == 1) return;
_observer.SettingsChanged += OnSettingsChanged;
foreach (var watcher in _watchers)
{
try { watcher.Start(); }
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: a watcher failed to start: {ex.Message}"); }
}
// Whatever moved between the baseline and the start of the watchers.
OnDebounceElapsed();
}
public void Stop()
{
if (Interlocked.Exchange(ref _stopped, 1) == 1) return;
DisposeWatchers();
_observer.SettingsChanged -= OnSettingsChanged;
if (Volatile.Read(ref _started) == 1) _observer.LogOpenFailures();
}
public void Dispose()
{
Stop();
_debounce.Dispose();
}
private void DisposeWatchers()
{
foreach (var watcher in _watchers)
{
try { watcher.Dispose(); }
catch (Exception ex) { _logger.Debug($"ProxySettingsHost: disposing a watcher threw: {ex.Message}"); }
}
_watchers.Clear();
}
private void OnDebounceElapsed()
{
_logger.Trace("ProxySettingsHost: checking the proxy settings.");
try { _observer.CheckForChange(); }
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: checking the proxy settings failed: {ex.Message}"); }
}
/// <summary>
/// Waits in the spool if uploads are down, so the timeline explains the gap once they work
/// again. The portal shows the newest event of this type.
/// </summary>
private void OnSettingsChanged(ProxySettings settings, string what, string route)
{
var data = settings.ToEventData();
data["trigger"] = "changed";
data["change"] = what;
data["route"] = route;
_post.Emit(
eventType: Constants.EventTypes.ProxyConfiguration,
source: Source,
message: $"Proxy settings changed: {what}",
severity: EventSeverity.Warning,
typedPayload: data);
}
}
}
@@ -1,10 +1,12 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
@@ -21,11 +23,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// It observes the central signal rail (<see cref="SignalIngress.SignalPosted"/>, the same
/// hook <c>GatherRuleExecutorHost</c> uses for phase_change triggers):
/// <list type="bullet">
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c>.</item>
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c> — unless the
/// user-ESP page already closed in this enrollment (D-414). The page exit is latched in the
/// <see cref="StartupEventGate"/>, because after an agent restart the AccountSetup phase
/// reaches the rail again (IME logs it at the next sign-in, and the adapter's dedup lives
/// per process).</item>
/// <item><b>Release</b> on <see cref="DecisionSignalKind.EspExiting"/> once the AccountSetup
/// registry shows progress (the real user-ESP page exit — see below), on
/// <see cref="Stop"/> (session teardown), or on the safety-cap timer (a backstop so a
/// device is never held awake for the agent's full max-lifetime).</item>
/// <item><b>RealmJoin hand-over</b> (D-413): RealmJoin signals only exist when the tenant runs
/// the RealmJoin Watcher. When RealmJoin was detected and is still Blank as the user-ESP
/// page closes, the hold stays until RealmJoin leaves Blank (it keeps the device awake
/// itself from there), bounded by <see cref="RealmJoinStartWaitMinutes"/>. RealmJoin
/// leaving Blank ends any hold; once it has, the host does not engage in this process.</item>
/// </list>
/// The hold itself is owned by <see cref="KeepAwakeController"/>; the OS also auto-clears it
/// on process exit / reboot, so it can never leak.
@@ -57,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// than host construction) means the ESP policy is already present in the registry. When the
/// value is unavailable (ESP skipped, WDP v2 with no ESP, or unreadable) the cap falls back to
/// <see cref="DefaultFallbackCapMinutes"/>. The resolved cap and its source are reported on the
/// <c>keep_awake_engaged</c> event for fleet-wide observability.
/// <c>keep_awake_engaged</c> event for fleet-wide observability. The RealmJoin wait replaces
/// this cap when the page closes: one timer, one bound per phase of the hold.
/// </para>
/// </summary>
internal sealed class UserEspKeepAwakeHost : ICollectorHost
@@ -78,9 +90,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
internal const int EspCapMarginMinutes = 30;
/// <summary>
/// How long the hold waits for RealmJoin to leave Blank after the user-ESP page closed
/// (D-413 has the measured start delays behind the value).
/// </summary>
internal const int RealmJoinStartWaitMinutes = 30;
/// <summary><see cref="StartupEventGate"/> latch: the user-ESP page closed in this enrollment (D-414).</summary>
internal const string UserEspPageClosedKey = "keep_awake.user_esp_page_closed";
// Release reasons (carried on the keep_awake_released event payload). "account_setup_complete"
// (release on the synthesized completion signal) was retired with q8n — see the class doc.
internal const string ReasonUserEspExited = "user_esp_exited";
internal const string ReasonRealmJoinStarted = "realmjoin_started";
internal const string ReasonRealmJoinNotStarted = "realmjoin_not_started";
internal const string ReasonHostStop = "host_stop";
internal const string ReasonSafetyCap = "safety_cap";
@@ -92,13 +115,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private static readonly string AccountSetupPhaseName = EnrollmentPhase.AccountSetup.ToString();
private readonly AgentLogger _logger;
private readonly IClock _clock;
private readonly InformationalEventPost _post;
private readonly KeepAwakeController _controller;
private readonly SignalIngress? _observableIngress;
private readonly int _fallbackCapMinutes;
private readonly TimeSpan? _safetyCapOverride;
private readonly TimeSpan _realmJoinStartWait;
private readonly Func<int?> _espTimeoutProvider;
private readonly Func<bool> _accountSetupProgressProbe;
private readonly StartupEventGate? _startupGate;
private enum HoldState { Idle, Engaging, Engaged, Released }
@@ -107,7 +133,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private Timer? _safetyCapTimer;
private HoldState _state = HoldState.Idle; // guarded by _gate
private int _activeCapMinutes; // guarded by _gate — for the cap-elapsed log line
private int _capGeneration; // guarded by _gate — a superseded timer callback is ignored
private bool _awaitingRealmJoin; // guarded by _gate — page closed, RealmJoin still Blank
private DateTime _userEspExitedUtc; // guarded by _gate — valid while _awaitingRealmJoin
private int _disposed;
private int _pageClosedLatched; // 0/1 — the latch write runs once per process
// RealmJoin observations of this process. Monotonic, written on the posting thread so the
// ThreadPool work that follows always sees them (0/1 for Volatile/Interlocked).
private int _realmJoinDetected;
private int _realmJoinStarted;
public UserEspKeepAwakeHost(
ISignalIngressSink ingress,
@@ -117,17 +152,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
KeepAwakeController? controller = null,
TimeSpan? safetyCapOverride = null,
Func<int?>? espTimeoutProvider = null,
Func<bool>? accountSetupProgressProbe = null)
Func<bool>? accountSetupProgressProbe = null,
TimeSpan? realmJoinStartWaitOverride = null,
StartupEventGate? startupGate = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_post = new InformationalEventPost(ingress, clock, logger);
_controller = controller ?? new KeepAwakeController(logger);
_observableIngress = ingress as SignalIngress;
_fallbackCapMinutes = fallbackCapMinutes > 0 ? fallbackCapMinutes : DefaultFallbackCapMinutes;
// safetyCapOverride is a test seam for a fixed (e.g. sub-second) cap.
// safetyCapOverride / realmJoinStartWaitOverride are test seams for fixed (e.g. sub-second) bounds.
_safetyCapOverride = safetyCapOverride;
_realmJoinStartWait = realmJoinStartWaitOverride ?? TimeSpan.FromMinutes(RealmJoinStartWaitMinutes);
// The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then).
_espTimeoutProvider = espTimeoutProvider
?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes);
@@ -135,6 +173,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// lasts until the safety cap (erring long, never early). Production always wires the
// EspAndHelloHost probe (DefaultComponentFactory).
_accountSetupProgressProbe = accountSetupProgressProbe ?? (() => false);
// Without a gate (tests) the page-closed latch is off and every agent run engages anew.
_startupGate = startupGate;
}
/// <summary>
@@ -180,21 +220,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool.
try
{
if (kind == DecisionSignalKind.EspExiting)
switch (kind)
{
// Only the user-ESP page exit releases: the Device→Account handoff 62407 arrives
// before any AccountSetup subcategory has left notStarted, the real exit after.
// Cheap to skip while idle; the probe is a locked dictionary scan.
if (IsEngagedOrEngaging() && ProbeAccountSetupProgress())
ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonUserEspExited, EventSeverity.Info));
return;
case DecisionSignalKind.EspExiting:
// Only the user-ESP page exit counts: the Device→Account handoff 62407 arrives
// before any AccountSetup subcategory has left notStarted, the real exit after.
// Checked in every hold state — the page-closed latch (D-414) must also be set
// while idle, capped or waiting. The probe is a locked dictionary scan.
if (ProbeAccountSetupProgress())
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnUserEspPageClosed, "user-ESP exit"));
return;
case DecisionSignalKind.RealmJoinDetected:
case DecisionSignalKind.RealmJoinPhaseChanged:
case DecisionSignalKind.RealmJoinResolved:
ObserveRealmJoin(kind, payload);
return;
}
if (payload != null
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
&& string.Equals(phaseName, AccountSetupPhaseName, StringComparison.OrdinalIgnoreCase))
{
ThreadPool.QueueUserWorkItem(_ => SafeEngage());
ThreadPool.QueueUserWorkItem(_ => SafeRun(Engage, "engage"));
}
}
catch (Exception ex)
@@ -203,15 +251,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private void SafeEngage()
/// <summary>
/// Records what the RealmJoin Watcher reported. The flags are set here, on the posting
/// thread, before any work is queued: the ThreadPool items run in no fixed order, and each
/// re-checks the hold state under <see cref="_gate"/>, so no hand-over is lost.
/// </summary>
private void ObserveRealmJoin(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
{
try { Engage(); }
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); }
Volatile.Write(ref _realmJoinDetected, 1);
// Resolved is phase 110 by definition; Detected and PhaseChanged carry the phase.
var leftBlank = kind == DecisionSignalKind.RealmJoinResolved
|| (payload != null
&& payload.TryGetValue(DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase, out var raw)
&& int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var phase)
&& phase >= RealmJoinInfo.PhaseRunningThresholdMin);
if (leftBlank && Interlocked.Exchange(ref _realmJoinStarted, 1) == 0)
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnRealmJoinStarted, "RealmJoin hand-over"));
}
private bool IsEngagedOrEngaging()
private void SafeRun(Action work, string what)
{
lock (_gate) { return _state == HoldState.Engaging || _state == HoldState.Engaged; }
try { work(); }
catch (Exception ex) { _logger.Error($"UserEspKeepAwakeHost: {what} failed.", ex); }
}
private bool ProbeAccountSetupProgress()
@@ -224,12 +287,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private void SafeRelease(string reason, EventSeverity severity)
{
try { Release(reason, severity, emit: true); }
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: release failed.", ex); }
}
/// <summary>Engage the hold exactly once (Idle → Engaging → Engaged). No-op from any other state.</summary>
private void Engage()
{
@@ -239,6 +296,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
lock (_gate)
{
if (_state != HoldState.Idle) return;
if (UserEspPageClosedEarlier())
{
// Typically after an agent restart: the AccountSetup phase reached the rail
// again, but the user-ESP page of this enrollment is already gone.
_state = HoldState.Released;
_logger.Info("UserEspKeepAwakeHost: the user-ESP page already closed in this enrollment — keep-awake not engaged.");
return;
}
if (Volatile.Read(ref _realmJoinStarted) == 1)
{
// Only after an agent restart: RealmJoin deploys (or is done) before the
// AccountSetup phase is seen again — nothing left to hand over.
_state = HoldState.Released;
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake not engaged.");
return;
}
// L16 (delta review 2026-07-02): claim the transition, but run the controller's
// Engage OUTSIDE the lock — it can block up to ~5 s on the keep-awake thread's
// ready-wait, and holding _gate across that stalled a concurrent Stop→Release.
@@ -272,8 +345,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
_activeCapMinutes = capMinutes;
// One-shot backstop (NOT periodic): fire once after the cap, then release.
_safetyCapTimer = new Timer(OnSafetyCapElapsed, null, cap, Timeout.InfiniteTimeSpan);
// One-shot backstop (NOT periodic). The page may have closed while engaging — then
// the RealmJoin wait is the bound already.
ArmCapTimer(_awaitingRealmJoin ? _realmJoinStartWait : cap);
_state = HoldState.Engaged;
}
@@ -281,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
["osAccepted"] = osAccepted ? "true" : "false",
["scope"] = "system+display",
["capMinutes"] = capMinutes.ToString(),
["capMinutes"] = capMinutes.ToString(CultureInfo.InvariantCulture),
["capSource"] = capSource,
});
}
@@ -296,41 +370,151 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
/// <summary>
/// The user-ESP page closed. The fact is latched for later agent runs first. Without
/// RealmJoin, or with RealmJoin already past Blank, the hold ends here. With RealmJoin still
/// Blank it waits for the hand-over: the ESP-sized cap gives way to the RealmJoin wait.
/// </summary>
private void OnUserEspPageClosed()
{
LatchUserEspPageClosed();
lock (_gate)
{
if (_state != HoldState.Engaging && _state != HoldState.Engaged) return;
if (_awaitingRealmJoin) return; // a repeated exit signal
if (Volatile.Read(ref _realmJoinDetected) == 1 && Volatile.Read(ref _realmJoinStarted) == 0)
{
_awaitingRealmJoin = true;
_userEspExitedUtc = _clock.UtcNow;
// While Engaging, Engage arms the wait when it completes.
if (_state == HoldState.Engaged) ArmCapTimer(_realmJoinStartWait);
_logger.Info($"UserEspKeepAwakeHost: user-ESP page closed before RealmJoin started its deployment — holding keep-awake until it starts (max {_realmJoinStartWait.TotalMinutes:0} min).");
return;
}
}
Release(ReasonUserEspExited, EventSeverity.Info);
}
// MarkSucceeded persists at once (fail-soft): the page is gone for this enrollment.
private void LatchUserEspPageClosed()
{
if (_startupGate == null || Interlocked.Exchange(ref _pageClosedLatched, 1) == 1) return;
try { _startupGate.MarkSucceeded(UserEspPageClosedKey); }
catch (Exception ex) { _logger.Debug($"UserEspKeepAwakeHost: page-closed latch write threw: {ex.Message}"); }
}
// In-memory lookup (no I/O) — safe under _gate.
private bool UserEspPageClosedEarlier()
{
if (_startupGate == null) return false;
try { return _startupGate.AlreadySucceeded(UserEspPageClosedKey); }
catch (Exception ex)
{
_logger.Debug($"UserEspKeepAwakeHost: page-closed latch read threw: {ex.Message}");
return false;
}
}
/// <summary>RealmJoin left Blank — it keeps the device awake from here.</summary>
private void OnRealmJoinStarted()
{
Dictionary<string, string>? extra = null;
lock (_gate)
{
if (_state == HoldState.Released) return;
if (_state == HoldState.Idle)
{
// Only after an agent restart (RealmJoin starts after the user-ESP page closed):
// no hold to hand over, and none to take up later in this process.
_state = HoldState.Released;
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake will not engage.");
return;
}
if (_awaitingRealmJoin)
{
var waited = Math.Max(0, (_clock.UtcNow - _userEspExitedUtc).TotalSeconds);
extra = new Dictionary<string, string>(StringComparer.Ordinal)
{
["waitedSeconds"] = ((long)waited).ToString(CultureInfo.InvariantCulture),
};
}
}
Release(ReasonRealmJoinStarted, EventSeverity.Info, extra);
}
/// <summary>
/// Release the hold exactly once (Idle/Engaging/Engaged → Released). Idempotent — a second
/// call (or a competing completion / cap / stop trigger) is a no-op. A release that lands
/// while an Engage is mid-flight (Engaging) wins: the engage path detects the final state
/// after its blocking controller call and rolls the OS hold back (L16).
/// </summary>
private void Release(string reason, EventSeverity severity, bool emit)
private void Release(string reason, EventSeverity severity, IReadOnlyDictionary<string, string>? extra = null)
{
bool wasEngaged;
lock (_gate)
{
if (_state == HoldState.Released) return;
DisarmSafetyCap();
DisarmCapTimer();
wasEngaged = _controller.Release();
_state = HoldState.Released;
}
if (wasEngaged && emit)
if (!wasEngaged) return;
var data = new Dictionary<string, string>(StringComparer.Ordinal) { ["reason"] = reason };
if (extra != null)
{
Emit(Constants.EventTypes.KeepAwakeReleased, severity, new Dictionary<string, string>(StringComparer.Ordinal)
{
["reason"] = reason,
});
foreach (var kv in extra) data[kv.Key] = kv.Value;
}
Emit(Constants.EventTypes.KeepAwakeReleased, severity, data);
}
private void OnSafetyCapElapsed(object? state)
private void OnCapTimerElapsed(object? timerState)
{
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
SafeRelease(ReasonSafetyCap, EventSeverity.Warning);
string reason;
EventSeverity severity;
Dictionary<string, string>? extra = null;
lock (_gate)
{
// A timer replaced at the page exit can still fire once — only the current one counts.
if (!(timerState is int generation) || generation != _capGeneration || _state == HoldState.Released) return;
if (_awaitingRealmJoin)
{
reason = ReasonRealmJoinNotStarted;
severity = EventSeverity.Info;
extra = new Dictionary<string, string>(StringComparer.Ordinal)
{
["waitMinutes"] = ((int)Math.Round(_realmJoinStartWait.TotalMinutes)).ToString(CultureInfo.InvariantCulture),
};
_logger.Info($"UserEspKeepAwakeHost: RealmJoin did not start within {_realmJoinStartWait.TotalMinutes:0} min after the user-ESP page closed — releasing keep-awake.");
}
else
{
reason = ReasonSafetyCap;
severity = EventSeverity.Warning;
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
}
}
SafeRun(() => Release(reason, severity, extra), "release");
}
// Must be called under _gate.
private void DisarmSafetyCap()
private void ArmCapTimer(TimeSpan dueIn)
{
DisarmCapTimer();
_safetyCapTimer = new Timer(OnCapTimerElapsed, _capGeneration, dueIn, Timeout.InfiniteTimeSpan);
}
// Must be called under _gate.
private void DisarmCapTimer()
{
_capGeneration++;
var timer = _safetyCapTimer;
_safetyCapTimer = null;
try { timer?.Dispose(); } catch { }
@@ -369,7 +553,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Release synchronously on teardown. If a completion/cap release already won, this is a
// no-op (state already Released). Emit is best-effort — the ingress may already be down.
Release(ReasonHostStop, EventSeverity.Info, emit: true);
Release(ReasonHostStop, EventSeverity.Info);
}
public void Dispose()
@@ -378,5 +562,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Stop();
try { _controller.Dispose(); } catch { }
}
// Test seam — the page closed while RealmJoin was still Blank and the hold now waits for it.
internal bool AwaitingRealmJoinForTest
{
get { lock (_gate) { return _awaitingRealmJoin; } }
}
}
}
@@ -0,0 +1,287 @@
#nullable enable
using System;
using System.Globalization;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
/// <summary>
/// Makes the agent log say which way uploads go and what happened to it. Something on the device
/// can change the proxy settings of the agent's account; .NET follows at once, and from then on
/// no channel may reach the backend, so the log on the device is the only witness.
/// <para>
/// Compares with what held at the start instead of judging a value: "no proxy" is the right
/// state on one network and the broken one on the next. Reads on failures and on registry
/// changes, never by polling. Nothing here throws into the upload path.
/// </para>
/// </summary>
public sealed class ProxyObserver
{
internal const string RouteUnknown = "unknown";
private static readonly TimeSpan DefaultRouteBound = TimeSpan.FromSeconds(3);
private readonly Func<ProxySettings> _readSettings;
private readonly Func<string> _readRoute;
private readonly IClock _clock;
private readonly AgentLogger _logger;
private readonly TimeSpan _routeBound;
private readonly object _sync = new object();
private ProxySettings? _current;
private string _lastRoute = RouteUnknown;
private Task<string>? _routeRead;
private DateTime? _uploadChangeAtUtc;
private string? _uploadChange;
private DateTime? _failingSinceUtc;
private int _failedAttempts;
private DateTime? _lastSuccessUtc;
/// <summary>Settings now, what changed, route now. Raised outside the lock, on the caller's thread.</summary>
internal event Action<ProxySettings, string, string>? SettingsChanged;
public ProxyObserver(string baseUrl, AgentLogger logger, IClock clock)
: this(
ProxySettingsReader.Read,
RouteReaderFor(baseUrl),
logger,
clock,
DefaultRouteBound)
{
}
internal ProxyObserver(
Func<ProxySettings> readSettings,
Func<string> readRoute,
AgentLogger logger,
IClock clock,
TimeSpan routeBound)
{
_readSettings = readSettings ?? throw new ArgumentNullException(nameof(readSettings));
_readRoute = readRoute ?? throw new ArgumentNullException(nameof(readRoute));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_routeBound = routeBound;
}
private static Func<string> RouteReaderFor(string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var target)) return () => RouteUnknown;
return () => ProxySettingsReader.ReadRoute(target);
}
/// <summary>Logs the state to compare against. Runs before the first request of the agent.</summary>
public void CaptureBaseline()
{
var settings = ReadSettingsSafe();
var route = ReadRouteBounded();
lock (_sync)
{
if (_current == null && !settings.IsUnreadable) _current = settings;
_lastRoute = route;
}
// Same text on a rebuild of the clients after an endpoint migration: the repeat goes to Trace.
_logger.Info($"ProxyObserver: start - {settings.DescribeAll()}; route to backend: {route}", repeatKey: "ProxyObserver.baseline");
}
/// <summary>Re-reads the settings and reports a change of content once, whoever notices it first.</summary>
public void CheckForChange()
{
var settings = ReadSettingsSafe();
if (settings.IsUnreadable) return;
ProxySettings previous;
var affectsUploads = false;
lock (_sync)
{
if (_current == null)
{
_current = settings;
return;
}
if (_current.Equals(settings)) return;
previous = _current;
_current = settings;
if (settings.DiffersForUploadsFrom(previous))
{
affectsUploads = true;
_uploadChangeAtUtc = _clock.UtcNow;
_uploadChange = settings.DescribeUploadChangeFrom(previous);
}
}
var route = ReadRouteBounded();
string routeBefore;
lock (_sync)
{
routeBefore = _lastRoute;
_lastRoute = route;
}
var what = settings.DescribeChangeFrom(previous);
_logger.Warning(affectsUploads
? $"ProxyObserver: proxy settings changed - {what}; route to backend: {(routeBefore == route ? route : $"{routeBefore} -> {route}")}. " +
"The agent's uploads follow these settings. Check with: bitsadmin /util /getieproxy localsystem"
: $"ProxyObserver: proxy settings changed - {what}. The agent's uploads do not use WinHTTP, services that rely on its default do.");
try
{
SettingsChanged?.Invoke(settings, what, route);
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: change handler threw: {ex.Message}");
}
}
/// <summary>The next hop to the backend: <c>direct</c>, <c>host:port</c> or <c>unknown</c>.</summary>
public string RouteText() => ReadRouteBounded();
/// <summary>
/// An upload did not reach the backend. The first of a series says what the reader needs to
/// know: when it began and whether the proxy settings moved before that.
/// </summary>
public void OnSendFailed(TimeSpan attemptDuration)
{
// Also catches a change the registry watcher did not report.
CheckForChange();
DateTime since;
DateTime? lastSuccess;
DateTime? changedAt;
string? change;
string? current;
lock (_sync)
{
_failedAttempts++;
if (_failingSinceUtc != null) return;
since = _clock.UtcNow - attemptDuration;
_failingSinceUtc = since;
lastSuccess = _lastSuccessUtc;
changedAt = _uploadChangeAtUtc;
change = _uploadChange;
current = _current?.Describe();
}
var route = ReadRouteBounded();
var lastUpload = lastSuccess == null ? "No successful upload yet." : $"Last successful upload {Time(lastSuccess.Value)}.";
if (changedAt != null && (lastSuccess == null || changedAt > lastSuccess))
{
_logger.Warning(
$"ProxyObserver: uploads are failing since {Time(since)}, {Span(since - changedAt.Value)} after the proxy settings changed at {Time(changedAt.Value)} ({change}). " +
$"Route to backend: {route}. {lastUpload}");
}
else
{
var unchangedSince = changedAt == null ? "start" : "the last successful upload";
_logger.Warning(
$"ProxyObserver: uploads are failing since {Time(since)}. Proxy settings of this account unchanged since {unchangedSince}: {current ?? RouteUnknown}. " +
$"Route to backend: {route}. {lastUpload}");
}
}
public void OnSendSucceeded()
{
DateTime since;
int attempts;
lock (_sync)
{
_lastSuccessUtc = _clock.UtcNow;
if (_failingSinceUtc == null) return;
since = _failingSinceUtc.Value;
attempts = _failedAttempts;
_failingSinceUtc = null;
_failedAttempts = 0;
}
CheckForChange();
_logger.Info($"ProxyObserver: uploads work again after {attempts} failed attempt(s) over {Span(_clock.UtcNow - since)}; route to backend: {ReadRouteBounded()}.");
}
/// <summary>The end of the log is read first, so a run that ends while failing says so there.</summary>
public void LogOpenFailures()
{
DateTime since;
int attempts;
DateTime? lastSuccess;
DateTime? changedAt;
string? change;
string? current;
lock (_sync)
{
if (_failingSinceUtc == null) return;
since = _failingSinceUtc.Value;
attempts = _failedAttempts;
lastSuccess = _lastSuccessUtc;
changedAt = _uploadChangeAtUtc;
change = _uploadChange;
current = _current?.Describe();
}
var lastUpload = lastSuccess == null ? "no successful upload" : $"last success {Time(lastSuccess.Value)}";
var settings = changedAt == null
? $"Proxy settings of this account unchanged since start: {current ?? RouteUnknown}."
: $"Proxy settings changed at {Time(changedAt.Value)} ({change}).";
_logger.Warning(
$"ProxyObserver: stopping with uploads still failing: {attempts} failed attempt(s) since {Time(since)}, {lastUpload}, route to backend: {ReadRouteBounded()}. {settings}");
}
private ProxySettings ReadSettingsSafe()
{
try
{
var settings = _readSettings();
if (settings.IsUnreadable) _logger.Debug($"ProxyObserver: proxy settings {settings.Describe()}", repeatKey: "ProxyObserver.unreadable");
return settings;
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: reading the proxy settings threw: {ex.Message}", repeatKey: "ProxyObserver.readThrew");
return ProxySettings.Unreadable(-1);
}
}
/// <summary>
/// Auto-detect can hang and must hold up neither the start nor an upload. A read still under
/// way is joined, so a hanging one does not pile up pool threads.
/// </summary>
private string ReadRouteBounded()
{
Task<string> read;
lock (_sync)
{
if (_routeRead == null || _routeRead.IsCompleted) _routeRead = Task.Run(ReadRouteSafe);
read = _routeRead;
}
return read.Wait(_routeBound) ? read.Result : RouteUnknown;
}
private string ReadRouteSafe()
{
try
{
return _readRoute();
}
catch (Exception ex)
{
_logger.Debug($"ProxyObserver: reading the route threw: {ex.Message}", repeatKey: "ProxyObserver.routeThrew");
return RouteUnknown;
}
}
private static string Time(DateTime utc) => utc.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
private static string Span(TimeSpan span)
{
var seconds = Math.Max(0, (long)span.TotalSeconds);
return seconds < 120 ? $"{seconds} s" : $"{seconds / 60} min";
}
}
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
/// <summary>
/// The proxy settings Windows holds for the account this process runs as (what
/// <c>bitsadmin /util /getieproxy localsystem</c> shows for SYSTEM and what .NET uses), plus the
/// WinHTTP proxy (<c>netsh winhttp show proxy</c>). Compared by content, because WinINET rewrites
/// its settings blob without changing anything. The values end up in logs and events, so
/// credentials and PAC query strings are kept out on the way in.
/// </summary>
internal sealed class ProxySettings : IEquatable<ProxySettings>
{
internal const string CredentialsHidden = "(value with credentials, not shown)";
public bool AutoDetect { get; }
public string PacUrl { get; }
public string Proxy { get; }
public string Bypass { get; }
public string WinHttpProxy { get; }
/// <summary>Win32 error of a failed read; null when the settings were read.</summary>
public int? ReadError { get; }
public bool IsUnreadable => ReadError != null;
public ProxySettings(bool autoDetect, string pacUrl, string proxy, string bypass, string winHttpProxy)
{
AutoDetect = autoDetect;
PacUrl = SanitizeUrl(pacUrl);
Proxy = HideCredentials(proxy);
Bypass = EmptyToNull(bypass);
WinHttpProxy = HideCredentials(winHttpProxy);
}
private ProxySettings(int readError)
{
ReadError = readError;
}
public static ProxySettings Unreadable(int win32Error) => new ProxySettings(win32Error);
/// <summary>Kept in step with ANALYZE-CORR-003, which matches <c>Proxy|PAC</c>.</summary>
public string ProxyType =>
IsUnreadable ? "Unknown"
: Proxy != null ? "Proxy"
: PacUrl != null ? "PAC"
: AutoDetect ? "AutoDetect"
: "Direct";
public string Describe()
{
if (IsUnreadable) return $"unreadable (Win32 error {ReadError})";
var parts = new List<string>(3);
if (AutoDetect) parts.Add("auto-detect");
if (PacUrl != null) parts.Add($"PAC {PacUrl}");
if (Proxy != null) parts.Add($"proxy {Proxy}");
return parts.Count == 0 ? "no proxy" : string.Join(", ", parts);
}
public string DescribeWinHttp() => WinHttpProxy == null ? "direct" : $"proxy {WinHttpProxy}";
public string DescribeAll() => $"IE settings of this account: {Describe()}; WinHTTP: {DescribeWinHttp()}";
/// <summary>The account's own settings, which the agent's uploads follow; WinHTTP is apart.</summary>
public bool DiffersForUploadsFrom(ProxySettings other) =>
AutoDetect != other.AutoDetect
|| PacUrl != other.PacUrl
|| Proxy != other.Proxy
|| Bypass != other.Bypass;
public string DescribeUploadChangeFrom(ProxySettings previous) =>
Describe() == previous.Describe()
? "IE settings of this account: proxy bypass list changed"
: $"IE settings of this account: {previous.Describe()} -> {Describe()}";
public string DescribeChangeFrom(ProxySettings previous)
{
var parts = new List<string>(2);
if (DiffersForUploadsFrom(previous)) parts.Add(DescribeUploadChangeFrom(previous));
if (WinHttpProxy != previous.WinHttpProxy) parts.Add($"WinHTTP: {previous.DescribeWinHttp()} -> {DescribeWinHttp()}");
return string.Join("; ", parts);
}
/// <summary>Payload of the <c>proxy_configuration</c> event.</summary>
public Dictionary<string, object> ToEventData() => new Dictionary<string, object>
{
{ "proxyType", ProxyType },
{ "autoDetect", AutoDetect },
{ "proxyEnabled", Proxy != null },
{ "proxyServer", Proxy },
{ "proxyOverride", Bypass },
{ "autoConfigUrl", PacUrl },
{ "winHttpProxyConfigured", WinHttpProxy != null },
{ "winHttpProxy", WinHttpProxy },
};
public bool Equals(ProxySettings other) =>
other != null
&& ReadError == other.ReadError
&& !DiffersForUploadsFrom(other)
&& WinHttpProxy == other.WinHttpProxy;
public override bool Equals(object obj) => Equals(obj as ProxySettings);
public override int GetHashCode() => (AutoDetect, PacUrl, Proxy, Bypass, WinHttpProxy, ReadError).GetHashCode();
private static string EmptyToNull(string value) => string.IsNullOrEmpty(value) ? null : value;
/// <summary>Scheme, host, port and path. A query string can carry a key; an unparsable URL is not repeated.</summary>
private static string SanitizeUrl(string url)
{
if (string.IsNullOrEmpty(url)) return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return "(unparsable URL)";
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
return $"{uri.Scheme}://{uri.Host}{port}{uri.AbsolutePath}";
}
/// <summary>
/// A proxy string has no defined form for credentials and a password can hold <c>=</c>,
/// <c>;</c> or <c>://</c>, so a value with an <c>@</c> is replaced as a whole, not taken apart.
/// </summary>
private static string HideCredentials(string proxy)
{
if (string.IsNullOrEmpty(proxy)) return null;
return proxy.IndexOf('@') < 0 ? proxy : CredentialsHidden;
}
}
}
@@ -0,0 +1,67 @@
using System;
using System.Net;
using System.Runtime.InteropServices;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Transport
{
internal static class ProxySettingsReader
{
public const string RouteDirect = "direct";
/// <summary>Reads the registry through WinHTTP, no network. Does not throw on a failed read.</summary>
public static ProxySettings Read()
{
var ie = new WinHttpNativeMethods.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG();
var winHttp = new WinHttpNativeMethods.WINHTTP_PROXY_INFO();
try
{
if (!WinHttpNativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ref ie))
{
var error = Marshal.GetLastWin32Error();
// The account has never had settings written, so nothing configures a proxy.
if (error != WinHttpNativeMethods.ERROR_FILE_NOT_FOUND) return ProxySettings.Unreadable(error);
}
if (!WinHttpNativeMethods.WinHttpGetDefaultProxyConfiguration(ref winHttp))
{
return ProxySettings.Unreadable(Marshal.GetLastWin32Error());
}
return new ProxySettings(
autoDetect: ie.fAutoDetect,
pacUrl: Marshal.PtrToStringUni(ie.lpszAutoConfigUrl),
proxy: Marshal.PtrToStringUni(ie.lpszProxy),
bypass: Marshal.PtrToStringUni(ie.lpszProxyBypass),
winHttpProxy: winHttp.dwAccessType == WinHttpNativeMethods.WINHTTP_ACCESS_TYPE_NAMED_PROXY
? Marshal.PtrToStringUni(winHttp.lpszProxy)
: null);
}
finally
{
Free(ie.lpszAutoConfigUrl);
Free(ie.lpszProxy);
Free(ie.lpszProxyBypass);
Free(winHttp.lpszProxy);
Free(winHttp.lpszProxyBypass);
}
}
/// <summary>
/// The next hop .NET chooses for <paramref name="target"/>: the same decision an
/// <c>HttpClientHandler</c> with default settings takes, PAC and auto-detect evaluated.
/// Auto-detect can take seconds on a network without WPAD.
/// </summary>
public static string ReadRoute(Uri target)
{
var hop = WebRequest.DefaultWebProxy?.GetProxy(target);
if (hop == null || string.Equals(hop.Host, target.Host, StringComparison.OrdinalIgnoreCase)) return RouteDirect;
return hop.IsDefaultPort ? hop.Host : $"{hop.Host}:{hop.Port}";
}
private static void Free(IntPtr memory)
{
if (memory != IntPtr.Zero) WinHttpNativeMethods.GlobalFree(memory);
}
}
}
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
private readonly Logging.AgentLogger? _logger;
private readonly ServerCertificateObserver? _serverCertificates;
private readonly NetworkMetrics? _networkMetrics;
private readonly ProxyObserver? _proxy;
/// <param name="serverCertificates">
/// Observer installed on the <paramref name="httpClient"/>'s handler; its issuer becomes
@@ -60,6 +61,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
/// <param name="networkMetrics">
/// Counts 2xx answers without the correlation echo (<c>agent_metrics_snapshot</c>). Null = not counted.
/// </param>
/// <param name="proxy">
/// Names the route in the failure lines and hears how each upload ended. Null = neither.
/// </param>
public BackendTelemetryUploader(
HttpClient httpClient,
string baseUrl,
@@ -72,7 +76,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
AuthFailureTracker? authFailureTracker = null,
Logging.AgentLogger? logger = null,
ServerCertificateObserver? serverCertificates = null,
NetworkMetrics? networkMetrics = null)
NetworkMetrics? networkMetrics = null,
ProxyObserver? proxy = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (string.IsNullOrEmpty(baseUrl)) throw new ArgumentException("BaseUrl is mandatory.", nameof(baseUrl));
@@ -89,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
_logger = logger;
_serverCertificates = serverCertificates;
_networkMetrics = networkMetrics;
_proxy = proxy;
}
public async Task<UploadResult> UploadBatchAsync(
@@ -153,7 +159,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
catch (TaskCanceledException ex)
{
sw.Stop();
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {ex.Message}");
_proxy?.OnSendFailed(sw.Elapsed);
// HttpClient timeout (not caller cancellation) surfaces as TaskCanceledException
// with cancellationToken.IsCancellationRequested == false.
return UploadResult.Transient($"timeout: {ex.Message}");
@@ -162,7 +169,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
{
sw.Stop();
var cause = TransportErrorCodes.DescribeSendFailure(ex);
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {cause}");
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {cause}");
_proxy?.OnSendFailed(sw.Elapsed);
// DNS failure, socket error, etc. — network-layer is retryable.
return UploadResult.Transient($"network: {cause}");
}
@@ -175,6 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
if (result.Success)
{
_logger?.Verbose($"BackendTelemetryUploader: ingest OK (corr={correlationId}, items={items.Count}, firstId={items[0].TelemetryItemId}, lastId={items[items.Count - 1].TelemetryItemId}, bytes={compressedBody.Length}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}).");
_proxy?.OnSendSucceeded();
}
else if (result.IsTransient)
{
@@ -189,7 +198,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
$"BackendTelemetryUploader: ingest INTERCEPTED — the answer did not come from the backend " +
$"(corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={interception.StatusCode}, " +
$"reason={interception.ForeignReason}, pageTitle=\"{interception.PageTitle ?? "(none)"}\", " +
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\").");
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\"{RouteSuffix()}).");
_proxy?.OnSendFailed(sw.Elapsed);
}
else if (result.IsSessionGone)
{
@@ -205,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
}
}
private string RouteSuffix() => _proxy == null ? string.Empty : $", route={_proxy.RouteText()}";
private async Task<UploadResult> MapResponseAsync(HttpResponseMessage response, string correlationId)
{
var statusCode = (int)response.StatusCode;
@@ -99,7 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Measured, not guessed: the reducer snapshot is rewritten on every decision step,
// so its mtime is the last moment the previous run is known to have been alive —
// and unlike LastBootUtc it exists for every exit type, exception_crash included.
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir));
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir),
proxyObserver: auth.ProxyObserver);
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty<string>();
@@ -52,6 +52,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// outbound call (legacy BackendApiClient calls + dominant /api/agent/telemetry POSTs).
var networkMetrics = new NetworkMetrics();
// Before the first request, so a registration that fails has the route in the log too.
var proxyObserver = new ProxyObserver(agentConfig.ApiBaseUrl, logger, SystemClock.Instance);
proxyObserver.CaptureBaseline();
var hardware = HardwareInfo.GetHardwareInfo(logger);
// Resolve the MDM client cert when cert-mode is on. Cert-missing is non-fatal here:
@@ -150,6 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
emergencyReporter: emergencyReporter,
authFailureTracker: authFailureTracker,
serverCertificates: serverCertificates,
proxyObserver: proxyObserver,
clientCertificateThumbprint: clientCertificate?.Thumbprint);
}
@@ -201,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
authFailureTracker: auth.AuthFailureTracker,
logger: logger, // Plan §5 Fix 5 — upload-cadence logging
serverCertificates: auth.ServerCertificates,
networkMetrics: auth.NetworkMetrics);
networkMetrics: auth.NetworkMetrics,
proxy: auth.ProxyObserver);
}
catch (Exception ex)
{
@@ -291,6 +297,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
public ServerCertificateObserver ServerCertificates { get; }
/// <summary>Shared by the telemetry uploader built in Phase 5 and the collector host.</summary>
public ProxyObserver ProxyObserver { get; }
/// <summary>
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
/// failed run's record so the next successful start can tell whether the certificate changed.
@@ -308,6 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter emergencyReporter,
AuthFailureTracker authFailureTracker,
ServerCertificateObserver serverCertificates,
ProxyObserver proxyObserver,
string clientCertificateThumbprint = null)
{
BackendApiClient = backendApiClient;
@@ -320,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter = emergencyReporter;
AuthFailureTracker = authFailureTracker;
ServerCertificates = serverCertificates;
ProxyObserver = proxyObserver;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
}
@@ -0,0 +1,184 @@
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The size limits of a tenant-authored analyze rule (D-410): at most 20 conditions and 10
/// preconditions, and every value a regex or not_regex operator compares — condition value,
/// filter value, event A filter value, precondition value — at most 2,000 characters and
/// parseable. One implementation feeds the dry-run validation and every write path (create,
/// update, template copy); the boundary itself is always allowed.
/// </summary>
public class AnalyzeRuleShapeLimitsTests
{
private const string TenantId = "tenant-limits";
[Fact]
public void A_rule_at_every_limit_passes()
{
var rule = Rule(AnalyzeRuleLimits.MaxConditions, AnalyzeRuleLimits.MaxPreconditions);
var atLimit = new string('a', AnalyzeRuleLimits.MaxRegexLength);
rule.Conditions[0].Operator = "regex";
rule.Conditions[0].Value = atLimit;
rule.Conditions[1].FilterField = "f";
rule.Conditions[1].FilterOperator = "not_regex";
rule.Conditions[1].FilterValue = atLimit;
rule.Conditions[2].EventAFilterOperator = "REGEX";
rule.Conditions[2].EventAFilterValue = atLimit;
rule.Preconditions[0].Operator = "regex";
rule.Preconditions[0].Value = atLimit;
Assert.Empty(AnalyzeRuleService.ShapeLimitErrors(rule));
}
[Fact]
public void One_condition_or_precondition_over_the_limit_is_named()
{
var errors = AnalyzeRuleService.ShapeLimitErrors(Rule(AnalyzeRuleLimits.MaxConditions + 1, AnalyzeRuleLimits.MaxPreconditions + 1));
Assert.Contains(errors, e => e.Contains("at most 20 conditions (this one has 21)"));
Assert.Contains(errors, e => e.Contains("at most 10 preconditions (this one has 11)"));
}
[Theory]
[InlineData("conditions[0].value")]
[InlineData("conditions[0].filterValue")]
[InlineData("conditions[0].eventAFilterValue")]
[InlineData("preconditions[0].value")]
public void A_regex_over_the_length_limit_is_rejected_wherever_it_sits(string field)
{
var rule = Rule(1, 1);
Place(rule, field, "regex", new string('a', AnalyzeRuleLimits.MaxRegexLength + 1));
var error = Assert.Single(AnalyzeRuleService.ShapeLimitErrors(rule));
Assert.StartsWith($"{field}: a regex may be at most 2000 characters (this one has 2001)", error);
}
[Theory]
[InlineData("conditions[0].filterValue")]
[InlineData("conditions[0].eventAFilterValue")]
[InlineData("preconditions[0].value")]
public void An_unparseable_regex_is_rejected_beyond_the_condition_value(string field)
{
var rule = Rule(1, 1);
Place(rule, field, "not_regex", "([unclosed");
var error = Assert.Single(AnalyzeRuleService.ShapeLimitErrors(rule));
Assert.StartsWith($"{field}: value is not a valid regex", error);
}
[Fact]
public void A_long_value_of_any_other_operator_is_not_limited()
{
var rule = Rule(1, 0);
rule.Conditions[0].Operator = "not_in";
rule.Conditions[0].Value = string.Join(",", Enumerable.Range(0, 1000).Select(i => $"app-{i:D5}"));
Assert.Empty(AnalyzeRuleService.ShapeLimitErrors(rule));
}
[Fact]
public void The_dry_run_validation_carries_the_limits()
{
var rule = Rule(AnalyzeRuleLimits.MaxConditions + 1, 0);
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at most 20 conditions"));
}
// ===== write paths =====
[Fact]
public async Task Create_rejects_an_oversized_rule_before_anything_is_stored()
{
var repo = new Mock<IRuleRepository>();
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateRuleAsync(TenantId, Rule(AnalyzeRuleLimits.MaxConditions + 1, 0)));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Update_of_a_custom_rule_rejects_an_oversized_regex_before_anything_is_stored()
{
var repo = new Mock<IRuleRepository>();
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var rule = Rule(1, 0);
Place(rule, "conditions[0].value", "not_regex", new string('a', AnalyzeRuleLimits.MaxRegexLength + 1));
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Template_copy_rejects_an_oversized_regex_from_its_variable()
{
// ANALYZE-SEC-006 shape: the template variable IS the tenant's not_regex allow-list.
var template = Rule(1, 0);
template.RuleId = "ANALYZE-SEC-006";
template.IsBuiltIn = true;
template.Conditions[0].Operator = "not_regex";
template.TemplateVariables = new List<TemplateVariable> { new() { Name = "allowList", ConditionIndex = 0, Field = "value" } };
var repo = new Mock<IRuleRepository>();
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { template });
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateFromTemplateAsync(TenantId, "ANALYZE-SEC-006",
new Dictionary<string, string> { ["allowList"] = new string('a', AnalyzeRuleLimits.MaxRegexLength + 1) }));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId), Times.Never);
}
// ===== fixtures =====
private static AnalyzeRule Rule(int conditions, int preconditions) => new()
{
RuleId = "CUSTOM-LIMITS",
IsBuiltIn = false, // the model defaults to a built-in rule
Title = "limits",
Severity = "warning",
Category = "test",
BaseConfidence = 80,
ConfidenceThreshold = 40,
Conditions = Enumerable.Range(0, conditions).Select(i => new RuleCondition
{
Signal = $"s{i}", Source = "event_data", EventType = "app_install_failed", DataField = "errorCode",
Operator = "equals", Value = "1", Required = true,
}).ToList(),
Preconditions = Enumerable.Range(0, preconditions).Select(_ => new RulePrecondition
{
Source = "event_data", EventType = "os_info", DataField = "edition", Operator = "equals", Value = "Enterprise",
}).ToList(),
};
private static void Place(AnalyzeRule rule, string field, string op, string value)
{
switch (field)
{
case "conditions[0].value":
rule.Conditions[0].Operator = op;
rule.Conditions[0].Value = value;
break;
case "conditions[0].filterValue":
rule.Conditions[0].FilterField = "f";
rule.Conditions[0].FilterOperator = op;
rule.Conditions[0].FilterValue = value;
break;
case "conditions[0].eventAFilterValue":
rule.Conditions[0].EventAFilterField = "f";
rule.Conditions[0].EventAFilterOperator = op;
rule.Conditions[0].EventAFilterValue = value;
break;
default:
rule.Preconditions[0].Operator = op;
rule.Preconditions[0].Value = value;
break;
}
}
}
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Caching.Memory;
@@ -72,7 +73,8 @@ public class AppHomingServiceTests
Mock.Of<System.Net.Http.IHttpClientFactory>(),
cache,
_registry,
_tenantConfigMock.Object)
_tenantConfigMock.Object,
Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
_detectorMock = new Mock<IGraphFeatureDetector>();
_maintenanceMock = new Mock<IMaintenanceRepository>();
@@ -287,6 +289,32 @@ public class AppHomingServiceTests
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_succeeds_when_the_budget_cuts_the_retries_of_a_missing_legacy_service_principal()
{
// The access check runs on 4 s and the first retry waits 5 s, so the answer arrives as
// transient. The Entra code still says the SP is gone: nothing left to lose.
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
SetupLegacyToken(GraphTokenResult.TransientFailure(
new EntraTokenRejection(401, EntraTokenRejection.ServicePrincipalMissingCode, "no service principal")));
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_stays_transient_when_the_budget_cuts_the_retries_of_any_other_legacy_rejection()
{
// Any other refusal proves nothing about the roles the tenant would lose with the flip.
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
SetupLegacyToken(GraphTokenResult.TransientFailure(
new EntraTokenRejection(400, "AADSTS53003", "blocked by Conditional Access")));
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.False(probe.Succeeded);
Assert.True(probe.IsTransient);
}
[Fact]
public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails()
{
@@ -349,6 +377,43 @@ public class AppHomingServiceTests
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task AutoFlip_clears_the_graph_access_rejection_of_the_app_the_tenant_leaves()
{
_store.Row!.GraphAccessRejectedSince = DateTime.UtcNow.AddHours(-3);
_store.Row.GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode;
_store.Row.GraphAccessRejectionConfirmedAt = DateTime.UtcNow.AddHours(-2);
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
// The confirmed episode ends visibly: the ops trail never keeps an open rejection.
var closed = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
Assert.Contains("app-homing-flip", closed.Details);
}
[Fact]
public async Task AutoFlip_clears_the_addon_episodes_and_closes_only_the_confirmed_ones()
{
_store.Row!.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddHours(-3);
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow.AddHours(-2);
_store.Row.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-5);
await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
var resolved = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.AddOnPermissionResolved);
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, resolved.Details);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByAppHomingFlip, resolved.Details);
// An unconfirmed rejection was never shown, so it ends without a trace.
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
}
[Fact]
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
{
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A radar pass reads one tenant at a time so its memory follows the largest tenant, and it never
/// evaluates an empty list: re-arm would read that as "version drained" and delete open episodes.
/// </summary>
public class AppVersionRadarPassTests
{
private static List<AppInstallSummary> Rows(string tenantId, int count)
{
var rows = new List<AppInstallSummary>();
for (var i = 0; i < count; i++)
rows.Add(new AppInstallSummary { TenantId = tenantId });
return rows;
}
[Fact]
public async Task Each_tenant_is_finished_before_the_next_one_is_loaded()
{
var calls = new List<string>();
await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "a", "b" },
tenantId =>
{
calls.Add($"load {tenantId}");
return Task.FromResult(Rows(tenantId, 2));
},
(tenantId, summaries) =>
{
calls.Add($"evaluate {tenantId}");
Assert.All(summaries, s => Assert.Equal(tenantId, s.TenantId));
return Task.FromResult((0, 0, 0));
},
(_, ex) => throw ex);
Assert.Equal(new[] { "load a", "evaluate a", "load b", "evaluate b" }, calls);
}
[Fact]
public async Task A_tenant_without_rows_is_not_evaluated()
{
var evaluated = new List<string>();
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "empty", "full" },
tenantId => Task.FromResult(tenantId == "empty" ? new List<AppInstallSummary>() : Rows(tenantId, 3)),
(tenantId, _) =>
{
evaluated.Add(tenantId);
return Task.FromResult((0, 0, 0));
},
(_, ex) => throw ex);
Assert.Equal(new[] { "full" }, evaluated);
Assert.Equal(1, pass.Tenants);
Assert.Equal(3, pass.Rows);
}
[Fact]
public async Task A_failing_tenant_does_not_cost_the_others_their_pass()
{
var failed = new List<string>();
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "load-fails", "evaluate-fails", "ok" },
tenantId => tenantId == "load-fails"
? throw new InvalidOperationException("load")
: Task.FromResult(Rows(tenantId, 1)),
(tenantId, _) => tenantId == "evaluate-fails"
? throw new InvalidOperationException("evaluate")
: Task.FromResult((1, 2, 3)),
(tenantId, _) => failed.Add(tenantId));
Assert.Equal(new[] { "load-fails", "evaluate-fails" }, failed);
Assert.Equal((1, 2, 3), (pass.Fired, pass.Refreshed, pass.Rearmed));
}
[Fact]
public async Task The_totals_add_up_over_the_tenants()
{
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "a", "b" },
tenantId => Task.FromResult(Rows(tenantId, 2)),
(_, _) => Task.FromResult((1, 2, 3)),
(_, ex) => throw ex);
Assert.Equal((2, 4L, 2, 4, 6), pass);
}
}
@@ -35,7 +35,6 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<McpUserService> _mcpUserMock;
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<OpsEventService> _opsEventsMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
@@ -84,10 +83,6 @@ public class AuthFunctionSideEffectTests
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
{ CallBase = false };
_telegramMock = new Mock<TelegramNotificationService>(
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
{ CallBase = false };
_globalNotificationMock = new Mock<GlobalNotificationService>(
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
@@ -117,7 +112,6 @@ public class AuthFunctionSideEffectTests
tenantAdmins ?? _tenantAdminsMock.Object,
_metricsRepoMock.Object,
preview ?? _previewMock.Object,
_telegramMock.Object,
_globalNotificationMock.Object,
_mcpUserMock.Object,
_autoApproveEnqueuerMock.Object,
@@ -136,9 +130,6 @@ public class AuthFunctionSideEffectTests
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
// a brand-new tenant). Default: all fire-and-forget calls succeed.
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
_globalNotificationMock
.Setup(x => x.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
@@ -233,7 +224,7 @@ public class AuthFunctionSideEffectTests
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
@@ -261,8 +252,7 @@ public class AuthFunctionSideEffectTests
Assert.Null(written.DpaAcceptedBy);
Assert.Null(written.DpaAcceptedAt);
Assert.Equal("auth", source);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Tenant Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
@@ -350,7 +340,7 @@ public class AuthFunctionSideEffectTests
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
@@ -371,7 +361,7 @@ public class AuthFunctionSideEffectTests
Assert.Empty(_store.Writes);
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
@@ -389,7 +379,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// -------------------------------------------------------------------------
@@ -624,7 +614,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
@@ -650,16 +640,17 @@ public class AuthFunctionSideEffectTests
}
[Fact]
public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow()
public async Task HandleNewTenantDomain_BellFailure_DoesNotThrow()
{
var config = DefaultConfig();
config.DomainName = null!;
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new HttpRequestException("Telegram unreachable"));
_globalNotificationMock
.Setup(x => x.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.ThrowsAsync(new HttpRequestException("Notification store unreachable"));
// Should not throw — Telegram is fire-and-forget
// Should not throw — the bell is fire-and-forget
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
// The write still landed before the fire-and-forget
@@ -69,7 +69,7 @@ public class AutopilotDeviceValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -95,7 +95,7 @@ public class AutopilotDeviceValidatorTests
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.True(result.IsValid);
Assert.Equal("ap-1", result.AutopilotDeviceId);
Assert.Equal("ap-1", result.Registration!.DeviceId);
Assert.Equal(2, handler.Requests.Count);
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
tokens.Verify(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()), Times.Exactly(2));
@@ -168,7 +168,7 @@ public class AutopilotDeviceValidatorTests
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.True(result.IsValid);
Assert.Equal("ap-1", result.AutopilotDeviceId);
Assert.Equal("ap-1", result.Registration!.DeviceId);
Assert.Equal(2, handler.Requests.Count);
}
@@ -201,7 +201,7 @@ public class AutopilotDeviceValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
CancellationToken observed = default;
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
@@ -213,4 +213,64 @@ public class AutopilotDeviceValidatorTests
Assert.True(observed.CanBeCanceled);
}
// ── Registration fields the session keeps ────────────────────────────────
private const string Upn = "first.last@example.invalid";
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
Assert.Equal("ap-1", registration.DeviceId);
Assert.Equal("TEST", registration.GroupTag);
Assert.Equal("PO-7", registration.PurchaseOrderId);
Assert.Equal("LAB-01", registration.DisplayName);
Assert.True(registration.UserAssigned);
var kept = Newtonsoft.Json.JsonConvert.SerializeObject(registration);
Assert.DoesNotContain(Upn, kept);
Assert.DoesNotContain("First Last", kept);
Assert.DoesNotContain("AAAAA-BBBBB", kept);
}
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
Assert.Null(registration.GroupTag);
Assert.Null(registration.PurchaseOrderId);
Assert.Null(registration.DisplayName);
Assert.False(registration.UserAssigned);
}
[Fact]
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
[Fact]
public async Task CachedValidation_CarriesTheRegistration()
{
var (sut, handler, _) = BuildSut(Json(
"{\"value\":[{\"id\":\"ap-1\",\"serialNumber\":\"PF3XKQ7\",\"groupTag\":\"TEST\"}]}"));
await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
var cached = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.Single(handler.Requests);
Assert.Equal("TEST", cached.Registration!.GroupTag);
}
}
@@ -181,6 +181,49 @@ public class ConfigBackupHookTests
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_GraphAccessState_NoSnapshot()
{
// Token side effect: stamped and cleared without anyone changing the configuration.
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.GraphAccessRejectedSince = DateTime.UtcNow.AddMinutes(-20);
incoming.GraphAccessRejectedCode = "AADSTS7000229";
incoming.GraphAccessRejectionConfirmedAt = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
{
// Agent and token side effect, like the Graph access state.
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
incoming.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow;
incoming.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
incoming.CloudPcPermissionMissingConfirmedAt = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoOpSave_IdenticalContent_NoSnapshot()
{
@@ -5,9 +5,9 @@ using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
/// Cover for the ConnectionType predicate used on the search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Sessions that
/// predate the projection (null ConnectionType) must never match
/// a set filter; an unset filter matches everything.
/// </summary>
public class ConnectionTypeFilterTests
@@ -65,7 +65,7 @@ public class CorporateIdentifierValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -95,6 +95,7 @@ public class DerivedWriterLivenessTests
configService: null!,
channelDispatcher: null!,
GuardFor(row),
stopReporter: null!,
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
await handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
@@ -116,6 +117,7 @@ public class DerivedWriterLivenessTests
ruleService: null!, Mock.Of<IRuleRepository>(), Mock.Of<ISessionRepository>(), Mock.Of<IMetricsRepository>(),
signalRNotification: null!, configService: null!, channelDispatcher: null!,
new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance),
stopReporter: null!,
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
@@ -12,8 +13,8 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path),
/// recipient validation, the Global-Admin gate, the rendered message, and the
/// send against a stubbed provider endpoint.
/// </summary>
public class EmailChannelProviderTests
@@ -30,15 +31,6 @@ public class EmailChannelProviderTests
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static Mock<IEmailChannelSender> EmailMock()
{
var mock = new Mock<IEmailChannelSender>();
@@ -50,6 +42,9 @@ public class EmailChannelProviderTests
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
private static NotificationChannel Webhook(WebhookProviderType provider, string url)
=> new() { Id = provider.ToString(), Name = provider.ToString(), ProviderType = (int)provider, Url = url, Enabled = true };
private static NotificationAlert Alert() => new()
{
EventType = "PushDeliveryFailed",
@@ -67,9 +62,8 @@ public class EmailChannelProviderTests
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
@@ -77,7 +71,6 @@ public class EmailChannelProviderTests
email.Verify(e => e.SendAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
@@ -86,7 +79,7 @@ public class EmailChannelProviderTests
var email = EmailMock();
email.Setup(e => e.SendAlertAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "Sent to 1 recipient(s)." });
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
@@ -99,7 +92,7 @@ public class EmailChannelProviderTests
[Fact]
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
{
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>());
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
@@ -108,6 +101,54 @@ public class EmailChannelProviderTests
Assert.Contains("not registered", result.Message);
}
[Fact]
public async Task Webhook_channel_reaches_the_webhook_path_and_never_the_email_sender()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[] { Webhook(WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, Platform);
webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
public async Task Mixed_list_each_channel_reaches_its_own_transport()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[]
{
Channel("ops@example.invalid"),
Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook"),
}, alert, Platform);
email.Verify(e => e.SendAlertAsync("ops@example.invalid", alert), Times.Once);
webhook.Verify(w => w.SendAsync("https://sales.example/hook", WebhookProviderType.GenericJson, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Channels_without_a_destination_are_skipped_on_every_transport()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
await dispatcher.SendToChannelsAsync(new[] { Channel(null), Webhook(WebhookProviderType.Slack, "") }, Alert(), Platform);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
// ── Validation and gate ───────────────────────────────────────────────
[Theory]
@@ -138,6 +179,10 @@ public class EmailChannelProviderTests
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
// The branch is per provider: a recipient list on a webhook channel still meets the URL gate.
var onWebhook = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.Slack, "ops@example.invalid") });
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(onWebhook));
}
[Fact]
@@ -146,14 +191,45 @@ public class EmailChannelProviderTests
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: true));
var refused = TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false);
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, candidate, isGlobalAdmin: false));
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(retargeted, candidate, isGlobalAdmin: false));
}
[Fact]
public void Gate_refuses_a_tenant_admin_who_flips_the_enabled_state()
{
var existing = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: true) });
var disabled = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: false) });
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(disabled, existing, isGlobalAdmin: false));
}
[Fact]
public void Gate_ignores_webhook_channels()
{
var candidate = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook") });
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false));
}
[Fact]
public void ValidateModel_enforces_the_gate_for_every_client_write()
{
var candidate = new TenantConfiguration
{
TenantId = "t1",
NotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }),
};
var existing = new TenantConfiguration { TenantId = "t1" };
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
}
// ── Rendering ─────────────────────────────────────────────────────────
@@ -0,0 +1,78 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Reads the AADSTS code out of Entra's token error body. The body below has the shape Entra
/// really sends for a tenant whose enterprise application was removed; the ids are synthetic.
/// </summary>
public class EntraTokenRejectionTests
{
private const string MissingServicePrincipalBody =
"{\"error\":\"invalid_client\"," +
"\"error_description\":\"AADSTS7000229: The client application aaaaaaaa-0000-0000-0000-000000000001 is missing service principal in the tenant 11111111-1111-1111-1111-111111111111. See instructions here: https://go.microsoft.com/fwlink/?linkid=2225119\\r\\nTrace ID: 00000000-0000-0000-0000-000000000002\\r\\nCorrelation ID: 00000000-0000-0000-0000-000000000003\\r\\nTimestamp: 2026-11-01 08:00:00Z\"," +
"\"error_codes\":[7000229]," +
"\"timestamp\":\"2026-11-01 08:00:00Z\"," +
"\"trace_id\":\"00000000-0000-0000-0000-000000000002\"," +
"\"correlation_id\":\"00000000-0000-0000-0000-000000000003\"," +
"\"error_uri\":\"https://login.microsoftonline.com/error?code=7000229\"}";
[Fact]
public void Parse_reads_the_code_and_keeps_only_the_first_line_of_the_description()
{
var rejection = EntraTokenRejection.Parse(401, MissingServicePrincipalBody);
Assert.Equal(401, rejection.StatusCode);
Assert.Equal("AADSTS7000229", rejection.ErrorCode);
Assert.True(rejection.IsServicePrincipalMissing);
Assert.False(rejection.IsOurCredential);
Assert.StartsWith("AADSTS7000229: The client application", rejection.Description);
Assert.DoesNotContain("Trace ID", rejection.Description);
}
[Fact]
public void Parse_falls_back_to_the_description_without_error_codes()
{
var rejection = EntraTokenRejection.Parse(400, "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: Access has been blocked by Conditional Access policies.\"}");
Assert.Equal("AADSTS53003", rejection.ErrorCode);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("<html>Bad Request</html>")]
[InlineData("[1,2]")]
[InlineData("{\"error\":\"invalid_request\"}")]
public void Parse_keeps_only_the_status_when_the_body_carries_no_code(string? body)
{
var rejection = EntraTokenRejection.Parse(400, body);
Assert.Equal(400, rejection.StatusCode);
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
Assert.False(rejection.IsServicePrincipalMissing);
Assert.False(rejection.IsOurCredential);
}
[Fact]
public void Parse_cuts_a_long_description()
{
var body = "{\"error_description\":\"AADSTS50000: " + new string('x', 1000) + "\"}";
Assert.Equal(300, EntraTokenRejection.Parse(400, body).Description!.Length);
}
[Theory]
[InlineData("AADSTS7000215", true)]
[InlineData("AADSTS7000222", true)]
[InlineData("AADSTS7000218", true)]
[InlineData("aadsts7000222", true)]
[InlineData("AADSTS7000229", false)]
[InlineData("AADSTS7000112", false)]
[InlineData("AADSTS53003", false)]
public void IsOurCredential_only_blames_our_own_secret(string code, bool expected)
{
Assert.Equal(expected, new EntraTokenRejection(401, code, null).IsOurCredential);
}
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Feedback;
@@ -19,8 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
/// alert carries neither the comment nor the sender.
/// raises <c>FeedbackRatingReceived</c> and the Global Admin bell, and that the alert carries neither
/// the comment nor the sender.
/// </summary>
public sealed class FeedbackFunctionSubmitTests
{
@@ -34,7 +33,7 @@ public sealed class FeedbackFunctionSubmitTests
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
[Fact]
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
public async Task Rating_IsStored_RaisesTheEvent_AndRingsTheBell()
{
var h = new Harness();
@@ -64,13 +63,12 @@ public sealed class FeedbackFunctionSubmitTests
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
}
[Fact]
public async Task TheAlert_CarriesNeitherCommentNorSender()
{
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
// Channels bound to FeedbackRatingReceived (Slack, webhooks, e-mail) receive message
// and details; the comment is read in the portal. Personal data must never ride along.
var h = new Harness();
@@ -160,7 +158,6 @@ public sealed class FeedbackFunctionSubmitTests
Assert.Empty(h.OpsEvents);
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
}
[Fact]
@@ -183,7 +180,6 @@ public sealed class FeedbackFunctionSubmitTests
public FakeFeedbackRepository Repo { get; }
public List<OpsEventEntry> OpsEvents { get; } = new();
public Mock<GlobalNotificationService> Bell { get; }
public Mock<TelegramNotificationService> Telegram { get; }
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
{
@@ -203,9 +199,6 @@ public sealed class FeedbackFunctionSubmitTests
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
Telegram = new Mock<TelegramNotificationService>(
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
{ CallBase = false };
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
@@ -216,7 +209,7 @@ public sealed class FeedbackFunctionSubmitTests
Sut = new FeedbackFunction(
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
Mock.Of<ISessionRepository>(), Repo,
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
}
@@ -228,8 +221,6 @@ public sealed class FeedbackFunctionSubmitTests
{
Bell.Verify(b => b.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
Telegram.Verify(t => t.SendFeedbackAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
}
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
@@ -23,7 +23,8 @@ public class FleetHealthPayloadTests
string manufacturer = "Contoso",
string model = "Laptop",
string? failureReason = null,
DateTime? startedAt = null)
DateTime? startedAt = null,
string? groupTag = null)
=> new()
{
TenantId = Guid.NewGuid().ToString(),
@@ -34,8 +35,106 @@ public class FleetHealthPayloadTests
Model = model,
FailureReason = failureReason!,
StartedAt = startedAt ?? DateTime.UtcNow,
AutopilotGroupTag = groupTag,
};
private static FleetGroupTagFilter Filter(string? excludeGroupTags, bool excludeUntagged = false)
{
Assert.True(FleetGroupTagFilter.TryParse(excludeGroupTags, excludeUntagged ? "true" : null, out var filter, out _));
return filter;
}
// ── Group tag breakdown and view filter ──────────────────────────────────
[Fact]
public void GroupTagHealth_IsEmpty_WhenNoSessionCarriesATag()
{
var payload = MetricsMath.BuildFleetHealthPayload(
new List<SessionSummary> { S(SessionStatus.Succeeded), S(SessionStatus.Failed) }, days: 7);
Assert.Empty(payload.GroupTagHealth);
}
[Fact]
public void GroupTagHealth_GroupsWithoutCase_InTheMostFrequentSpelling_WithANoTagBucket()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, groupTag: "TEST"),
S(SessionStatus.Failed, groupTag: "TEST"),
S(SessionStatus.Failed, groupTag: " test "),
S(SessionStatus.Succeeded, groupTag: "PROD"),
S(SessionStatus.InProgress),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
Assert.Equal(new[] { "TEST", "No group tag", "PROD" }.OrderBy(l => l), payload.GroupTagHealth.Select(g => g.Label).OrderBy(l => l));
var test = payload.GroupTagHealth.Single(g => g.GroupTag == "TEST");
Assert.Equal((3, 1, 2), (test.Total, test.Succeeded, test.Failed));
Assert.Equal(string.Empty, payload.GroupTagHealth.Single(g => g.Label == "No group tag").GroupTag);
Assert.Equal("TEST", payload.GroupTagHealth[0].GroupTag); // by volume
}
[Fact]
public void Filter_TakesExcludedTagsOutOfEveryFigure_ButKeepsThemFlaggedInTheBreakdown()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Failed, groupTag: "TEST", model: "Lab VM", failureReason: "lab"),
S(SessionStatus.Failed, groupTag: "test", model: "Lab VM", failureReason: "lab"),
S(SessionStatus.Succeeded, groupTag: "PROD"),
S(SessionStatus.Succeeded),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter("Test"));
Assert.Equal(2, payload.ExcludedSessions);
Assert.Equal(new[] { "Test" }, payload.ExcludedGroupTags);
Assert.False(payload.ExcludeUntagged);
Assert.Equal(2, payload.Stats.Total);
Assert.Equal(100d, payload.Stats.SuccessRate);
Assert.Empty(payload.FailureReasons);
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Lab VM");
Assert.Equal(0, payload.DailyData.Sum(d => d.Failed));
var excluded = Assert.Single(payload.GroupTagHealth, g => g.Excluded);
Assert.Equal(("TEST", 2), (excluded.GroupTag, excluded.Total));
}
[Fact]
public void Filter_ExcludeUntagged_TakesTheSessionsWithoutATagOut()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Failed),
S(SessionStatus.Succeeded, groupTag: "PROD"),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter(null, excludeUntagged: true));
Assert.Equal(1, payload.ExcludedSessions);
Assert.True(payload.ExcludeUntagged);
Assert.Equal(100d, payload.Stats.SuccessRate);
Assert.True(payload.GroupTagHealth.Single(g => g.GroupTag.Length == 0).Excluded);
}
[Fact]
public void TryParse_TrimsDeduplicatesAndCapsTheList()
{
Assert.True(FleetGroupTagFilter.TryParse(" TEST , test,,LAB ", null, out var filter, out _));
Assert.Equal(new[] { "TEST", "LAB" }, filter.ExcludedTags);
Assert.True(filter.IsActive);
Assert.True(FleetGroupTagFilter.TryParse(null, null, out var none, out _));
Assert.False(none.IsActive);
var tooMany = string.Join(",", Enumerable.Range(0, FleetGroupTagFilter.MaxExcludedTags + 1).Select(i => $"T{i}"));
Assert.False(FleetGroupTagFilter.TryParse(tooMany, null, out _, out var error));
Assert.Contains("at most", error);
Assert.False(FleetGroupTagFilter.TryParse(new string('x', 300), null, out _, out _));
}
[Fact]
public void EmptyInput_YieldsZeroedStats_AndOneDailyPointPerDay()
{
@@ -1,5 +1,6 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
@@ -34,10 +35,13 @@ public class GetTenantFeatureFlagsPayloadTests
// — and that forces a deliberate code review of whether the new field is non-sensitive.
Assert.Equal(new[]
{
// Non-sensitive by review: booleans only, the timestamps stay in the admin-gated config.
"addOnPermissionMissingBlocksAll",
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
// self-service migration banner).
"appHomingFunnelActive",
"bootstrapTokenEnabled",
"cloudPcPermissionMissing",
// Non-sensitive by review: bool only ("is a company name set"), never the value
// itself (admin-gated full config only).
"companyNameSet",
@@ -55,6 +59,10 @@ public class GetTenantFeatureFlagsPayloadTests
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
"entitlements",
// Non-sensitive by review: booleans only; the Entra code stays in the admin-gated config.
"graphAccessAppMissing",
"graphAccessRejected",
"intuneDeviceBindingPermissionMissing",
"isTrial",
"showScriptOutput",
"trialAvailable",
@@ -65,6 +73,122 @@ public class GetTenantFeatureFlagsPayloadTests
}, fieldNames);
}
[Fact]
public void Payload_GraphAccess_SuspicionAloneShowsNothing()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
GraphAccessRejectedSince = Now.AddMinutes(-5),
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
});
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
}
[Theory]
[InlineData(EntraTokenRejection.ServicePrincipalMissingCode, true)]
[InlineData("AADSTS53003", false)]
[InlineData(null, false)]
public void Payload_GraphAccess_ConfirmedShowsTheNotice_AndNamesOnlyTheMissingApp(string? code, bool appMissing)
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
GraphAccessRejectedSince = Now.AddHours(-1),
GraphAccessRejectedCode = code,
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
});
Assert.True(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.Equal(appMissing, element.GetProperty("graphAccessAppMissing").GetBoolean());
Assert.DoesNotContain("AADSTS", element.GetRawText());
}
[Fact]
public void Payload_GraphAccess_StaysSilentWithoutDeviceValidation()
{
// The switch being off has its own banner; the agent does not depend on Graph then.
var element = Serialize(new TenantConfiguration
{
GraphAccessRejectedSince = Now.AddHours(-1),
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
});
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
}
[Fact]
public void Payload_AddOn_SuspicionAloneShowsNothing()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
ValidateIntuneDeviceBinding = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddMinutes(-5),
});
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_AddOn_ConfirmedShowsTheNotice_PerValidation()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
ValidateIntuneDeviceBinding = true,
ValidateCloudPcDevice = true,
CloudPcPermissionMissingSince = Now.AddHours(-1),
CloudPcPermissionMissingConfirmedAt = Now.AddMinutes(-45),
});
Assert.True(element.GetProperty("cloudPcPermissionMissing").GetBoolean());
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
// Autopilot still admits its devices.
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_AddOn_SwitchingTheValidationOffHidesTheNoticeAtOnce()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
});
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
}
[Theory]
// Only the binding is on: no device can be validated.
[InlineData(false, false, true)]
// The Cloud PC check is on and still works for Cloud PCs.
[InlineData(true, false, false)]
// Both are on and both are confirmed missing.
[InlineData(true, true, true)]
public void Payload_AddOn_BlocksAllOnlyWhenNoOtherValidationAdmits(bool cloudPcOn, bool cloudPcConfirmed, bool blocksAll)
{
var element = Serialize(new TenantConfiguration
{
ValidateIntuneDeviceBinding = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
ValidateCloudPcDevice = cloudPcOn,
CloudPcPermissionMissingSince = cloudPcConfirmed ? Now.AddHours(-1) : null,
CloudPcPermissionMissingConfirmedAt = cloudPcConfirmed ? Now.AddMinutes(-45) : null,
});
Assert.True(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
Assert.Equal(blocksAll, element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_PassesThroughExplicitFieldValues()
{
@@ -0,0 +1,460 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The two stages of a lost Graph access: one refusal only stamps a suspicion, a refusal at least
/// 15 minutes later confirms it and alerts once, a freshly issued token ends the episode. Runs on
/// the real configuration service over a stored row, so the storage round trip of the three fields
/// and the decision on the fresh row are part of what is proven.
/// </summary>
public class GraphAccessHealthRecorderTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTimeOffset Start = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
private static readonly EntraTokenRejection MissingApp = new(401, EntraTokenRejection.ServicePrincipalMissingCode, "missing");
private static readonly EntraTokenRejection ConditionalAccess = new(400, "AADSTS53003", "blocked");
private static readonly EntraTokenRejection ExpiredSecret = new(401, "AADSTS7000222", "expired");
private static readonly string[] CoreRoles = { GraphAppPermissions.DeviceManagementServiceConfigReadAll };
private static readonly string[] CoreAndBindingRoles =
{ GraphAppPermissions.DeviceManagementServiceConfigReadAll, GraphAppPermissions.DeviceManagementManagedDevicesReadAll };
private static readonly IReadOnlyList<AddOnValidation> Binding = new[] { AddOnValidation.IntuneDeviceBinding };
private readonly Mock<IConfigRepository> _repo = new();
private readonly StoredTenantConfig _store;
private readonly List<OpsEventEntry> _events = new();
private readonly TestTimeProvider _time = new(Start);
private readonly GraphAccessHealthRecorder _sut;
public GraphAccessHealthRecorderTests()
{
_store = _repo.BackWithRow(ValidatedRow());
_sut = NewInstance();
}
private static TenantConfiguration ValidatedRow()
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ValidateAutopilotDevice = true;
return row;
}
/// <summary>A backend instance of its own: its own config cache over the same stored row.</summary>
private GraphAccessHealthRecorder NewInstance()
{
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => _events.Add(e))
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
var opsEvents = new OpsEventService(
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
return new GraphAccessHealthRecorder(
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, _time);
}
private void Advance(TimeSpan by) => _time.SetUtcNow(_time.GetUtcNow() + by);
// ── Rejected: the two stages ────────────────────────────────────────────
[Fact]
public async Task First_rejection_only_stamps_the_suspicion()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Further_rejections_inside_the_window_write_nothing()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(TimeSpan.FromMinutes(14));
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Single(_store.Writes);
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Rejection_after_the_window_confirms_and_alerts_once()
{
await _sut.RecordRejectedAsync(TenantId, ConditionalAccess);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(2, _store.Writes.Count);
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row.GraphAccessRejectionConfirmedAt);
// The code of the latest refusal counts: it selects the text and the repair.
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAccessRejected, alert.EventType);
Assert.Equal(OpsEventSeverity.Error, alert.Severity);
Assert.Equal(TenantId, alert.TenantId);
Assert.Contains(EntraTokenRejection.ServicePrincipalMissingCode, alert.Details);
}
[Fact]
public async Task Across_instances_only_one_confirms_and_alerts()
{
var other = NewInstance();
// The other instance holds a view from before the suspicion.
await other.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await other.RecordRejectedAsync(TenantId, MissingApp);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Single(_events);
}
[Fact]
public async Task A_lost_write_alerts_nothing_and_the_next_rejection_decides_again()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
_store.RejectWrites = true;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
_store.RejectWrites = false;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Single(_events);
}
[Fact]
public async Task A_token_without_a_role_runs_through_the_same_two_stages()
{
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
Assert.Equal("NoApplicationRole", _store.Row!.GraphAccessRejectedCode);
Assert.Empty(_events);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_tenant_without_device_validation_stays_untouched()
{
_store.Row = TenantConfiguration.CreateDefault(TenantId);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task A_tenant_without_a_row_stays_without_one()
{
_store.Row = null;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
}
// ── Our own credentials ─────────────────────────────────────────────────
[Fact]
public async Task Our_expired_secret_never_touches_the_tenant_and_alerts_once_per_hour()
{
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Advance(TimeSpan.FromMinutes(59));
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Assert.Empty(_store.Writes);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAppCredentialRejected, alert.EventType);
Assert.Equal(OpsEventSeverity.Critical, alert.Severity);
Advance(TimeSpan.FromMinutes(2));
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Assert.Equal(2, _events.Count);
}
// ── Minted ──────────────────────────────────────────────────────────────
[Fact]
public async Task A_minted_token_writes_nothing_without_a_state()
{
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Empty(_store.Writes);
Assert.Empty(_events);
}
[Fact]
public async Task A_minted_token_clears_a_suspicion_silently()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Empty(_events);
}
[Fact]
public async Task A_minted_token_ends_a_confirmed_episode_with_one_recovery_event()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
_events.Clear();
await _sut.RecordMintedAsync(TenantId, CoreRoles);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
var recovery = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAccessRecovered, recovery.EventType);
Assert.Equal(OpsEventSeverity.Info, recovery.Severity);
}
[Fact]
public async Task After_a_recovery_a_new_episode_starts_at_the_first_stage()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(_time.GetUtcNow().UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
// ── Add-on validations: the same two stages per validation ─────────────
private void SwitchOn(bool binding = true, bool cloudPc = false, bool autopilot = true)
{
_store.Row!.ValidateAutopilotDevice = autopilot;
_store.Row.ValidateIntuneDeviceBinding = binding;
_store.Row.ValidateCloudPcDevice = cloudPc;
}
[Fact]
public async Task First_device_kept_out_only_stamps_the_addon_suspicion()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Equal(Start.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
Assert.Empty(_events);
}
[Fact]
public async Task A_device_kept_out_after_the_window_confirms_and_alerts_once()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(14));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(1));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Equal(2, _store.Writes.Count);
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, alert.EventType);
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, alert.Details);
Assert.Contains(GraphAppPermissions.DeviceManagementManagedDevicesReadAll, alert.Details);
Assert.Contains("\"blocksAll\":false", alert.Details);
}
[Fact]
public async Task Each_addon_runs_its_own_episode()
{
SwitchOn(binding: true, cloudPc: true);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(10));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc });
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding });
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.NotNull(_store.Row.CloudPcPermissionMissingSince);
Assert.Null(_store.Row.CloudPcPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_switched_off_validation_is_never_stamped()
{
SwitchOn(binding: false);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task The_only_validation_reports_that_it_blocks_all()
{
SwitchOn(binding: true, autopilot: false);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Contains("\"blocksAll\":true", Assert.Single(_events).Details);
Assert.True(AddOnValidation.BlocksAllValidation(_store.Row!));
}
[Fact]
public async Task A_fresh_token_with_the_permission_ends_a_suspicion_silently()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Empty(_events);
}
[Fact]
public async Task A_fresh_token_with_the_permission_ends_a_confirmed_episode_with_one_event()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
_events.Clear();
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Equal(OpsEventSeverity.Info, resolved.Severity);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
}
[Fact]
public async Task A_fresh_token_without_the_permission_confirms_only_after_the_window()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Single(_store.Writes);
Advance(TimeSpan.FromMinutes(10));
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_fresh_token_without_an_addon_episode_writes_nothing()
{
SwitchOn();
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task Switching_the_validation_off_ends_a_confirmed_episode_once()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
_events.Clear();
_store.Row!.ValidateIntuneDeviceBinding = false;
// The maintenance run reads fresh, like another instance.
var maintenance = NewInstance();
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
}
[Fact]
public async Task Reconciling_leaves_an_episode_alone_while_its_switch_is_on()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await NewInstance().ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Single(_store.Writes);
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
}
// ── Fail-soft: the agent route must not notice ──────────────────────────
[Fact]
public async Task A_storage_failure_never_reaches_the_caller()
{
_store.ThrowOnRead = new InvalidOperationException("storage down");
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await _sut.ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task A_failing_write_never_reaches_the_caller()
{
_store.ThrowOnWrite = new InvalidOperationException("storage down");
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
Assert.Empty(_events);
}
}
@@ -0,0 +1,310 @@
using System.Net;
using System.Net.Http;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Tests.GraphResolution;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The maintenance run asks Entra for every tenant with a suspicion, so the state does not depend on
/// device traffic. Runs the real token service and recorder; only Entra's answer is scripted.
/// </summary>
public class GraphAccessRecheckMaintenanceTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
private static readonly DateTimeOffset Now = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
private static readonly string TokenBody = TestGraphToken.Body(TestGraphToken.ValidationRole);
private readonly ScriptedEntra _entra = new();
private readonly Mock<IConfigRepository> _repo = new();
private readonly StoredTenantConfig _store;
private readonly List<OpsEventEntry> _events = new();
private readonly GraphTokenService _tokens;
private readonly GraphAccessRecheckMaintenance _sut;
public GraphAccessRecheckMaintenanceTests()
{
_store = _repo.BackWithRow(ValidatedRow());
_repo.Setup(r => r.GetAllTenantConfigurationsAsync())
.ReturnsAsync(() => _store.Row == null
? new List<TenantConfiguration>()
: new List<TenantConfiguration> { ConfigRepoMockExtensions.Clone(_store.Row) });
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => _events.Add(e))
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
var opsEvents = new OpsEventService(
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
var recorder = new GraphAccessHealthRecorder(
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, new TestTimeProvider(Now));
_tokens = NewTokenService(new SingleHandlerFactory(_entra), recorder, tenantConfig);
_sut = new GraphAccessRecheckMaintenance(_repo.Object, _tokens, recorder, NullLogger<GraphAccessRecheckMaintenance>.Instance);
}
private static GraphTokenService NewTokenService(
IHttpClientFactory factory, IGraphAccessHealthRecorder recorder, TenantConfigurationService tenantConfig)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
["EntraId:ClientSecret"] = "secret",
}).Build();
return new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance),
tenantConfig,
recorder);
}
private static TenantConfiguration ValidatedRow()
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ValidateAutopilotDevice = true;
return row;
}
private void Suspect(TimeSpan age, string code = EntraTokenRejection.ServicePrincipalMissingCode)
{
_store.Row!.GraphAccessRejectedSince = Now.UtcDateTime - age;
_store.Row.GraphAccessRejectedCode = code;
}
[Fact]
public async Task Confirms_an_old_suspicion_without_device_traffic()
{
// A real 401 goes into the retry delay, which the budget cuts: the refusal must survive it.
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.Unauthorized, MissingAppBody);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(Now.UtcDateTime, _store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_young_suspicion_stays_a_suspicion()
{
Suspect(TimeSpan.FromMinutes(10));
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
await _sut.RunAsync();
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Clears_the_notice_after_a_repair_made_directly_in_Entra()
{
Suspect(TimeSpan.FromHours(5));
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
_entra.Answer(HttpStatusCode.OK, TokenBody);
await _sut.RunAsync();
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRecovered, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_token_without_a_role_keeps_the_notice_up()
{
// A user's sign-in re-created the removed enterprise application without any permission.
Suspect(TimeSpan.FromHours(5));
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
Assert.NotNull(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task A_token_without_a_role_confirms_an_old_suspicion()
{
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal("NoApplicationRole", _store.Row.GraphAccessRejectedCode);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_cached_token_cannot_answer_the_question()
{
// The token this instance still holds stays valid after the enterprise app is gone.
_entra.Answer(HttpStatusCode.OK, TokenBody);
await _tokens.GetAccessTokenAsync(TenantId);
Assert.Equal(1, _entra.Posts);
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
await _sut.RunAsync();
Assert.Equal(2, _entra.Posts);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
}
[Fact]
public async Task No_answer_from_Entra_decides_nothing()
{
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.ServiceUnavailable, "{}");
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task A_tenant_without_a_suspicion_is_not_asked()
{
Assert.Equal(0, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
}
[Fact]
public async Task A_tenant_that_switched_device_validation_off_is_not_asked()
{
Suspect(TimeSpan.FromMinutes(20));
_store.Row!.ValidateAutopilotDevice = false;
Assert.Equal(0, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
}
[Fact]
public async Task A_failing_read_ends_the_run_quietly()
{
_repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
Assert.Equal(0, await _sut.RunAsync());
}
[Fact]
public async Task One_failing_tenant_does_not_stop_the_next()
{
// The stub throws on the unscripted first tenant, which the token service does not catch.
var repo = new Mock<IConfigRepository>();
var first = ValidatedRow();
first.GraphAccessRejectedSince = Now.UtcDateTime;
var second = ValidatedRow();
second.TenantId = OtherTenantId;
second.GraphAccessRejectedSince = Now.UtcDateTime;
repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration> { first, second });
var recorder = new Mock<IGraphAccessHealthRecorder>();
var handler = new StubHttpMessageHandler().When($"/{OtherTenantId}/oauth2", HttpStatusCode.OK, TokenBody);
var tenantConfig = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
var sut = new GraphAccessRecheckMaintenance(
repo.Object, NewTokenService(new StubHttpClientFactory(handler), recorder.Object, tenantConfig), recorder.Object,
NullLogger<GraphAccessRecheckMaintenance>.Instance);
Assert.Equal(2, await sut.RunAsync());
recorder.Verify(r => r.RecordMintedAsync(OtherTenantId, It.IsAny<IReadOnlyCollection<string>>()), Times.Once);
}
// ── Add-on episodes ─────────────────────────────────────────────────────
private void AddOnSuspect(TimeSpan age, TimeSpan? confirmedAgo = null, bool switchedOn = true)
{
_store.Row!.ValidateIntuneDeviceBinding = switchedOn;
_store.Row.IntuneDeviceBindingPermissionMissingSince = Now.UtcDateTime - age;
if (confirmedAgo is { } ago)
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = Now.UtcDateTime - ago;
}
[Fact]
public async Task Confirms_an_old_addon_suspicion_with_a_token_that_lacks_the_permission()
{
AddOnSuspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.OK, TokenBody);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(Now.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task Ends_an_addon_episode_once_the_grant_arrives()
{
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4));
_entra.Answer(HttpStatusCode.OK,
TestGraphToken.Body(TestGraphToken.ValidationRole, GraphAppPermissions.DeviceManagementManagedDevicesReadAll));
await _sut.RunAsync();
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
}
[Fact]
public async Task A_switched_off_addon_ends_without_asking_Entra()
{
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4), switchedOn: false);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
var resolved = Assert.Single(_events);
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
}
/// <summary>Entra's token endpoint with an answer that can change between calls.</summary>
private sealed class ScriptedEntra : HttpMessageHandler
{
private HttpStatusCode _status = HttpStatusCode.OK;
private string _body = "{}";
public int Posts { get; private set; }
public void Answer(HttpStatusCode status, string body)
{
_status = status;
_body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Posts++;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
}
@@ -32,7 +32,7 @@ public class GraphAuthFailureTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
return new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
}
@@ -342,7 +342,8 @@ public class GraphFeatureDetectorTests
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
Moq.Mock.Of<IGraphAccessHealthRecorder>());
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
@@ -497,7 +498,8 @@ public class GraphFeatureDetectorTests
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
}
@@ -529,7 +531,8 @@ public class GraphFeatureDetectorTests
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
_firstToken = firstToken;
_laterToken = laterToken;
@@ -567,7 +570,8 @@ public class GraphFeatureDetectorTests
factory,
cache,
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
_onInvalidate = onInvalidate;
}
@@ -0,0 +1,21 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
/// <summary>What Entra's token endpoint answers on success, with the application roles of the tenant's service principal.</summary>
internal static class TestGraphToken
{
public const string ValidationRole = "DeviceManagementServiceConfig.Read.All";
public static string Body(params string[] roles)
{
var jwt = new JwtSecurityToken(
issuer: "https://sts.windows.net/test",
audience: "https://graph.microsoft.com",
claims: roles.Select(r => new Claim("roles", r)),
notBefore: null,
expires: DateTime.UtcNow.AddHours(1));
return $"{{\"access_token\":\"{new JwtSecurityTokenHandler().WriteToken(jwt)}\",\"expires_in\":3599}}";
}
}
@@ -43,16 +43,21 @@ public class GraphTokenServiceTests
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
/// lookup is never consulted for the decision — a null-returning repo is fine.
/// </summary>
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
private static GraphTokenService NewService(
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
accessHealth ?? Moq.Mock.Of<IGraphAccessHealthRecorder>());
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
@@ -240,7 +245,8 @@ public class GraphTokenServiceTests
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
Moq.Mock.Of<IGraphAccessHealthRecorder>());
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
@@ -260,6 +266,196 @@ public class GraphTokenServiceTests
Assert.Equal(3, TokenPosts(handler, TenantA));
}
// ── Entra's refusal ─────────────────────────────────────────────────────
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
private static GraphTokenService ServiceFor(
StubHttpMessageHandler handler,
Moq.Mock<IGraphAccessHealthRecorder> accessHealth,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
return NewService(config, new StubHttpClientFactory(handler), accessHealth.Object, logger);
}
[Fact]
public async Task Rejection_survives_a_budget_that_ends_the_retries()
{
// The validators run on 8 s and the recheck on 3 s; the chain's first retry waits 5 s.
var handler = new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.Unauthorized, MissingAppBody);
var (svc, _) = Create(handler);
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(500));
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
Assert.Null(result.AccessToken);
Assert.True(result.IsTransient);
Assert.NotNull(result.Rejection);
Assert.Equal(401, result.Rejection!.StatusCode);
Assert.True(result.Rejection.IsServicePrincipalMissing);
Assert.Equal(1, TokenPosts(handler, TenantA));
}
[Theory]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.TooManyRequests)]
[InlineData(HttpStatusCode.RequestTimeout)]
public async Task A_server_error_or_throttle_is_no_rejection(HttpStatusCode status)
{
var handler = new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", status, MissingAppBody);
var (svc, _) = Create(handler);
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
Assert.True(result.IsTransient);
Assert.Null(result.Rejection);
}
[Fact]
public async Task Agent_validation_reports_a_rejection()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.False(result.IsTransient);
health.Verify(h => h.RecordRejectedAsync(TenantA, Moq.It.Is<EntraTokenRejection>(r => r.ErrorCode == "AADSTS53003")), Moq.Times.Once);
}
[Fact]
public async Task The_portal_path_does_not_report_a_rejection()
{
// The consent flows see refusals as a matter of course while a consent propagates.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
await svc.GetAccessTokenAsync(TenantA);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task Only_a_fresh_token_counts_as_proof_of_access()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)), health);
var minted = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
var cached = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.True(minted.IsFreshlyMinted);
Assert.False(cached.IsFreshlyMinted);
// The roles travel along: they decide the add-on episodes.
health.Verify(h => h.RecordMintedAsync(TenantA, Moq.It.Is<IReadOnlyCollection<string>>(r => r.Contains(TestGraphToken.ValidationRole))), Moq.Times.Once);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task A_token_without_a_role_is_no_proof_of_access()
{
// A sign-in consent re-created the removed enterprise application without any permission.
// The notice must survive it: this is the moment an admin signs in to read it.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
await svc.GetAccessTokenAsync(TenantA);
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task Agent_validation_counts_a_token_without_a_role_as_a_refusal()
{
// Graph refuses every call made with it, so the agent is locked out all the same.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.NotNull(result.AccessToken);
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
}
[Fact]
public async Task Agent_validation_does_not_judge_a_token_from_the_cache()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
}
[Fact]
public async Task A_probe_under_explicit_credentials_reports_nothing()
{
// A token of the app the tenant is not homed on says nothing about the tenant's access.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
foreach (var (status, body) in new[]
{
(HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)),
(HttpStatusCode.OK, TestGraphToken.Body()),
(HttpStatusCode.BadRequest, ConditionalAccessBody),
})
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = NewService(config, new StubHttpClientFactory(new StubHttpMessageHandler().When($"/{TenantA}/oauth2", status, body)), health.Object);
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
health.VerifyNoOtherCalls();
}
}
[Fact]
public async Task Entra_refusal_is_logged_once_per_window()
{
var logger = new Moq.Mock<Microsoft.Extensions.Logging.ILogger<GraphTokenService>>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody),
new Moq.Mock<IGraphAccessHealthRecorder>(), logger.Object);
await svc.GetAccessTokenAsync(TenantA);
await svc.GetAccessTokenAsync(TenantA);
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.IsAny<Exception?>(),
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -0,0 +1,74 @@
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the group-tag search filter and for the device-snapshot path's in-memory twin of the
/// OData push-down: a filter must answer the same on both search paths.
/// </summary>
public class GroupTagSearchFilterTests
{
private static SessionSummary Session(string? groupTag = null) => new()
{
SessionId = "s", TenantId = "t", AutopilotGroupTag = groupTag,
Manufacturer = "Contoso", Model = "EliteBook 840", EnrollmentType = "v1",
DeviceName = "LAB-01", OsBuild = "26100.1000", Status = SessionStatus.Failed,
};
[Theory]
[InlineData("TEST", true)]
[InlineData("test", true)] // Intune does not fix the casing of a tag
[InlineData(" Test ", true)]
[InlineData("TEST-LAB", false)] // exact, not prefix
[InlineData(null, false)] // session without a recorded tag
public void GroupTagFilter_IsCaseInsensitiveAndExact(string? sessionTag, bool expected)
{
var filter = new SessionSearchFilter { GroupTag = "Test" };
Assert.Equal(expected, TableStorageService.MatchesGroupTag(Session(sessionTag), filter));
}
[Fact]
public void NoGroupTagFilter_MatchesEverySession()
{
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter()));
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter { GroupTag = " " }));
}
[Theory]
[InlineData("Manufacturer", "Contoso", true)]
[InlineData("Manufacturer", "contoso", false)] // OData eq is ordinal: the twin is too
[InlineData("Model", "EliteBook 840", true)]
[InlineData("Model", "EliteBook", false)]
[InlineData("EnrollmentType", "v2", false)]
[InlineData("DeviceName", "LAB", true)] // prefix, like the ge/lt range
[InlineData("DeviceName", "PC", false)]
[InlineData("OsBuild", "26100", true)]
[InlineData("OsBuild", "22631", false)]
[InlineData("Status", "Failed", true)]
[InlineData("Status", "Succeeded", false)]
public void DeviceSnapshotPath_AppliesThePushedDownColumns(string column, string value, bool expected)
{
var filter = column switch
{
"Manufacturer" => new SessionSearchFilter { Manufacturer = value },
"Model" => new SessionSearchFilter { Model = value },
"EnrollmentType" => new SessionSearchFilter { EnrollmentType = value },
"DeviceName" => new SessionSearchFilter { DeviceName = value },
"OsBuild" => new SessionSearchFilter { OsBuild = value },
_ => new SessionSearchFilter { Status = value },
};
Assert.Equal(expected, TableStorageService.MatchesPushedDownColumns(Session(), filter));
}
[Fact]
public void Fingerprint_BindsTheGroupTag()
{
// A continuation token minted for one group tag must not be replayed against another.
var a = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "TEST" });
var b = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "PROD" });
Assert.NotEqual(a, b);
}
}
@@ -0,0 +1,105 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The hardware whitelist matches tenant wildcards against device headers on every agent request
/// without building a Regex (D-411): same results as the former regex construction for every shape
/// an entry can take, linear work for the worst entry a tenant can write, and a device value longer
/// than any real hardware string only meets "*".
/// </summary>
public class HardwareWhitelistValidatorTests
{
/// <summary>The construction this replaced, kept as the oracle: '*' → '.*', '?' → '.', the rest escaped.</summary>
private static bool FormerRegexMatch(string text, string pattern)
{
if (pattern == "*")
return true;
var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(text, regex, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("Dell Inc.", "Dell*")]
[InlineData("dell inc.", "DELL*")]
[InlineData("Hewlett-Packard", "HP*")]
[InlineData("HP", "HP*")]
[InlineData("LENOVO", "Lenovo*")]
[InlineData("Microsoft Corporation", "Microsoft Corporation")]
[InlineData("Microsoft Corporation ", "Microsoft Corporation")]
[InlineData("Microsoft Corp", "Microsoft Corporation")]
[InlineData("ThinkPad T14 Gen 6", "*T14*")]
[InlineData("ThinkPad T14 Gen 6", "*Gen ?")]
[InlineData("ThinkPad T14 Gen 16", "*Gen ?")]
[InlineData("ThinkPad T14 Gen 6", "ThinkPad*Gen*")]
[InlineData("VMware20,1", "VMware??,?")]
[InlineData("VMware20,1", "VMware?,?")]
[InlineData("a.b", "a.b")]
[InlineData("axb", "a.b")]
[InlineData("f(x)", "f(x)")]
[InlineData("f(x)", "f(*)")]
[InlineData("a\\b", "a\\b")]
[InlineData("a\\xyzb", "a\\*b")]
[InlineData("a*b", "a\\*b")]
[InlineData("[abc]", "[abc]")]
[InlineData("a", "[abc]")]
[InlineData("x+y", "x+y")]
[InlineData("xxy", "x+y")]
[InlineData("$1.00", "$1.00")]
[InlineData("^start", "^start")]
[InlineData("abc", "**")]
[InlineData("abc", "*?*")]
[InlineData("ab", "???")]
[InlineData("abc", "???")]
[InlineData("abcc", "*c")]
[InlineData("ÄÖÜ GmbH", "äöü*")]
[InlineData("straße", "STRASSE")]
public void Matches_like_the_former_regex_construction(string value, string pattern)
{
Assert.Equal(FormerRegexMatch(value, pattern), HardwareWhitelistValidator.MatchesWildcard(value, pattern));
}
[Fact]
public void Case_folding_is_ordinal_the_one_deliberate_difference()
{
// The regex engine folded Turkish dotted 'İ' to 'i'; ordinal folding does not. SMBIOS strings are ASCII.
Assert.True(FormerRegexMatch("İstanbul", "istanbul*"));
Assert.False(HardwareWhitelistValidator.MatchesWildcard("İstanbul", "istanbul*"));
}
[Fact]
public void The_worst_whitelist_a_tenant_can_write_costs_milliseconds_per_request()
{
// 200 entries of 128 characters, each a chain of wildcards that never matches, against the
// longest value still compared (the cap) and a 32 KB header (past the cap).
var entry = string.Concat(Enumerable.Repeat("*a", 63)) + "*b";
var whitelist = Enumerable.Repeat(entry, 200).ToArray();
var atCap = new string('a', HardwareWhitelistValidator.MaxMatchedValueLength);
var huge = new string('a', 32 * 1024);
var clock = Stopwatch.StartNew();
var atCapResult = HardwareWhitelistValidator.ValidateHardware(atCap, "Model", whitelist, new[] { "*" });
var hugeResult = HardwareWhitelistValidator.ValidateHardware(huge, "Model", whitelist, new[] { "*" });
clock.Stop();
Assert.False(atCapResult.IsValid);
Assert.False(hugeResult.IsValid);
// The former construction spent up to 200 × 100 ms per list on this.
Assert.True(clock.ElapsedMilliseconds < 500, $"took {clock.ElapsedMilliseconds} ms");
}
[Fact]
public void A_value_longer_than_any_hardware_string_only_meets_the_star()
{
var longValue = "Dell" + new string('x', HardwareWhitelistValidator.MaxMatchedValueLength);
Assert.False(HardwareWhitelistValidator.MatchesWildcard(longValue, "Dell*"));
Assert.True(HardwareWhitelistValidator.MatchesWildcard(longValue, "*"));
var rejected = HardwareWhitelistValidator.ValidateHardware(longValue, "Model", new[] { "Dell*" }, new[] { "*" });
Assert.False(rejected.IsValid);
Assert.True(rejected.ErrorMessage!.Length < HardwareWhitelistValidator.MaxMatchedValueLength + 64, "the rejection message carries a shortened value");
}
}
@@ -79,7 +79,7 @@ public class IntuneEnrollmentValidationTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokens = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -290,7 +290,8 @@ public class IntuneEnrollmentValidationTests
// ------------------------------------------------------------------ SecurityValidator chain
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(
TenantConfiguration config, IGraphAccessHealthRecorder? accessHealth = null, CloudPcDeviceValidator? cloudPc = null)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
@@ -318,7 +319,9 @@ public class IntuneEnrollmentValidationTests
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
NullLogger.Instance,
intuneDeviceBindingValidator: binding.Object);
cloudPcDeviceValidator: cloudPc,
intuneDeviceBindingValidator: binding.Object,
accessHealth: accessHealth);
return (validator, binding);
}
@@ -407,6 +410,118 @@ public class IntuneEnrollmentValidationTests
Assert.Equal(retryAfter, result.RetryAfterSeconds);
}
// ------------------------------------------------------------------ missing add-on permission
private static Mock<CloudPcDeviceValidator> CloudPcAnswering(CloudPcValidationResult result)
{
var cloudPc = new Mock<CloudPcDeviceValidator>(
NullLogger<CloudPcDeviceValidator>.Instance, Mock.Of<IHttpClientFactory>(),
new MemoryCache(new MemoryCacheOptions()), null!)
{ CallBase = false };
cloudPc.Setup(c => c.ValidateCloudPcAsync(CertTenant, DeviceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
return cloudPc;
}
private static readonly CloudPcValidationResult CloudPcPermissionMissing =
new() { IsValid = false, PermissionMissing = true, ErrorMessage = "not permitted" };
private static Task<SecurityValidationResult> Validate(SecurityValidator validator, bool selfReportedCloudPc = false)
=> validator.ValidateRequestAsync(CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant,
selfReportedCloudPc: selfReportedCloudPc);
[Fact]
public async Task Chain_MissingBindingPermission_ReportsTheAddOn_AndKeepsTheResponse()
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (withRecorder, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
var (withoutRecorder, bindingWithout) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(bindingWithout, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
var reported = await Validate(withRecorder);
var silent = await Validate(withoutRecorder);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.IntuneDeviceBinding)), Times.Once);
Assert.Equal(HttpStatusCode.ServiceUnavailable, reported.StatusCode);
Assert.Equal(silent.StatusCode, reported.StatusCode);
Assert.Equal(silent.RetryAfterSeconds, reported.RetryAfterSeconds);
Assert.Equal(silent.Details, reported.Details);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.Match)]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.Transient)]
public async Task Chain_OtherBindingOutcomes_ReportNothing(IntuneDeviceBindingOutcome outcome)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "x" });
await Validate(validator);
recorder.VerifyNoOtherCalls();
}
[Theory]
[InlineData(true)]
[InlineData(false)] // a laptop not (yet) in Autopilot or a Hyper-V test VM: the grant would not admit it either
public async Task Chain_MissingCloudPcPermission_KeepsThe403_AndReportsOnlyASelfReportedCloudPc(bool selfReportedCloudPc)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, _) = BuildChain(new TenantConfiguration { ValidateCloudPcDevice = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
var result = await Validate(validator, selfReportedCloudPc);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Contains("not permitted", result.Details);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.CloudPc)),
selfReportedCloudPc ? Times.Once() : Times.Never());
recorder.VerifyNoOtherCalls();
}
[Fact]
public async Task Chain_MissingCloudPcPermission_ReportsNothing_WhenTheBindingAdmits()
{
// The device got in: the missing grant had no consequence.
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId });
var result = await Validate(validator);
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
recorder.VerifyNoOtherCalls();
}
[Theory]
[InlineData(true)]
[InlineData(false)] // a Hyper-V VM outside Autopilot: only the binding's grant would admit it
public async Task Chain_BothPermissionsMissing_ReportsInChainOrder_CloudPcOnlyWhenSelfReported(bool selfReportedCloudPc)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
AddOnValidation[] expected = selfReportedCloudPc
? [AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding]
: [AddOnValidation.IntuneDeviceBinding];
var result = await Validate(validator, selfReportedCloudPc);
// The binding's missing grant keeps the device waiting rather than rejected (D-282).
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.SequenceEqual(expected))), Times.Once);
}
[Fact]
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
{
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Maintenance;
using Xunit;
@@ -7,6 +8,18 @@ namespace AutopilotMonitor.Functions.Tests;
public class MaintenanceStepTimerTests
{
private const long Mb = 1024 * 1024;
private static MaintenanceStepTimer TimerOver(params (long PeakMb, long AllocatedMb)[] readings)
{
var queue = new Queue<(long PeakMb, long AllocatedMb)>(readings);
return new MaintenanceStepTimer(() =>
{
var (peakMb, allocatedMb) = queue.Dequeue();
return new MaintenanceMemoryReading(peakMb * Mb, allocatedMb * Mb, 1740 * Mb, 2088 * Mb);
});
}
[Fact]
public async Task Records_every_step_in_order_and_returns_the_body_result()
{
@@ -30,4 +43,49 @@ public class MaintenanceStepTimerTests
Assert.True(timer.Steps.ContainsKey("boom"));
}
// The memory columns have to name the step that set the peak: each step records the process
// peak after it ran and only the allocation of its own span.
[Fact]
public async Task Each_step_reports_the_process_peak_after_it_and_its_own_allocation()
{
var timer = TimerOver((400, 100), (400, 150), (400, 150), (1900, 1450), (1900, 1450));
await timer.RunAsync("light", () => Task.CompletedTask);
await timer.RunAsync<int>("heavy", () => Task.FromResult(7));
var report = timer.MemoryReport();
Assert.Equal(400, report.StepsPeakMb["light"]);
Assert.Equal(50, report.StepsAllocMb["light"]);
Assert.Equal(1900, report.StepsPeakMb["heavy"]);
Assert.Equal(1300, report.StepsAllocMb["heavy"]);
Assert.Equal(1740, report.GcHeapLimitMb);
Assert.Equal(2088, report.GcHighLoadMb);
}
[Fact]
public async Task A_throwing_step_still_reports_its_memory()
{
var timer = TimerOver((300, 10), (800, 60), (800, 60));
await Assert.ThrowsAsync<InvalidOperationException>(
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
var report = timer.MemoryReport();
Assert.Equal(800, report.StepsPeakMb["boom"]);
Assert.Equal(50, report.StepsAllocMb["boom"]);
}
[Fact]
public void A_real_reading_sees_the_process_peak_and_the_gc_limits()
{
GC.Collect();
var reading = MaintenanceMemoryReading.Capture();
Assert.True(reading.PeakWorkingSetBytes > 0);
Assert.True(reading.AllocatedBytes > 0);
Assert.True(reading.GcHeapLimitBytes > 0);
Assert.True(reading.GcHighLoadBytes > 0);
}
}
@@ -435,10 +435,10 @@ public class NotificationChannelHealthTests
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
.ReturnsAsync(webhookResult);
}
var telegram = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
return new NotificationChannelDispatcher(webhook.Object, telegram.Object, (push ?? new Mock<IPushChannelSender>()).Object,
Mock.Of<IEmailChannelSender>(), recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
var email = new Mock<IEmailChannelSender>();
email.Setup(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
return new NotificationChannelDispatcher(webhook.Object, (push ?? new Mock<IPushChannelSender>()).Object,
email.Object, recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
}
[Fact]
@@ -447,13 +447,13 @@ public class NotificationChannelHealthTests
var recorder = new Mock<INotificationChannelHealthRecorder>();
var push = new Mock<IPushChannelSender>();
var dispatcher = Dispatcher(recorder, push: push);
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"), It.Is<NotificationSendResult>(x => !x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(It.IsAny<NotificationScope>(), It.Is<NotificationChannel>(c => c.Id == "push"), It.IsAny<NotificationSendResult>(), It.IsAny<ChannelSendKind>()), Times.Never);
push.Verify(p => p.SendAsync(Tenant, It.IsAny<NotificationAlert>()), Times.Once);
}
@@ -474,13 +474,13 @@ public class NotificationChannelHealthTests
{
var recorder = new Mock<INotificationChannelHealthRecorder>();
var dispatcher = Dispatcher(recorder, webhookResult: () => throw new InvalidOperationException("boom"));
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"),
It.Is<NotificationSendResult>(x => !x.Success && x.Message == "Unexpected error: boom"), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
}
// ── Read endpoint projection ──────────────────────────────────────────
@@ -36,6 +36,7 @@ public class NotificationChannelsTests
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
ChannelJson("none-provider", providerType: 0),
ChannelJson("retired-teams-connector", providerType: 1),
ChannelJson("retired-provider", providerType: 40),
ChannelJson("unknown-provider", providerType: 99)) + "]";
var parsed = NotificationChannel.ParseList(json);
@@ -153,6 +154,20 @@ public class NotificationChannelsTests
Assert.Null(redacted[1].SigningSecret);
}
[Fact]
public void RedactedCopy_MasksTheRecipientsOfAnEmailChannel()
{
var cfg = new TenantConfiguration
{
NotificationChannelsJson = "[" + ChannelJson("mail", providerType: 60, url: "ops@example.invalid") + "]",
};
var redacted = NotificationChannel.ParseList(cfg.RedactedCopyForReader().NotificationChannelsJson);
Assert.Equal("mail", Assert.Single(redacted).Id);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url);
}
[Fact]
public void RedactedCopy_UnparseableChannelsJson_FallsBackToWholeStringRedaction()
{
@@ -218,6 +233,7 @@ public class NotificationChannelsTests
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
[InlineData("[{\"id\":\"a\",\"providerType\":40}]", "invalid provider type")] // retired, never reused
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
{
@@ -238,6 +254,7 @@ public class NotificationChannelsTests
[Theory]
[InlineData(1, false)] // retired Teams Office 365 Connector
[InlineData(40, false)] // retired, never reused
[InlineData(99, false)]
[InlineData(0, true)]
[InlineData(2, true)]
@@ -257,8 +274,6 @@ public class NotificationChannelsTests
}
[Theory]
[InlineData(40, false)] // Telegram
[InlineData(40, true)]
[InlineData(50, false)] // Push
[InlineData(50, true)]
[InlineData(60, false)] // E-mail
@@ -275,7 +290,6 @@ public class NotificationChannelsTests
}
[Theory]
[InlineData(40)]
[InlineData(50)]
[InlineData(60)]
public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType)
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers),
/// readable facts (card + e-mail formats) and verbatim under "data" (generic JSON consumers),
/// and only the channels whose rule opted in get it — on the platform scope, like every ops send.
/// Before this, an alert carried only category/event/severity and a tenant GUID.
/// </summary>
@@ -117,10 +117,10 @@ public class OpsAlertPayloadTests
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
// ── Telegram text carries the same information ────────────────────────
// ── E-mail text carries the same information ──────────────────────────
[Fact]
public void TelegramRendering_IncludesFlattenedFacts()
public void EmailRendering_IncludesFlattenedFacts()
{
var alert = new NotificationAlert
{
@@ -130,7 +130,7 @@ public class OpsAlertPayloadTests
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
};
var text = TelegramNotificationService.RenderAlertText(alert);
var text = EmailAlertRenderer.Text(alert);
Assert.Contains("Domain Name: contoso.example", text);
Assert.Contains("Pro trial started", text);
@@ -0,0 +1,86 @@
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;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The ops events behind a customer submission are the only signal an alert channel gets. They
/// name ids and the portal path, never the submitter or a comment; the submitter stays in the
/// row's user id, which the dispatch never forwards.
/// </summary>
public class OpsEventSubmissionSignalsTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Submitter = "someone@contoso.invalid";
private readonly List<OpsEventEntry> _events = new();
private readonly OpsEventService _sut;
public OpsEventSubmissionSignalsTests()
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(_events.Add)
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
_sut = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
}
private OpsEventEntry AssertSingleSignal(string eventType, string portalPath)
{
var entry = Assert.Single(_events);
Assert.Equal(eventType, entry.EventType);
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
Assert.Equal(TenantId, entry.TenantId);
Assert.Contains(portalPath, entry.Details!);
Assert.Equal(Submitter, entry.UserId);
Assert.DoesNotContain(Submitter, entry.Message);
Assert.DoesNotContain(Submitter, entry.Details!);
return entry;
}
[Fact]
public async Task TenantSignup_names_the_tenant_page()
{
await _sut.RecordTenantSignupAsync(TenantId, Submitter);
AssertSingleSignal(OpsEventTypes.TenantSignup, $"/admin/tenants/management?tenantId={TenantId}");
}
[Fact]
public async Task SessionReport_names_the_session_and_the_report_only()
{
await _sut.RecordSessionReportSubmittedAsync(TenantId, Submitter, "session-1", "report-1");
var entry = AssertSingleSignal(OpsEventTypes.SessionReportSubmitted, "/admin/reports/session-reports?reportId=report-1");
Assert.Contains("session-1", entry.Details!);
}
[Fact]
public async Task DiagFilesReport_names_the_report_only()
{
await _sut.RecordDiagFilesReportSubmittedAsync(TenantId, Submitter, "report-2");
AssertSingleSignal(OpsEventTypes.DiagFilesReportSubmitted, "/admin/reports/session-reports?reportId=report-2");
}
[Fact]
public async Task RuleSubmission_names_the_submission_and_the_rule_only()
{
await _sut.RecordRuleSubmissionReceivedAsync(TenantId, Submitter, "abc123def456", "analyze", "ANALYZE-X-1");
var entry = AssertSingleSignal(OpsEventTypes.RuleSubmissionReceived, "/admin/reports/rule-submissions?submissionId=abc123def456");
Assert.Contains("ANALYZE-X-1", entry.Details!);
}
}
@@ -12,28 +12,12 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
/// behavior survives the migration untouched, per-rule channel routing, the platform dispatch
/// scope (an ops alert's tenant is its subject, never the channel owner), and redaction of the
/// new secret-bearing field.
/// Platform (ops) notification channels: the stored list, per-rule channel routing, the platform
/// dispatch scope (an ops alert's tenant is its subject, never the channel owner), and redaction of
/// the secret-bearing field.
/// </summary>
public class OpsNotificationChannelsTests
{
private static AdminConfiguration LegacyConfig(
bool telegramEnabled = true, string telegramChat = "-1003785642894",
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
bool slackEnabled = false, string slackUrl = "")
=> new()
{
UpdatedBy = "test",
OpsAlertTelegramEnabled = telegramEnabled,
OpsAlertTelegramChatId = telegramChat,
OpsAlertTeamsEnabled = teamsEnabled,
OpsAlertTeamsWebhookUrl = teamsUrl,
OpsAlertSlackEnabled = slackEnabled,
OpsAlertSlackWebhookUrl = slackUrl,
};
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
@@ -61,44 +45,20 @@ public class OpsNotificationChannelsTests
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
// ── Legacy synthesis ──────────────────────────────────────────────────
[Fact]
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
{
var channels = LegacyConfig().GetOpsNotificationChannels();
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
var telegram = channels[0];
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
Assert.Equal("-1003785642894", telegram.Url);
Assert.True(telegram.Enabled);
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
}
[Fact]
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
{
// A configured-but-disabled provider must stay disabled, or the migration would start
// sending to a destination the operator had deliberately switched off.
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
}
// ── Stored list ───────────────────────────────────────────────────────
[Fact]
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
[Fact]
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
public void GetOpsNotificationChannels_ReturnsTheStoredList()
{
var config = LegacyConfig();
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
var config = new AdminConfiguration
{
UpdatedBy = "t",
OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") }),
};
var channels = config.GetOpsNotificationChannels();
@@ -46,6 +46,25 @@ public class OrphanSweepOpsEventTests
Assert.Contains("\"aggregateMetrics\":90000", evt.Details);
}
[Fact]
public async Task MaintenanceCompleted_carries_the_step_memory_and_the_gc_limits()
{
var (service, saved) = Rig();
var memory = new MaintenanceMemoryReport(
new Dictionary<string, long> { ["appVersionRadar"] = 1900 },
new Dictionary<string, long> { ["appVersionRadar"] = 1300 },
GcHeapLimitMb: 1740,
GcHighLoadMb: 2088);
await service.RecordMaintenanceCompletedAsync(123456, "Timer", memory: memory);
var evt = Assert.Single(saved);
Assert.Contains("\"stepsPeakMb\":{\"appVersionRadar\":1900}", evt.Details);
Assert.Contains("\"stepsAllocMb\":{\"appVersionRadar\":1300}", evt.Details);
Assert.Contains("\"gcHeapLimitMb\":1740", evt.Details);
Assert.Contains("\"gcHighLoadMb\":2088", evt.Details);
}
[Fact]
public async Task OrphanEventsCleaned_is_a_Warning_with_per_table_totals_and_the_legacy_count()
{
@@ -0,0 +1,56 @@
using System;
using AutopilotMonitor.Functions.Telemetry;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The memory metric is a percentage of the limits the GC works against, so a reading keeps its
/// meaning when the instance size changes, and an alert can name one number for every size.
/// </summary>
public class ProcessMemoryPressureTests
{
[Fact]
public void Heap_is_committed_memory_against_the_heap_limit()
{
var snapshot = default(ProcessSnapshot) with { CommittedBytes = 1305, TotalAvailableMemoryBytes = 1740 };
Assert.Equal(75.0, ProcessMemoryPressure.HeapPercent(snapshot));
}
[Fact]
public void Load_is_memory_load_against_the_high_load_threshold()
{
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2088, HighMemoryLoadThresholdBytes = 2088 };
Assert.Equal(100.0, ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void A_value_past_the_limit_is_not_capped()
{
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2320, HighMemoryLoadThresholdBytes = 2088 };
Assert.Equal(111.1, ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void Before_the_first_gc_there_is_no_reading()
{
var snapshot = default(ProcessSnapshot) with { WorkingSetBytes = 500 };
Assert.Null(ProcessMemoryPressure.HeapPercent(snapshot));
Assert.Null(ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void A_captured_snapshot_after_a_gc_has_both_readings()
{
GC.Collect();
var snapshot = ProcessSnapshot.Capture();
Assert.True(ProcessMemoryPressure.HeapPercent(snapshot) > 0);
Assert.True(ProcessMemoryPressure.LoadPercent(snapshot) > 0);
}
}
@@ -20,6 +20,7 @@ public class ProcessStallClassifierTests
PendingWorkItems: pending,
CompletedWorkItems: completed,
WorkingSetBytes: 0, HeapSizeBytes: 0, CommittedBytes: 0,
TotalAvailableMemoryBytes: 0, MemoryLoadBytes: 0, HighMemoryLoadThresholdBytes: 0,
LastGcGeneration: 0, LastGcPause: TimeSpan.Zero, LastGcCompacted: false);
[Fact]
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
/// admins — only Telegram and E-mail stay behind the GA gate — and one Push channel per scope),
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
/// the raw-table deny-list and the dependency redaction.
/// </summary>
public class PushChannelRoutingTests
@@ -35,15 +35,6 @@ public class PushChannelRoutingTests
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static NotificationChannel PushChannel(string id = "push", bool enabled = true) =>
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled };
@@ -56,9 +47,8 @@ public class PushChannelRoutingTests
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var push = new Mock<IPushChannelSender>();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
@@ -66,7 +56,6 @@ public class PushChannelRoutingTests
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
@@ -75,7 +64,7 @@ public class PushChannelRoutingTests
// A pair stored before validation allowed only one: each would reach every paired device.
var webhook = WebhookMock();
var push = new Mock<IPushChannelSender>();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, TelegramMock().Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
var slack = new NotificationChannel { Id = "slack", Name = "slack", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x", Enabled = true };
@@ -94,7 +83,7 @@ public class PushChannelRoutingTests
var push = new Mock<IPushChannelSender>();
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, push.Object);
var result = await dispatcher.SendTestAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
@@ -109,7 +98,6 @@ public class PushChannelRoutingTests
Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination());
Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination());
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram));
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Email));
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack));
}
@@ -127,23 +115,23 @@ public class PushChannelRoutingTests
}
[Fact]
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram()
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_email()
{
var json = Json(PushChannel());
// Add, disable, rename and toggle: all the tenant admin's own.
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(json, existingJson: null, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
var renamed = PushChannel();
renamed.Name = "Phones";
renamed.NotifyOnFailure = true;
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(renamed), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(renamed), json, isGlobalAdmin: false));
// The same channel id turned into Telegram still needs a Global Administrator.
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
Assert.Equal("Telegram channels can only be configured by a Global Administrator.",
TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: true));
// The same channel id turned into E-mail still needs a Global Administrator.
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
Assert.Equal("E-mail channels can only be configured by a Global Administrator.",
TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: true));
}
[Fact]
@@ -5,9 +5,9 @@ using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the RebootCount range predicate used on every search path that does NOT push the
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Without it a <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
/// Cover for the RebootCount range predicate used on the search path that does NOT push the
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Without it a
/// <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
/// with RebootCount &lt; 5 (the medium finding). Min is inclusive (">= many reboots").
/// </summary>
public class RebootCountFilterBoundsTests
@@ -56,10 +56,11 @@ public class ReportUploadGateTests
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));
var window = ReportUploadGate.AlertWindow;
Assert.True(gate.TakeAlert(start, window));
Assert.False(gate.TakeAlert(start.AddMinutes(59), window));
Assert.True(gate.TakeAlert(start + window, window));
Assert.False(gate.TakeAlert(start + window, window));
}
[Fact]
@@ -255,7 +255,7 @@ public class ReportsWireParityTests
}
// =========================================================================
// GetDistressReportsFunction — { success, count, reports }
// GetDistressReportsFunction — { success, count, reports, truncated }
// =========================================================================
[Fact]
@@ -287,13 +287,15 @@ public class ReportsWireParityTests
{
success = true,
count = reports.Count,
reports
reports,
truncated = false
},
new DistressReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports
Reports = reports,
Truncated = false
});
}
@@ -0,0 +1,340 @@
using System.Diagnostics;
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The evaluation budget of tenant-authored rules (D-410): the first regex match timeout stops the
/// rule instead of costing one second per event, a spent run budget leaves the later rules
/// unevaluated, every scan without an operator call is checked too, a rule that did not finish keeps
/// its stored row, and the dry-run reports the stop as verdict "stopped".
/// </summary>
public class RuleEvaluationBudgetTests
{
private const string TenantId = "tenant-budget";
private const string SessionId = "session-budget";
/// <summary>Nested quantifier over input it cannot match: catastrophic backtracking, ends at the match timeout (2 s).</summary>
private const string CatastrophicPattern = @"^(\w+\s?)+$";
private static readonly string NonMatchingInput = new string('a', 60) + "!";
// ===== analysis run =====
[Fact]
public async Task Regex_timeout_stops_the_rule_after_one_match_and_the_next_rule_still_runs()
{
var events = Enumerable.Range(1, 200).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
var redos = CustomRule("TEST-REDOS", new RuleCondition
{
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
});
var next = CustomRule("TEST-NEXT", ExistsCondition("app_install_failed"));
var clock = Stopwatch.StartNew();
var outcome = await Harness(events, redos, next).Engine().AnalyzeSessionAsync(TenantId, SessionId);
clock.Stop();
// One timed-out match (2 s), not 200 of them.
Assert.True(clock.ElapsedMilliseconds < 5000, $"took {clock.ElapsedMilliseconds} ms");
var stop = Assert.Single(outcome.Stops);
Assert.Equal(new RuleEvaluationStop("TEST-REDOS", EvaluationStopReasons.RegexTimeout, IsCatalogRule: false, EndsRun: false), stop);
Assert.Empty(outcome.SkippedRuleIds);
Assert.Equal(new[] { "TEST-NEXT" }, outcome.Results.Select(r => r.RuleId));
}
[Fact]
public async Task Spent_budget_stops_the_run_keeps_every_unfinished_row_and_resolves_nothing()
{
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "app-1")) };
var first = CustomRule("TEST-A", new RuleCondition
{
Signal = "a", Source = "event_data", EventType = "app_install_failed",
DataField = "appId", Operator = "equals", Value = "app-1", Required = true,
});
var second = CustomRule("TEST-B", ExistsCondition("app_install_failed"));
var third = CustomRule("TEST-C", ExistsCondition("app_install_failed"));
var interimA = StoredRow("TEST-A", isInterim: true);
var interimB = StoredRow("TEST-B", isInterim: true);
var harness = Harness(events, first, second, third);
harness.ExistingResults.AddRange(new[] { interimA, interimB });
var engine = harness.Engine(TimeSpan.Zero);
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
var stop = Assert.Single(outcome.Stops);
Assert.Equal("TEST-A", stop.RuleId);
Assert.Equal(EvaluationStopReasons.TimeBudget, stop.Reason);
Assert.True(stop.EndsRun);
Assert.Equal(new[] { "TEST-B", "TEST-C" }, outcome.SkippedRuleIds);
Assert.Empty(outcome.Results);
Assert.Empty(outcome.ResolvedResults); // a stop is no evidence that a finding went away
Assert.Equal(new[] { interimA, interimB }, outcome.KeptResults);
Assert.Null(interimA.ResolvedAt);
Assert.True(interimA.IsInterim);
}
[Fact]
public async Task A_rule_that_spends_its_share_stops_alone_and_the_next_rule_still_runs()
{
// One expensive rule must not use up the run for every rule after it (a device can make
// catalog rules slow by flooding its own session): its share ends, the run goes on.
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "app-1")) };
var expensive = CustomRule("TEST-EXPENSIVE", new RuleCondition
{
Signal = "a", Source = "event_data", EventType = "app_install_failed",
DataField = "appId", Operator = "equals", Value = "app-1", Required = true,
});
var next = CustomRule("TEST-NEXT", ExistsCondition("app_install_failed"));
var outcome = await Harness(events, expensive, next).Engine(ruleBudget: TimeSpan.Zero)
.AnalyzeSessionAsync(TenantId, SessionId);
var stop = Assert.Single(outcome.Stops);
Assert.Equal(new RuleEvaluationStop("TEST-EXPENSIVE", EvaluationStopReasons.TimeBudget, IsCatalogRule: false, EndsRun: false), stop);
Assert.Empty(outcome.SkippedRuleIds);
Assert.Equal(new[] { "TEST-NEXT" }, outcome.Results.Select(r => r.RuleId));
}
/// <summary>
/// Each of these evaluators scans the session per matching event without calling an operator
/// (quadratic in the session size); without its own check a spent budget would not stop it, and
/// the rule would simply be evaluated.
/// </summary>
[Theory]
[InlineData("event_correlation pairing")]
[InlineData("suppressByEvent scan")]
[InlineData("app_install_duration start search")]
public async Task Scans_without_an_operator_call_check_the_budget_too(string scan)
{
var (condition, events) = scan switch
{
"event_correlation pairing" => (
new RuleCondition
{
Signal = "pair", Source = "event_correlation", EventType = "app_install_started",
CorrelateEventType = "app_install_failed", JoinField = "appId", Required = true,
},
new[] { Event("app_install_started", 1, ("appId", "x")), Event("app_install_failed", 2, ("appId", "x")) }),
"suppressByEvent scan" => (
new RuleCondition
{
Signal = "failed", Source = "event_type", EventType = "app_install_failed", Operator = "exists", Required = true,
SuppressByEvent = new SuppressByEventConfig { EventType = "app_install_completed", JoinField = "appId" },
},
new[] { Event("app_install_failed", 1, ("appId", "x")) }),
_ => (
new RuleCondition { Signal = "slow", Source = "app_install_duration", Operator = "gt", Value = "0", Required = true },
new[] { Event("app_install_completed", 1, ("appId", "x")) }),
};
var outcome = await Harness(events.ToList(), CustomRule("TEST-SCAN", condition)).Engine(TimeSpan.Zero)
.AnalyzeSessionAsync(TenantId, SessionId);
var stop = Assert.Single(outcome.Stops);
Assert.Equal(EvaluationStopReasons.TimeBudget, stop.Reason);
Assert.Empty(outcome.Results);
}
// ===== dry-run =====
[Fact]
public void Dry_run_regex_timeout_ends_with_verdict_stopped_and_a_note_naming_the_condition()
{
var rule = CustomRule("TEST-DRY",
ExistsCondition("app_install_failed"),
new RuleCondition
{
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
});
var events = Enumerable.Range(1, 50).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
var dry = Harness(events).Engine().DryRunEvaluate(rule, events, new RuleEvaluationBudget(RuleEvaluationBudget.DryRun));
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
Assert.Equal(EvaluationStopReasons.RegexTimeout, dry.StopReason);
Assert.StartsWith("conditions[1] ('slow'): a regex hit the 2 s match timeout", dry.StopNote);
// Only what finished before the stop; no half-evaluated evidence map.
Assert.Equal(new[] { "exists_app_install_failed" }, dry.Conditions.Select(c => c.Signal));
Assert.Null(dry.MatchedConditions);
Assert.Null(dry.FinalConfidence);
}
[Fact]
public void Dry_run_spent_budget_stops_at_the_first_operator()
{
var rule = CustomRule("TEST-DRY", ExistsCondition("app_install_failed"));
rule.Preconditions = new List<RulePrecondition>
{
new() { Source = "event_data", EventType = "os_info", DataField = "edition", Operator = "equals", Value = "Enterprise" },
};
var events = new List<EnrollmentEvent> { Event("os_info", 1, ("edition", "Enterprise")) };
var dry = Harness(events).Engine().DryRunEvaluate(rule, events, new RuleEvaluationBudget(TimeSpan.Zero));
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
Assert.Equal(EvaluationStopReasons.TimeBudget, dry.StopReason);
Assert.StartsWith("preconditions[0] (os_info): the dry-run used up its 0 s time budget on this session's 1 events", dry.StopNote);
Assert.Empty(dry.Preconditions);
Assert.Empty(dry.Conditions);
}
[Fact]
public async Task Dry_run_of_a_caller_who_went_away_stops_as_cancelled()
{
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "x")) };
var rule = CustomRule("TEST-DRY", new RuleCondition
{
Signal = "a", Source = "event_data", EventType = "app_install_failed", DataField = "appId", Operator = "equals", Value = "x", Required = true,
});
using var gone = new CancellationTokenSource();
gone.Cancel();
var dry = await Harness(events).Engine().DryRunRuleAsync(TenantId, SessionId, rule, gone.Token);
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
Assert.Equal(EvaluationStopReasons.Cancelled, dry.StopReason);
}
// ===== "Analyze now" =====
[Fact]
public async Task Reanalyze_with_a_full_gate_answers_429_and_raises_one_ops_event()
{
var opsEvents = new List<OpsEventEntry>();
var harness = Harness(new List<EnrollmentEvent>());
var gate = new ReanalyzeGate(1, TestNotifications.CapturingOpsEvents(opsEvents), NullLogger<ReanalyzeGate>.Instance);
using var busy = gate.TryEnter();
var function = harness.Function(gate, opsEvents);
var (req, _) = EndpointHarness.Request(TenantId, queryString: "?reanalyze=true");
var response = await function.Run(req, SessionId);
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(response));
Assert.True(response.Headers.TryGetValues("Retry-After", out var retryAfter));
Assert.Equal("5", retryAfter!.Single());
var alert = Assert.Single(opsEvents);
Assert.Equal(OpsEventTypes.RuleEvaluationLimitReached, alert.EventType);
Assert.Contains("re-analysis", alert.Details);
harness.Maintenance.Verify(m => m.DeleteSessionRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Reanalyze_writes_back_the_row_of_a_stopped_rule_and_names_it()
{
var events = Enumerable.Range(1, 20).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
var redos = CustomRule("TEST-REDOS", new RuleCondition
{
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
});
var earlierFinding = StoredRow("TEST-REDOS", isInterim: false);
var harness = Harness(events, redos);
harness.ExistingResults.Add(earlierFinding);
var opsEvents = new List<OpsEventEntry>();
var function = harness.Function(
new ReanalyzeGate(TestNotifications.CapturingOpsEvents(opsEvents), NullLogger<ReanalyzeGate>.Instance), opsEvents);
var (req, _) = EndpointHarness.Request(TenantId, queryString: "?reanalyze=true");
var response = await function.Run(req, SessionId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
harness.Maintenance.Verify(m => m.DeleteSessionRuleResultsAsync(TenantId, SessionId), Times.Once);
harness.Rules.Verify(r => r.StoreRuleResultAsync(earlierFinding), Times.Once);
response.Body.Position = 0;
using var body = JsonDocument.Parse(response.Body);
Assert.Equal(new[] { "TEST-REDOS" }, body.RootElement.GetProperty("stoppedRuleIds").EnumerateArray().Select(e => e.GetString()));
var stopped = Assert.Single(opsEvents);
Assert.Equal(OpsEventTypes.RuleEvaluationStopped, stopped.EventType);
Assert.Equal(OpsEventSeverity.Warning, stopped.Severity);
}
// ===== fixtures =====
private static RuleCondition ExistsCondition(string eventType) => new()
{
Signal = "exists_" + eventType, Source = "event_type", EventType = eventType, Operator = "exists", Required = true,
};
private static AnalyzeRule CustomRule(string ruleId, params RuleCondition[] conditions) => new()
{
RuleId = ruleId,
IsBuiltIn = false, // the model defaults to a built-in rule
Title = ruleId,
Severity = "warning",
Category = "test",
Enabled = true,
BaseConfidence = 80,
ConfidenceThreshold = 40,
Conditions = conditions.ToList(),
Explanation = "test",
};
private static RuleResult StoredRow(string ruleId, bool isInterim) => new()
{
RuleId = ruleId,
ResultId = "result-" + ruleId,
TenantId = TenantId,
SessionId = SessionId,
IsInterim = isInterim,
DetectedAt = DateTime.UtcNow.AddMinutes(-30),
};
private static EnrollmentEvent Event(string eventType, int sequence, params (string Key, object Value)[] data) => new()
{
EventId = $"evt-{sequence}",
TenantId = TenantId,
SessionId = SessionId,
EventType = eventType,
Sequence = sequence,
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
Data = data.ToDictionary(d => d.Key, d => d.Value),
};
private static RuleHarness Harness(List<EnrollmentEvent> events, params AnalyzeRule[] customRules) => new(events, customRules);
/// <summary>The tenant's custom rules behind a real AnalyzeRuleService, mocked storage.</summary>
private sealed class RuleHarness
{
public Mock<IRuleRepository> Rules { get; } = new();
public Mock<ISessionRepository> Sessions { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public List<RuleResult> ExistingResults { get; } = new();
public RuleHarness(List<EnrollmentEvent> events, AnalyzeRule[] customRules)
{
Rules.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
Rules.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(customRules.ToList());
Rules.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
Rules.Setup(r => r.GetRuleResultsAsync(TenantId, SessionId)).ReturnsAsync(() => ExistingResults.ToList());
Rules.Setup(r => r.StoreRuleResultAsync(It.IsAny<RuleResult>())).ReturnsAsync(true);
Sessions.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
}
private AnalyzeRuleService Service() => new(Rules.Object, NullLogger<AnalyzeRuleService>.Instance);
public RuleEngine Engine(TimeSpan? runBudget = null, TimeSpan? ruleBudget = null) =>
new(Service(), Rules.Object, Sessions.Object, NullLogger<RuleEvaluationBudgetTests>.Instance)
{
RunBudget = runBudget ?? RuleEvaluationBudget.AnalysisRun,
RuleBudget = ruleBudget ?? RuleEvaluationBudget.AnalysisRule,
};
public GetRuleResultsFunction Function(ReanalyzeGate gate, List<OpsEventEntry> opsEvents) =>
new(NullLogger<GetRuleResultsFunction>.Instance, Rules.Object, Maintenance.Object, Sessions.Object, Service(),
gate, new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(opsEvents)));
}
}
@@ -69,7 +69,7 @@ public class RuleEvidenceKeysTests
Required = true,
};
var dry = Engine().DryRunEvaluate(Rule(condition), Events());
var dry = Engine().DryRunEvaluate(Rule(condition), Events(), new RuleEvaluationBudget(RuleEvaluationBudget.DryRun));
var matched = Assert.IsAssignableFrom<IDictionary<string, object>>(dry.MatchedConditions);
Assert.True(matched.ContainsKey("sig"), $"{source}/{op} did not match the fixture");
@@ -344,6 +344,11 @@ public class RulePresentationTests
Assert.Equal(RulePresentationLimits.TemplateMaxLength, fact.GetProperty("value").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("key").GetProperty("maxLength").GetInt32());
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("name").GetProperty("maxLength").GetInt32());
// The rule-shape counts (D-410): the schema lets validate_rule refuse what the backend refuses.
var analyzeRule = defs.GetProperty("analyzeRule").GetProperty("properties");
Assert.Equal(AnalyzeRuleLimits.MaxConditions, analyzeRule.GetProperty("conditions").GetProperty("maxItems").GetInt32());
Assert.Equal(AnalyzeRuleLimits.MaxPreconditions, analyzeRule.GetProperty("preconditions").GetProperty("maxItems").GetInt32());
}
private static string[] Strings(JsonElement array) => array.EnumerateArray().Select(e => e.GetString()!).ToArray();
@@ -0,0 +1,147 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The per-instance caps on synchronous rule work (D-379, D-410) and the ops events behind them: a
/// caller past a gate's limit gets 429 with a retry hint, the first refusal of a window raises one
/// RuleEvaluationLimitReached naming the gate, and stopped rules reach the operators through a
/// reporter that a tenant's rule count cannot turn into an alarm storm.
/// </summary>
public class RuleWorkGateTests
{
private const string Upn = "admin@example.com";
private readonly List<OpsEventEntry> _opsEvents = new();
[Theory]
[InlineData("dry-run", 5)]
[InlineData("re-analysis", 5)]
[InlineData("pattern-test", 3)]
public async Task Caller_past_the_limit_gets_429_with_a_retry_hint_and_one_ops_event_per_window(string gateName, int retryAfter)
{
var gate = Gate(gateName, limit: 1);
using var busy = gate.TryEnter();
Assert.NotNull(busy);
Assert.Null(gate.TryEnter());
var (req, _) = EndpointHarness.Request("tenant-a", Upn);
var refused = await gate.RefuseAsync(req, "tenant-a", Upn);
await gate.RefuseAsync(req, "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 header));
Assert.Equal(retryAfter.ToString(), header!.Single());
// The situation only: the portal and the MCP phrase the retry from Retry-After.
Assert.EndsWith("running right now.", ErrorText(refused));
var alert = Assert.Single(_opsEvents);
Assert.Equal(OpsEventTypes.RuleEvaluationLimitReached, alert.EventType);
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
Assert.Equal("tenant-a", alert.TenantId);
using var details = JsonDocument.Parse(alert.Details!);
Assert.Equal(gateName, details.RootElement.GetProperty("gate").GetString());
}
[Fact]
public void Default_limits_never_refuse_a_person()
{
var ops = TestNotifications.CapturingOpsEvents(_opsEvents);
// One above the measured peak: 3 parallel dry-runs from one AI client on one instance.
Assert.Equal(4, new RuleDryRunGate(ops, NullLogger<RuleDryRunGate>.Instance).Limit);
Assert.Equal(5, new ReanalyzeGate(ops, NullLogger<ReanalyzeGate>.Instance).Limit);
}
// ===== RuleEvaluationStopReporter =====
[Fact]
public async Task A_stopped_custom_rule_is_a_warning_and_a_stopped_catalog_rule_an_error()
{
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
var outcome = new AnalysisOutcome();
outcome.Stops.Add(new RuleEvaluationStop("CUSTOM-1", EvaluationStopReasons.RegexTimeout, IsCatalogRule: false, EndsRun: false));
outcome.Stops.Add(new RuleEvaluationStop("ANALYZE-APP-002", EvaluationStopReasons.TimeBudget, IsCatalogRule: true, EndsRun: true));
outcome.SkippedRuleIds.AddRange(new[] { "CUSTOM-2", "CUSTOM-3" });
await reporter.ReportAsync("tenant-a", "session-1", AnalyzeRunKind.Terminal, outcome);
Assert.Equal(2, _opsEvents.Count);
Assert.All(_opsEvents, e => Assert.Equal(OpsEventTypes.RuleEvaluationStopped, e.EventType));
Assert.Equal(OpsEventSeverity.Warning, _opsEvents[0].Severity);
Assert.Equal(OpsEventSeverity.Error, _opsEvents[1].Severity);
using var budgetStop = JsonDocument.Parse(_opsEvents[1].Details!);
Assert.Equal(2, budgetStop.RootElement.GetProperty("skippedRules").GetInt32());
using var regexStop = JsonDocument.Parse(_opsEvents[0].Details!);
Assert.Equal(0, regexStop.RootElement.GetProperty("skippedRules").GetInt32());
}
[Fact]
public void One_event_per_tenant_and_rule_per_window()
{
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
Assert.True(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start));
Assert.False(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start.AddMinutes(59)));
Assert.True(reporter.Take("tenant-b", "CUSTOM-1", isCatalogRule: false, start.AddMinutes(1)));
Assert.True(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start + RuleEvaluationStopReporter.Window));
}
[Fact]
public void A_tenant_with_many_rules_cannot_flood_the_alarm_or_silence_others()
{
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
var noisyTenant = Enumerable.Range(0, 1000).Count(i => reporter.Take("tenant-noisy", $"CUSTOM-{i}", false, start.AddSeconds(i)));
Assert.Equal(RuleEvaluationStopReporter.MaxCustomEventsPerTenant, noisyTenant);
// Another tenant and the catalog keep their own room.
Assert.True(reporter.Take("tenant-quiet", "CUSTOM-1", false, start.AddMinutes(20)));
Assert.True(reporter.Take("tenant-noisy", "ANALYZE-SEC-005", isCatalogRule: true, start.AddMinutes(21)));
// A new window starts over.
Assert.True(reporter.Take("tenant-noisy", "CUSTOM-new", false, start + RuleEvaluationStopReporter.Window));
}
[Fact]
public void Custom_rule_stops_of_all_tenants_never_take_the_catalog_slots()
{
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
var custom = Enumerable.Range(0, 100).Count(t =>
Enumerable.Range(0, 10).Count(r => reporter.Take($"tenant-{t}", $"CUSTOM-{r}", false, start)) > 0);
var catalog = Enumerable.Range(0, 100).Count(r => reporter.Take("tenant-0", $"ANALYZE-X-{r:D3}", true, start));
Assert.Equal(RuleEvaluationStopReporter.MaxCustomEventsPerWindow / RuleEvaluationStopReporter.MaxCustomEventsPerTenant, custom);
Assert.Equal(RuleEvaluationStopReporter.MaxCatalogEventsPerWindow, catalog);
}
private RuleWorkGate Gate(string name, int limit)
{
var ops = TestNotifications.CapturingOpsEvents(_opsEvents);
return name switch
{
"dry-run" => new RuleDryRunGate(limit, ops, NullLogger<RuleDryRunGate>.Instance),
"re-analysis" => new ReanalyzeGate(limit, ops, NullLogger<ReanalyzeGate>.Instance),
_ => new TestLogPatternGate(limit, ops, NullLogger<TestLogPatternGate>.Instance),
};
}
private static string ErrorText(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
{
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return doc.RootElement.GetProperty("error").GetString()!;
}
}
@@ -140,6 +140,26 @@ public class RulesWireParityTests
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
}
[Fact]
public void DryRunAnalyzeRuleResponse_carries_a_stop_with_its_reason_and_note()
{
var sessionId = "7c1e2a90-1111-4b7c-a1d2-52f3bbbb0006";
var result = new RuleDryRun
{
Verdict = RuleDryRunVerdict.Stopped,
EventCount = 4200,
BaseConfidence = 50,
ConfidenceThreshold = 60,
// D-410: verdict "stopped" — the trace keeps only the entries evaluated before the stop.
StopReason = EvaluationStopReasons.RegexTimeout,
StopNote = "conditions[0] ('slow'): a regex hit the 2 s match timeout on this session's data",
};
AssertParity(
new { success = true, sessionId, result },
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
}
// ---- GetRuleResults ------------------------------------------------------------------
[Fact]
@@ -178,6 +198,8 @@ public class RulesWireParityTests
};
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
var persistFailureRuleIds = new List<string> { "ANALYZE-NET-001" };
// D-410: rules the reanalyze stopped at its evaluation budget, omitted when there are none.
var stoppedRuleIds = new List<string> { "CUSTOM-SLOW-001" };
AssertParity(
new
@@ -190,7 +212,8 @@ public class RulesWireParityTests
highCount = openResults.Count(r => r.Severity == "high"),
warningCount = openResults.Count(r => r.Severity == "warning"),
persistFailureCount = persistFailureRuleIds.Count,
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null,
stoppedRuleIds = stoppedRuleIds.Count > 0 ? stoppedRuleIds : null
},
new GetRuleResultsResponse
{
@@ -202,7 +225,8 @@ public class RulesWireParityTests
HighCount = openResults.Count(r => r.Severity == "high"),
WarningCount = openResults.Count(r => r.Severity == "warning"),
PersistFailureCount = persistFailureRuleIds.Count,
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null,
StoppedRuleIds = stoppedRuleIds.Count > 0 ? stoppedRuleIds : null
});
}
@@ -72,7 +72,7 @@ public class SessionDeletionWorkerTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
// PR-B audit consolidation: deletion_poisoned audit moved to SessionDeletionPoisoned
// OpsEvent (operator-bound, Telegram-routable). Tenant-audit row is no longer written.
// OpsEvent (operator-bound, routable through alert rules). Tenant-audit row is no longer written.
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
Assert.Equal(TenantId, poisoned.TenantId);
Assert.Equal(SessionId, poisoned.SessionId);
@@ -29,6 +29,7 @@ public class SessionFreeTextSearchTests
DeviceName = "DESKTOP-A1B2C3",
SerialNumber = "5CG1234XYZ",
Manufacturer = "Contoso",
AutopilotGroupTag = "LAB-WEST",
Model = "EliteBook 840",
Status = SessionStatus.Failed,
GeoCountry = "DE",
@@ -178,6 +179,7 @@ public class SessionFreeTextSearchTests
DeviceName = F("deviceName"),
SerialNumber = F("serialNumber"),
Manufacturer = F("manufacturer"),
AutopilotGroupTag = F("autopilotGroupTag"),
Model = F("model"),
Status = Enum.Parse<SessionStatus>(F("status")),
SessionId = F("sessionId"),
@@ -83,6 +83,11 @@ public class SessionIndexFieldManifestTests
["EspSoftFailure"] = true,
["CompletionSource"] = "esp_exit",
["ValidatedBy"] = "AutopilotV1",
["AutopilotDeviceId"] = "ap-1",
["AutopilotGroupTag"] = "TEST",
["AutopilotPurchaseOrderId"] = "PO-7",
["AutopilotDisplayName"] = "LAB-01",
["AutopilotUserAssigned"] = true,
["FailureSnapshotJson"] = "{\"x\":1}",
["AdminMarkedAction"] = "failed",
["VerdictPath"] = "sweep:r5_incomplete",
@@ -1013,7 +1013,7 @@ public class SlaTenantStatusLifecycleTests
.Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient());
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance), Mock.Of<IPushChannelSender>());
var channelDispatcher = new NotificationChannelDispatcher(webhook, Mock.Of<IPushChannelSender>());
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
@@ -60,7 +60,7 @@ public class StoreSessionReregistrationPreserveTests
DeviceName = "PC-1",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.NotNull(harness.Written);
@@ -95,7 +95,7 @@ public class StoreSessionReregistrationPreserveTests
DeviceName = "PC-1",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.NotNull(harness.Written);
@@ -134,7 +134,7 @@ public class StoreSessionReregistrationPreserveTests
IsSelfDeployingProfile = false, // fresh read failed / degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
@@ -166,7 +166,7 @@ public class StoreSessionReregistrationPreserveTests
IsSelfDeployingProfile = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
@@ -199,7 +199,7 @@ public class StoreSessionReregistrationPreserveTests
IsCloudPc = false, // fresh marker read degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
@@ -233,7 +233,7 @@ public class StoreSessionReregistrationPreserveTests
EnrollmentType = "v1", // degraded registry read on restart
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
@@ -265,7 +265,7 @@ public class StoreSessionReregistrationPreserveTests
EnrollmentType = "v2",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
@@ -295,7 +295,7 @@ public class StoreSessionReregistrationPreserveTests
IsCloudPc = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
@@ -327,7 +327,7 @@ public class StoreSessionReregistrationPreserveTests
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -358,7 +358,7 @@ public class StoreSessionReregistrationPreserveTests
var harness = new Harness(existing);
var boundAt = new DateTime(2026, 1, 1, 9, 0, 1, DateTimeKind.Utc);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -401,7 +401,7 @@ public class StoreSessionReregistrationPreserveTests
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(fresh, new Mock<Response>().Object));
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 3, 8, 0, 0, DateTimeKind.Utc),
@@ -419,7 +419,7 @@ public class StoreSessionReregistrationPreserveTests
public async Task StoreSessionAsync_fresh_row_carries_the_stamped_owner()
{
var harness = new Harness(existing: null);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -431,6 +431,214 @@ public class StoreSessionReregistrationPreserveTests
Assert.False(harness.Written.ContainsKey("OwnerThumbprint"));
}
// ── Carry-over of the columns the registration does not own ─────────────
[Fact]
public async Task StoreSessionAsync_carries_every_declared_column_it_does_not_own()
{
// Driven by the manifest: a column added to SessionIndexFieldManifest later is carried
// through an agent restart without anyone touching StoreSessionAsync. Merge-written columns
// such as ImeAgentVersion, LastIngestAt, ExcessiveEventsAlerted, Block* and the pending
// server actions used to be dropped by the Replace.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
};
foreach (var column in TableStorageService.CarriedColumns)
existing[column] = $"carried:{column}";
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Contains("ImeAgentVersion", TableStorageService.CarriedColumns);
Assert.Contains("PendingActionsJson", TableStorageService.CarriedColumns);
foreach (var column in TableStorageService.CarriedColumns)
Assert.Equal($"carried:{column}", harness.Written![column]);
}
[Fact]
public void RegistrationOwnedColumns_name_only_declared_columns()
{
// A typo in the owned list would silently turn a column into a carried one.
var declared = SessionIndexFieldManifest.All
.Concat(SessionIndexFieldManifest.PrimaryOnly)
.Append("IndexRowKey") // Sessions-only bookkeeping, deliberately outside the index manifest
.ToHashSet(StringComparer.Ordinal);
Assert.All(TableStorageService.RegistrationOwnedColumns, column => Assert.Contains(column, declared));
Assert.Equal(
TableStorageService.RegistrationOwnedColumns.Length,
TableStorageService.RegistrationOwnedColumns.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public async Task StoreSessionAsync_cas_retry_carries_the_values_of_its_own_fresh_read()
{
var startedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc));
var initial = new TableEntity(TenantId, SessionId) { ["StartedAt"] = startedAt, ["Status"] = "InProgress" };
initial.ETag = new ETag("0xINITIAL");
var firstFresh = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = startedAt, ["Status"] = "InProgress",
["ImeAgentVersion"] = "1.0", ["LastIngestAt"] = startedAt.AddMinutes(5),
};
firstFresh.ETag = new ETag("0xFRESH1");
var secondFresh = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = startedAt, ["Status"] = "InProgress",
["ImeAgentVersion"] = "2.0",
};
secondFresh.ETag = new ETag("0xFRESH2");
var harness = new Harness(initial);
harness.Sessions.SetupSequence(t => t.GetEntityAsync<TableEntity>(
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(firstFresh, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(secondFresh, new Mock<Response>().Object));
var attempts = 0;
TableEntity? written = null;
harness.Sessions.Setup(t => t.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
if (++attempts == 1)
return Task.FromException<Response>(new RequestFailedException(412, "ConditionNotMet"));
written = e;
return Task.FromResult(new Mock<Response>().Object);
});
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Equal(2, attempts);
Assert.Equal("2.0", written!.GetString("ImeAgentVersion"));
// Carried by the failed attempt, gone from the second fresh read: not carried again.
Assert.False(written.ContainsKey("LastIngestAt"));
}
[Fact]
public async Task StoreSessionAsync_does_not_carry_the_terminal_tuple_of_a_non_terminal_row()
{
// The terminal tuple is owned: it is re-stamped only while the fresh status is terminal.
// A WhiteGlove Part 1 row resuming into Part 2 keeps the old behaviour of dropping it.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "Pending",
["CompletionSource"] = "whiteglove_part1",
["FailureSnapshotJson"] = "{}",
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Equal("InProgress", harness.Written!.GetString("Status"));
Assert.False(harness.Written.ContainsKey("CompletionSource"));
Assert.False(harness.Written.ContainsKey("FailureSnapshotJson"));
}
// ── Server facts: validator and Autopilot registration ──────────────────
private static SessionRegistration Reregistration() => new()
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
};
private static TableEntity ExistingRow(params (string Column, object Value)[] columns)
{
var row = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
};
foreach (var (column, value) in columns)
row[column] = value;
row.ETag = new ETag("0xEXISTING");
return row;
}
[Fact]
public async Task StoreSessionAsync_writes_the_autopilot_registration_of_a_new_session()
{
var harness = new Harness(existing: null);
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
{
ValidatedBy = ValidatorType.AutopilotV1,
AutopilotRegistration = new AutopilotRegistration
{
DeviceId = "ap-1", GroupTag = "TEST", PurchaseOrderId = "PO-7", DisplayName = "LAB-01", UserAssigned = true,
},
});
Assert.True(ok);
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
Assert.Equal("PO-7", harness.Written.GetString("AutopilotPurchaseOrderId"));
Assert.Equal("LAB-01", harness.Written.GetString("AutopilotDisplayName"));
Assert.True(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
[Fact]
public async Task StoreSessionAsync_a_fresh_autopilot_registration_replaces_the_stored_one_whole()
{
// The group tag was removed in Intune between two agent starts: the column clears.
var harness = new Harness(ExistingRow(
("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "OLD"), ("AutopilotPurchaseOrderId", "PO-1"),
("AutopilotDisplayName", "LAB-01"), ("AutopilotUserAssigned", true)));
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
{
ValidatedBy = ValidatorType.AutopilotV1,
AutopilotRegistration = new AutopilotRegistration { DeviceId = "ap-1", PurchaseOrderId = "PO-2" },
});
Assert.True(ok);
Assert.False(harness.Written!.ContainsKey("AutopilotGroupTag"));
Assert.False(harness.Written.ContainsKey("AutopilotDisplayName"));
Assert.Equal("PO-2", harness.Written.GetString("AutopilotPurchaseOrderId"));
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
[Fact]
public async Task StoreSessionAsync_without_an_autopilot_registration_keeps_the_stored_one_and_the_validator()
{
// A re-registration another path admitted (or with validation switched off) proves nothing
// about the Autopilot registration: the stored values and the recorded validator stay.
var harness = new Harness(ExistingRow(
("ValidatedBy", "AutopilotV1"), ("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "TEST"),
("AutopilotUserAssigned", false)));
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts());
Assert.True(ok);
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
private sealed class Harness
{
public Mock<TableClient> Sessions { get; }
@@ -483,5 +691,9 @@ public class StoreSessionReregistrationPreserveTests
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
}
/// <summary>Stores with no server facts unless a test hands them in.</summary>
public Task<bool> Store(SessionRegistration registration, SessionOwner? owner = null, SessionServerFacts? facts = null)
=> Sut.StoreSessionAsync(registration, facts ?? new SessionServerFacts(), owner);
}
}
@@ -40,7 +40,7 @@ public class StoreSessionTenantLookupClaimTests
{
var harness = new Harness(existingOwner: null);
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.True(ok);
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
@@ -54,7 +54,7 @@ public class StoreSessionTenantLookupClaimTests
{
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.True(ok);
Assert.Contains("session-write", harness.Order);
@@ -67,7 +67,7 @@ public class StoreSessionTenantLookupClaimTests
var harness = new Harness(existingOwner: TenantA);
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
() => harness.Sut.StoreSessionAsync(Registration(TenantB), new SessionServerFacts()));
Assert.Equal(SessionId, ex.SessionId);
Assert.Equal(TenantB, ex.RequestedTenantId);
@@ -87,7 +87,7 @@ public class StoreSessionTenantLookupClaimTests
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.False(ok);
Assert.DoesNotContain("session-write", harness.Order);
@@ -66,7 +66,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
[Fact]
public async Task TheAlert_CarriesNeitherTextNorSenderNorContactAddress()
{
// Channels bound to FeedbackReceived (Telegram, Slack, webhooks) receive message + details;
// Channels bound to FeedbackReceived (Slack, webhooks, e-mail) receive message + details;
// the feedback itself is read in the portal. Personal data must never ride along.
var h = new Harness();
@@ -52,7 +52,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
Assert.Equal("Pricing was too high for our use case.", h.Repo.PersistedOffboardingFeedback.Comment);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback.InteractedAt);
// OpsEvent emitted with the canonical type catalog-registered for Telegram-routing.
// OpsEvent emitted with the canonical type catalog-registered for alert routing.
var ops = Assert.Single(h.OpsEvents);
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
Assert.Equal(TenantId, ops.TenantId);
@@ -283,7 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
// Alert-dispatch dependencies are inert: empty AdminConfig service + bare HTTP
// clients. The feedback path emits Info-severity so even if alerts ARE evaluated,
// no Telegram/webhook fires for an unconfigured rule set.
// no channel fires for an unconfigured rule set.
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
@@ -0,0 +1,66 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ranking of <see cref="TableDistressReportRepository.KeepNewestAsync"/>: the table yields rows in
/// tenant order, so a cap must keep the newest rows across tenants, never the first rows read.
/// </summary>
public class TableDistressReportRepositoryNewestTests
{
private static readonly DateTime T0 = new(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc);
private static DistressReportEntry Entry(string tenantId, int minute) => new()
{
TenantId = tenantId,
ErrorType = "DeviceNotRegistered",
SerialNumber = $"SN-{tenantId[0]}-{minute}",
IngestedAt = T0.AddMinutes(minute),
};
private static async IAsyncEnumerable<DistressReportEntry> Source(params DistressReportEntry[] entries)
{
foreach (var entry in entries)
{
await Task.Yield();
yield return entry;
}
}
// Tenant order as the table returns it: all rows of tenant "a", then tenant "b", each newest first.
private static DistressReportEntry[] TwoTenantsInterleavedInTime() =>
[
Entry("aaaaaaaa", 50), Entry("aaaaaaaa", 30), Entry("aaaaaaaa", 10),
Entry("bbbbbbbb", 60), Entry("bbbbbbbb", 40), Entry("bbbbbbbb", 20),
];
[Fact]
public async Task OverTheCap_KeepsNewestAcrossTenants_NewestFirst()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
Source(TwoTenantsInterleavedInTime()), maxResults: 4);
Assert.True(truncated);
Assert.Equal(new[] { 60, 50, 40, 30 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
}
[Fact]
public async Task AtTheCap_ReturnsAll_NotTruncated()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
Source(TwoTenantsInterleavedInTime()), maxResults: 6);
Assert.False(truncated);
Assert.Equal(new[] { 60, 50, 40, 30, 20, 10 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
}
[Fact]
public async Task EmptyTable_ReturnsEmpty_NotTruncated()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(Source(), maxResults: 4);
Assert.Empty(reports);
Assert.False(truncated);
}
}
@@ -45,7 +45,7 @@ public class TableLifecycleBucketTests
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
[Constants.TableNames.PreviewConfig] = "global e-mail template overrides",
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
@@ -1,236 +0,0 @@
using System.Net.Http;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
/// tenant admin from sending through the platform-owned bot.
/// </summary>
public class TelegramChannelProviderTests
{
private static Mock<WebhookNotificationService> WebhookMock()
{
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
// ── Dispatch routing ──────────────────────────────────────────────────
[Fact]
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync("-1003785642894", alert), Times.Once);
webhook.Verify(w => w.SendAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform);
webhook.Verify(w => w.SendAsync(
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
}, alert, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync("-100123", alert), Times.Once);
webhook.Verify(w => w.SendAsync(
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task SendToChannels_SkipsChannelsWithoutDestination()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, null, "tg"),
Channel((int)WebhookProviderType.Slack, "", "slack"),
}, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendTest_RoutesTelegramToBot()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var result = await dispatcher.SendTestAsync(
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
Assert.True(result.Success);
telegram.Verify(t => t.SendAlertAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
}
// ── Chat-ID validation ────────────────────────────────────────────────
[Theory]
[InlineData("-1003785642894")]
[InlineData("123456789")]
[InlineData("@salesdesk")]
[InlineData("@Sales_Desk_01")]
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("https://api.telegram.org/bot123/sendMessage")]
[InlineData("-100abc")]
[InlineData("@abc")] // too short
[InlineData("@1sales")] // must start with a letter
[InlineData("@sales-desk")] // hyphen not allowed
[InlineData("123456789012345678901")] // 21 digits
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Fact]
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
{
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
// provider-specific destination branch is wired, not just the format helper.
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
}
[Fact]
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
// ── Global-Admin gate ─────────────────────────────────────────────────
[Fact]
public void TelegramGate_GlobalAdminMayAddChannel()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
[Fact]
public void TelegramGate_TenantAdminMayNotAddChannel()
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
[Fact]
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
{
// The gate must not turn a GA-created Telegram channel into a permanent save block
// for the tenant admin's unrelated configuration changes.
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_IgnoresNonTelegramChannels()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
[Fact]
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
{
var candidate = new TenantConfiguration
{
TenantId = "t1",
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
};
var existing = new TenantConfiguration { TenantId = "t1" };
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
}
}
@@ -310,6 +310,13 @@ public class TenantConfigPatchServiceTests
[InlineData("domainName")]
[InlineData("lastUpdated")]
[InlineData("updatedBy")]
[InlineData("graphAccessRejectedSince")]
[InlineData("graphAccessRejectedCode")]
[InlineData("graphAccessRejectionConfirmedAt")]
[InlineData("intuneDeviceBindingPermissionMissingSince")]
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
[InlineData("cloudPcPermissionMissingSince")]
[InlineData("cloudPcPermissionMissingConfirmedAt")]
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
{
var harness = new Harness(Stored());
@@ -33,6 +33,9 @@ public class TenantConfigProjectionTests
"onboardedAt", "onboardedBy", "dpaVersion", "dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
"lastUpdated", "dataRetentionDays",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
"cloudPcPermissionMissingSince", "cloudPcPermissionMissingConfirmedAt",
};
[Fact]
@@ -213,6 +213,66 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.LastAuthClientIdSince);
}
[Fact]
public void Roundtrip_GraphAccessFields_SurviveStoreAndMap()
{
var since = new DateTime(2026, 11, 2, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
GraphAccessRejectedSince = since,
GraphAccessRejectedCode = "AADSTS7000229",
GraphAccessRejectionConfirmedAt = since.AddMinutes(20),
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(since, mapped.GraphAccessRejectedSince);
Assert.Equal("AADSTS7000229", mapped.GraphAccessRejectedCode);
Assert.Equal(since.AddMinutes(20), mapped.GraphAccessRejectionConfirmedAt);
}
[Fact]
public void Map_LegacyRow_WithoutGraphAccessColumns_ReadsNull()
{
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
Assert.Null(mapped.GraphAccessRejectedSince);
Assert.Null(mapped.GraphAccessRejectedCode);
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
}
[Fact]
public void Roundtrip_AddOnPermissionFields_SurviveStoreAndMap()
{
var since = new DateTime(2026, 11, 4, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
IntuneDeviceBindingPermissionMissingSince = since,
IntuneDeviceBindingPermissionMissingConfirmedAt = since.AddMinutes(15),
CloudPcPermissionMissingSince = since.AddHours(1),
CloudPcPermissionMissingConfirmedAt = since.AddHours(2),
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(since, mapped.IntuneDeviceBindingPermissionMissingSince);
Assert.Equal(since.AddMinutes(15), mapped.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(since.AddHours(1), mapped.CloudPcPermissionMissingSince);
Assert.Equal(since.AddHours(2), mapped.CloudPcPermissionMissingConfirmedAt);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
@@ -227,7 +227,7 @@ public class TestLogPatternFunctionTests
Assert.Equal(1, result.TimeoutCount);
Assert.Single(result.Lines);
Assert.Equal("regex_timeout", result.Lines[0].Outcome);
Assert.Equal(TestLogPatternFunction.StopReasonRegexTimeout, result.StopReason);
Assert.Equal(EvaluationStopReasons.RegexTimeout, 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");
@@ -259,7 +259,7 @@ public class TestLogPatternFunctionTests
Assert.Single(result.Lines);
Assert.Equal("matched", result.Lines[0].Outcome);
Assert.Equal(TestLogPatternFunction.StopReasonTimeBudget, result.StopReason);
Assert.Equal(EvaluationStopReasons.TimeBudget, result.StopReason);
Assert.Contains(result.Notes, n => n.Contains("after line 1") && n.Contains("lines 2-3 were not evaluated"));
var twoLines = TestLogPatternFunction.EvaluatePattern(
@@ -282,16 +282,19 @@ public class TestLogPatternFunctionTests
TestLogPatternFunction.EvaluationBudget, cts.Token);
Assert.Empty(result.Lines);
Assert.Equal(TestLogPatternFunction.StopReasonCancelled, result.StopReason);
Assert.Equal(EvaluationStopReasons.Cancelled, result.StopReason);
Assert.Equal(0, result.MatchCount);
}
// ===== the per-instance gate =====
private static TestLogPatternGate Gate(int limit)
=> new(limit, TestNotifications.CapturingOpsEvents(new()), Microsoft.Extensions.Logging.Abstractions.NullLogger<TestLogPatternGate>.Instance);
[Fact]
public void Gate_HandsOutLimitSlots_AndTakesThemBackOnDispose()
{
var gate = new TestLogPatternGate(1);
var gate = Gate(1);
Assert.Equal(1, gate.Limit);
var first = gate.TryEnter();
@@ -307,10 +310,10 @@ public class TestLogPatternFunctionTests
}
[Fact]
public void Gate_DefaultLimit_IsTwoPerInstance()
public void Gate_DefaultLimit_IsThreePerInstance()
{
var gate = new TestLogPatternGate();
Assert.Equal(2, gate.Limit);
var gate = new TestLogPatternGate(TestNotifications.CapturingOpsEvents(new()), Microsoft.Extensions.Logging.Abstractions.NullLogger<TestLogPatternGate>.Instance);
Assert.Equal(3, gate.Limit);
Assert.Equal(TestLogPatternGate.DefaultLimit, gate.Limit);
}
}
@@ -2,6 +2,7 @@ using System.Net.Http;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -10,11 +11,10 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> but do not
/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare
/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport
/// resolves its bot URL from a mocked repository that returns nothing.
/// HttpClient it never uses (no channel is configured in these stubs).
/// <para>
/// Tests that DO assert on dispatch should mock the transports directly — see
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
/// <c>EmailChannelProviderTests</c> — rather than extending this helper.
/// </para>
/// </summary>
internal static class TestNotifications
@@ -22,10 +22,19 @@ internal static class TestNotifications
internal static NotificationChannelDispatcher InertDispatcher()
=> new(
new WebhookNotificationService(new HttpClient()),
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
Mock.Of<IPushChannelSender>());
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
=> new(adminConfigService, InertDispatcher(), NullLogger<OpsAlertDispatchService>.Instance);
/// <summary>An <see cref="OpsEventService"/> that records every saved event in <paramref name="sink"/> and sends nothing.</summary>
internal static OpsEventService CapturingOpsEvents(List<OpsEventEntry> sink)
{
var repo = new Mock<IOpsEventRepository>();
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Returns<OpsEventEntry>(e => { lock (sink) sink.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, InertOpsAlertDispatch(adminConfig.Object));
}
}
@@ -376,6 +376,11 @@ public class TypedContractCompletionWireParityTests
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
deviceValidationEnabled = true,
graphAccessRejected = true,
graphAccessAppMissing = false,
intuneDeviceBindingPermissionMissing = true,
cloudPcPermissionMissing = false,
addOnPermissionMissingBlocksAll = false,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
@@ -402,6 +407,11 @@ public class TypedContractCompletionWireParityTests
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
DeviceValidationEnabled = true,
GraphAccessRejected = true,
GraphAccessAppMissing = false,
IntuneDeviceBindingPermissionMissing = true,
CloudPcPermissionMissing = false,
AddOnPermissionMissingBlocksAll = false,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
@@ -88,8 +88,7 @@ public class WhatsNewNotificationServiceTests
public Harness()
{
var webhook = new WebhookNotificationService(new HttpClient());
var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance);
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, telegram, Mock.Of<IPushChannelSender>());
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, Mock.Of<IPushChannelSender>());
Dispatcher
.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>(), It.IsAny<NotificationScope>()))
.Returns<IEnumerable<NotificationChannel>, NotificationAlert, NotificationScope>((channels, alert, scope) =>
@@ -434,14 +433,14 @@ public class WhatsNewNotificationServiceTests
}
[Fact]
public void BuildWhatsNewAlert_TelegramText_CarriesSectionsAndLinks()
public void BuildWhatsNewAlert_EmailText_CarriesSectionsAndLinks()
{
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(
new List<WhatsNewFeedEntry> { Entry("platform", "p", title: "**Lead**", body: "Body.") },
null,
WhatsNewNotificationService.PortalWhatsNewUrl("platform"));
var text = TelegramNotificationService.RenderAlertText(alert);
var text = EmailAlertRenderer.Text(alert);
Assert.Contains("What's new in Autopilot Monitor", text);
Assert.Contains("Platform \u00b7 Sep 8, 2026 \u2014 Lead", text);
@@ -32,12 +32,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
/// <summary>
/// Changes to ONLY these properties do not snapshot the tenant-config row: the
/// LastAuthClientId pair flips as an auth-flow side effect and would flood the
/// two backup slots with states nobody ever wants to revert to.
/// LastAuthClientId pair flips as an auth-flow side effect and the Graph access state as
/// a token side effect; both would flood the two backup slots with states nobody ever
/// wants to revert to.
/// </summary>
private static readonly HashSet<string> TenantBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy", "LastAuthClientId", "LastAuthClientIdSince",
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
};
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
@@ -541,62 +545,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- Preview Config ---
public async Task<Dictionary<string, string>> GetPreviewConfigAsync()
{
try
{
var config = new Dictionary<string, string>();
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
foreach (var kvp in entity.Value)
{
if (kvp.Key == "odata.etag" || kvp.Key == "PartitionKey" || kvp.Key == "RowKey" || kvp.Key == "Timestamp")
continue;
config[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
}
return config;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return new Dictionary<string, string>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading preview config");
throw;
}
}
public async Task<bool> SavePreviewConfigAsync(string key, string value)
{
try
{
// Get existing entity or create new one
TableEntity entity;
try
{
var existing = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
entity = existing.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
entity = new TableEntity("TelegramBot", "config");
}
entity[key] = value;
await _previewConfigTableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving preview config key {Key}", key);
return false;
}
}
// --- Email Template Overrides ---
private const string EmailTemplatesPartition = "EmailTemplates";
@@ -871,6 +819,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "HomedAppClientId", config.HomedAppClientId },
{ "LastAuthClientId", config.LastAuthClientId },
{ "LastAuthClientIdSince", config.LastAuthClientIdSince },
{ "GraphAccessRejectedSince", config.GraphAccessRejectedSince },
{ "GraphAccessRejectedCode", config.GraphAccessRejectedCode },
{ "GraphAccessRejectionConfirmedAt", config.GraphAccessRejectionConfirmedAt },
{ "IntuneDeviceBindingPermissionMissingSince", config.IntuneDeviceBindingPermissionMissingSince },
{ "IntuneDeviceBindingPermissionMissingConfirmedAt", config.IntuneDeviceBindingPermissionMissingConfirmedAt },
{ "CloudPcPermissionMissingSince", config.CloudPcPermissionMissingSince },
{ "CloudPcPermissionMissingConfirmedAt", config.CloudPcPermissionMissingConfirmedAt },
{ "PlanTier", config.PlanTier },
{ "TrialExpiresUtc", config.TrialExpiresUtc },
{ "TrialStartedUtc", config.TrialStartedUtc },
@@ -994,6 +949,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
HomedAppClientId = entity.GetString("HomedAppClientId"),
LastAuthClientId = entity.GetString("LastAuthClientId"),
LastAuthClientIdSince = entity.GetDateTime("LastAuthClientIdSince"),
GraphAccessRejectedSince = entity.GetDateTime("GraphAccessRejectedSince"),
GraphAccessRejectedCode = entity.GetString("GraphAccessRejectedCode"),
GraphAccessRejectionConfirmedAt = entity.GetDateTime("GraphAccessRejectionConfirmedAt"),
IntuneDeviceBindingPermissionMissingSince = entity.GetDateTime("IntuneDeviceBindingPermissionMissingSince"),
IntuneDeviceBindingPermissionMissingConfirmedAt = entity.GetDateTime("IntuneDeviceBindingPermissionMissingConfirmedAt"),
CloudPcPermissionMissingSince = entity.GetDateTime("CloudPcPermissionMissingSince"),
CloudPcPermissionMissingConfirmedAt = entity.GetDateTime("CloudPcPermissionMissingConfirmedAt"),
PlanTier = entity.GetString("PlanTier") ?? "free",
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
@@ -1054,12 +1016,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "SlaNotificationCooldownHours", config.SlaNotificationCooldownHours },
// Ops Alert settings
{ "OpsAlertRulesJson", config.OpsAlertRulesJson ?? string.Empty },
{ "OpsAlertTelegramEnabled", config.OpsAlertTelegramEnabled },
{ "OpsAlertTelegramChatId", config.OpsAlertTelegramChatId ?? string.Empty },
{ "OpsAlertTeamsEnabled", config.OpsAlertTeamsEnabled },
{ "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty },
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
// Per-line agent binary integrity: written by the release pipeline via Merge.
// The backend never changes them (the PATCH gate rejects them), so the
@@ -1130,12 +1086,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SlaNotificationCooldownHours = entity.GetInt32("SlaNotificationCooldownHours") ?? 24,
// Ops Alert settings
OpsAlertRulesJson = entity.GetString("OpsAlertRulesJson"),
OpsAlertTelegramEnabled = entity.GetBoolean("OpsAlertTelegramEnabled") ?? false,
OpsAlertTelegramChatId = entity.GetString("OpsAlertTelegramChatId"),
OpsAlertTeamsEnabled = entity.GetBoolean("OpsAlertTeamsEnabled") ?? false,
OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"),
OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false,
OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"),
OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"),
// Per-line agent binary integrity. V2 is the only wired line (V1 retired).
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
@@ -78,18 +78,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return result;
}
public async Task<List<DistressReportEntry>> GetAllDistressReportsAsync(int maxResults = 500)
public Task<(List<DistressReportEntry> Reports, bool Truncated)> GetNewestDistressReportsAsync(int maxResults)
{
var result = new List<DistressReportEntry>();
// A cross-partition query returns rows in tenant order, so the first N rows are not the
// newest N: the whole table is read (bounded by the 14-day cleanup) and ranked here.
return KeepNewestAsync(MapAll(_table.QueryAsync<TableEntity>(maxPerPage: 1000)), maxResults);
}
await foreach (var entity in _table.QueryAsync<TableEntity>(maxPerPage: maxResults))
private static async IAsyncEnumerable<DistressReportEntry> MapAll(IAsyncEnumerable<TableEntity> entities)
{
await foreach (var entity in entities)
yield return MapToEntry(entity);
}
/// <summary>
/// The newest <paramref name="maxResults"/> entries by IngestedAt, newest first. A min-heap keeps
/// memory at <paramref name="maxResults"/> however many rows the source yields.
/// </summary>
internal static async Task<(List<DistressReportEntry> Reports, bool Truncated)> KeepNewestAsync(
IAsyncEnumerable<DistressReportEntry> entries, int maxResults)
{
var newest = new PriorityQueue<DistressReportEntry, DateTime>();
var seen = 0;
await foreach (var entry in entries)
{
result.Add(MapToEntry(entity));
if (result.Count >= maxResults)
break;
seen++;
if (newest.Count < maxResults)
newest.Enqueue(entry, entry.IngestedAt);
else
newest.EnqueueDequeue(entry, entry.IngestedAt);
}
return result;
var reports = new List<DistressReportEntry>(newest.Count);
while (newest.TryDequeue(out var entry, out _))
reports.Add(entry);
reports.Reverse();
return (reports, seen > maxResults);
}
public async Task<int> DeleteDistressReportsOlderThanAsync(string tenantId, DateTime cutoff)
@@ -22,9 +22,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_publisher = publisher;
}
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null)
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionServerFacts serverFacts, SessionOwner? ownerToStamp = null)
{
var result = await _storage.StoreSessionAsync(registration, ownerToStamp);
var result = await _storage.StoreSessionAsync(registration, serverFacts, ownerToStamp);
if (result)
await _publisher.PublishAsync("session.created", registration, registration.TenantId);
return result;
@@ -266,9 +266,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
public Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
=> _storage.SearchSessionsAsync(tenantId, filter);
public Task<RawPage<SessionSummary>> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
=> _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation);
@@ -302,13 +302,15 @@ public class AutopilotDeviceValidationConsentFunction
/// <summary>
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
/// one per serial-based validation (Autopilot, corporate identifier, and
/// "device-preparation" for Device Association Validation).
/// "device-preparation" for Device Association Validation), plus "restore" for repairing the
/// Graph access of validations that are already on.
/// </summary>
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
{
"autopilot",
"corporate",
"device-preparation",
"restore",
};
/// <summary>

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