Compare commits

...

102 Commits

Author SHA1 Message Date
Oliver Kieselbach 6b06edb222 View as: keep the navbar and sidebar clear of the view-as bar
The bar sat in the normal flow above the sticky navbar and scrolled away,
while the desktop sidebar is fixed at the navbar's height. In a view-as tab
the navbar therefore covered the top of the sidebar (its first entry) by up
to the bar's height, depending on the scroll position.

The bar now sticks above the navbar at a fixed height: one line, a long
tenant name truncates. globals.css derives --view-as-offset from its
presence (:root:has([data-view-as-bar])): the bar's height while it is
shown, 0 otherwise. Everything positioned against the top of the viewport
adds it: the navbar's sticky top, the desktop sidebar, the mobile
notification panel, the sticky severity card, the push app's header, and
anchor scrolling (scroll-padding-top on the root). The sidebar's section
jump and scroll-spy margin add the bar's measured height
(viewAsBarHeight in lib/viewAs.ts). Ordinary tabs keep offset 0, so their
layout is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:37:49 +01:00
Oliver Kieselbach 5a37721d24 bump pointers: internal, .claude/commands (D-422 tenant activity: plan, decision, docs, health marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:29 +01:00
Oliver Kieselbach 884f8495b2 Tenant Activity: per-tenant usage for platform readers, MCP get_tenant_activity (D-422)
New route GET global/metrics/tenant-activity (GlobalReadOrAdmin, no tenant
scope): every signed-up tenant's enrollments, successes and active days over
the last N full UTC days (1-90) against the N days before, a lifecycle stage
(never enrolled, dormant, new, declining, active, quiet), the lifetime
counters, the last counter move and its portal users, plus totals and a daily
series. Built from the daily UsageMetrics rows, the per-tenant counters and
the UserActivity sign-in history; the sign-in read is the only fail-soft input.

- TenantLifecycleFacts: one set of per-tenant facts (enrolled, last active
  day, first day with sessions, the 30-day dormant check) for the growth
  figures and the new route. The window ends at the last aggregated day, so
  shortly after midnight UTC a tenant active only yesterday no longer counts
  as dormant in the growth figures either.
- Shared: GetTenantActivityResponse DTOs, TenantActivityStages and
  TenantEditionNames as manifest vocabularies, PortalVisit; the daily rows
  carry SessionsSucceeded and SessionsFailed. Parity facts, regenerated
  manifest and wire types.
- Web: GA -> Metrics -> Tenant Activity (above MCP Usage) with tiles,
  enrollments per day, a sortable ranking and the lifecycle lists. The
  generic list parts leave the MCP usage folder (lib/useListState, lib/format,
  sections/listParts with TenantNameCell); formatDayKey reads both day-key
  forms and replaces the Customers page's copy; PlanEdition derives from the
  backend's edition names.
- MCP: get_tenant_activity for Global Admins and Global Readers with the views
  summary, ranking and lifecycle; slim rows with fixed caps keep every answer
  below the tool limit. Catalog ceilings raised by the measured size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:06 +01:00
Oliver Kieselbach cc674b26ad bump pointer: internal, .claude/commands (D-421 released and verified live; platform-health view-as marker query fixed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:55:39 +01:00
Oliver Kieselbach 7139374681 bump pointer: internal, .claude/commands (D-421 view as tenant: decision, concept doc, plan; marker rows and KQL hints)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:41:24 +01:00
Oliver Kieselbach e6d92c6005 Merge pull request #212 from okieselbach/feat/view-as-tenant
View as tenant: a Global Admin reads one tenant's portal as its member, read-only
2026-11-06 16:38:58 +01:00
Oliver Kieselbach 46880b58e7 Portal: view a tenant as its member (Global Admin, read-only)
?viewAs=<tenant id> (optional &viewAsRole=Operator|Viewer) arms a view-as
tab. The selection lives in sessionStorage, outlives the sign-in redirect
of a new tab, and every API call carries the view-as headers, the SignalR
negotiate included. A purple bar names the viewed tenant and leaves the
mode; Tenant Management gets a "View as" link per tenant.

The tab stays isolated from the caller's other tabs and from the viewed
tenant's numbers:
- the dashboard seed is skipped, because a seed is matched by URL alone
- API calls bypass the browser's shared HTTP cache
- the Global-Admin view is forced off without writing localStorage, so
  other tabs keep it
- web telemetry counts the tab at the Global Admin's home tenant and names
  the viewed one as viewAsTenantId
- a refused view-as reloads the tab as an ordinary one and says why once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:39:34 +01:00
Oliver Kieselbach ee96ff2d23 Backend: read-only "view as tenant" for Global Admins
A Global Admin can send X-View-As-Tenant (optional X-View-As-Role) to read
one tenant's portal exactly as a member of that tenant sees it. The policy
middleware admits the headers only for a Global Admin in person (never an
MCP call or a foreign client app), refuses every write with ViewAsReadOnly,
and decides everything else with the same evaluators over an emulated
member, so the emulation cannot drift from the real rules: a parity test
compares every read route and tenant role against a real member.

- RequestContext.ViewAs: TenantId and TargetTenantId become the viewed
  tenant and TenantHelper.GetTenantId follows them; identity uses keep the
  caller's home tenant (IdentityTenantId)
- Route catalog: ViewAsAccess, GET reads and everything else writes, with
  each exception listed in a test
- auth/me answers as the emulated member without any sign-in side effect
- GET actions stay closed under view-as: re-analyze, re-scan, the consent
  flow and the homing auto-flip
- SignalR binding, presence and the request row stay with the real caller;
  every telemetry item of such a request carries ViewAsTenantId
- Ratchet: functions take the data tenant from the request context, never
  from the token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:39:12 +01:00
Oliver Kieselbach 4275c13dfc bump pointer: internal (D-420 released: Newtonsoft guard, Graph/Entra parsers on System.Text.Json, follow-ups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:04:18 +01:00
Oliver Kieselbach 1616e33c14 Merge pull request #211 from okieselbach/feat/newtonsoft-guard-upstream-stj
Newtonsoft.Json contained by a guard; Graph/Entra response parsers on System.Text.Json (D-420)
2026-11-06 12:18:10 +01:00
Oliver Kieselbach 0b39455d4e Graph/Entra response parsers: permanent invariant fuzz (D-420)
UpstreamJsonInvariantTests drive the nine production parsers with 500 bodies
each, generated with every value kind, the inputs of the deliberate
differences D1 to D7 included, and check without a reference:
- a parser throws only what its callers expect (JsonException for an
  unreadable body or link, InvalidOperationException for a non-object list
  entry, nothing for the Entra rejection and the managed device), and every
  listed kind still occurs;
- a body reads the same in another layout: compact, or spread out with
  comments and trailing commas, members in another order;
- every text in a result is text the body sent, trimmed or cut at most;
- ParseObjectOrNull yields an object, null or a JsonException for any text,
  edited bodies included.

The body generator and the result descriptions move out of the temporary
differential test into UpstreamResponseCases and UpstreamParserReadings, so
they stay when the Newtonsoft reference goes. The generator's default mode
draws the same random sequence as before, so the differential test still
compares the same 20,000 cases per parser. Value pools and serialized
literals are built once; layers 1, 3 and 4 together run in under a second.

Mutation-checked locally: reading non-text values as their raw JSON fails
18 of the 28 fuzz tests, dropping the element clone fails all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:59:45 +01:00
Oliver Kieselbach 48baffe753 Graph/Entra response parsers on System.Text.Json (D-420)
The eight parsers for Microsoft Graph and Entra token-endpoint responses read
through a new helper, Helpers/UpstreamJson, instead of Newtonsoft.Json:
GraphTokenService, EntraTokenRejection, the Autopilot, corporate-identifier,
device-association, Cloud PC and Intune-binding validators, and the script
display-name resolver. Every call site keeps its flow; the existing
catch (JsonException) blocks now bind to System.Text.Json.

Unchanged and pinned by the characterization tests: empty and null bodies,
malformed bodies and their outcome per validator, non-object list entries
(transient), paging, duplicate keys (last wins), case-sensitive names,
comments and trailing commas, the token lifetime.

Deliberate differences, each pinned by a test named _D1 to _D7:
- D1 JSON null in a text field reads as null instead of "".
- D2 dates keep their fraction and are culture-independent UTC.
- D3 date-shaped text is kept as sent.
- D4 non-text values, and text whose escapes are no valid UTF-16, read as
  null (fail-closed).
- D5 an expires_in that is not a whole number falls back to the default
  token lifetime instead of throwing or being coerced.
- D6 an error code beyond Int64 is skipped instead of throwing.
- D7 a body cut off mid-object is unreadable; Newtonsoft's DeserializeObject
  read the part before the cut as if it were complete.

Also:
- The script resolver caches a per-ID 200 without a name, as the full pull
  already does, so a nameless script costs no Graph call on every view.
- The token cache caps expires_in before the TimeSpan conversion; a value
  near Int64.MaxValue overflowed instead of hitting the 55-minute cap.
- New UpstreamJsonTests cover every helper member and a culture matrix;
  a TTL test records the cache lifetime per expires_in variant.
- The differential test compares a cut-off body with the parser's answer to
  an unreadable body (D7) and stays green on 20,000 cases per parser.
- The Newtonsoft guard baseline loses the eight parser files and the dead
  using in MaintenanceService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:40:25 +01:00
Oliver Kieselbach f6f88161f9 Graph/Entra response parsers: differential test against a Newtonsoft reference
LegacyUpstreamParsers keeps a verbatim copy of the eight Newtonsoft parsers in the test project.
UpstreamJsonDifferentialTests generates 20,000 bodies per parser from a fixed seed and requires the
production parser to yield exactly what the reference yields, or to throw where it throws.
The bodies vary:
- root kind, empty and broken bodies, trailing content
- comments, trailing commas, whitespace, property order, unknown and duplicate properties
- value lists of mixed entries, escaped and Unicode text
- paging links, lifetimes as number or numeric text, error-code variants

They leave out the inputs that the move to System.Text.Json changes on purpose; those are pinned in
GraphResponseParsingTests. Each parser must also reach a real result in at least 2 % of the cases,
so the comparison cannot pass on misses alone.

Today production and reference are identical, so the test is green. Altering two parsers in the
reference turned exactly those two tests red. The two files are temporary and go once the System.Text.Json
parsers have run cleanly in production (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:05:20 +01:00
Oliver Kieselbach d0a525ecbd Graph/Entra response parsers: characterization tests
GraphResponseParsingTests pins what the eight Graph and Entra token-endpoint parsers yield for
every realistic body and every malformed one: empty, null, array and broken bodies, missing or
non-array value lists, non-object entries before and after the match, paging links, lifetimes as
number or text, error-code variants, comments and trailing commas. Written against the current
Newtonsoft code and green on it. The tests ending in _D1 to _D6 pin the behaviour that the move
to System.Text.Json changes on purpose; the rest is the parsers' contract (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:51:07 +01:00
Oliver Kieselbach 257f403949 Graph/Entra responses: read through pure functions, behaviour unchanged
GraphTokenService, AutopilotDeviceValidator, CorporateIdentifierValidator and
ScriptDisplayNameResolver parsed their response bodies inline in the HTTP methods. The parsing
now sits in internal static functions (ReadTokenResponse, FindRegistration, ReadIdentityPage,
ReadScriptPage, ReadScriptDisplayName) with the same Newtonsoft calls, so every body yields the
same result and every exception still reaches the same catch block. This gives the response
parsers one testable shape ahead of their move to System.Text.Json (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:43:13 +01:00
Oliver Kieselbach 7cbcb6bc4c Guard: Newtonsoft.Json frozen per file and per project
NewtonsoftUsageGuardTests scans every shipped source file under src/; test projects are excluded, and
bin, obj, node_modules and .next are not entered. A code line that names the Newtonsoft.Json namespace
counts as a use, a comment line does not. The 104 files that use it today form a baseline with the
reason each group still needs it, and only the five projects that reference the package today may
keep the reference. A new use, a new package reference or a stale entry fails the test, so the
baseline can only shrink. New code reads and writes JSON with System.Text.Json (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:38:58 +01:00
Oliver Kieselbach f43462567e bump pointer: internal (D-419: #209 and the CI race fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:21:12 +01:00
Oliver Kieselbach b5ac4651a0 Session deletion maintenance test waits for both watchdog events
The severe-watchdog test blocked only until the severe OpsEvent and then asserted the warning as
well. The two delays are independent timers: under load the severe one can complete first, and the
cancel in the finally then drops a warning delay whose callback has not run yet (seen once in CI on
3bc8b87a). The fanout body now waits for both events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:12:08 +01:00
Oliver Kieselbach 3bc8b87a19 Bump xunit.runner.visualstudio from 2.8.2 to 4.0.1
Applied on main for #209: after the dotnet group update changed the neighbouring xunit lines, the
PR conflicted and Dependabot did not act on the rebase request. The change is the PR's own five
lines. All five test suites keep their counts (8532 / 3891 / 648 / 44 / 125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:01:16 +01:00
Oliver Kieselbach 950b3cc679 bump pointer: internal (D-419 Dependabot wave: decision, plan, backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:40:25 +01:00
dependabot[bot] 0559919a9e Bump Azure.Data.Tables and 15 others (#210)
Bumps Azure.Data.Tables from 12.11.0 to 12.13.0
Bumps Azure.Monitor.Query from 1.5.0 to 1.7.1
Bumps Azure.Storage.Blobs from 12.27.0 to 12.30.1
Bumps Azure.Storage.Queues from 12.25.0 to 12.28.1
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.10 to 10.0.14
Bumps Microsoft.Azure.Functions.Worker from 2.51.0 to 2.52.0
Bumps Microsoft.Azure.Functions.Worker.ApplicationInsights from 2.50.0 to 2.51.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore from 2.1.0 to 2.1.1
Bumps Microsoft.Azure.Functions.Worker.Extensions.SignalRService from 2.0.1 to 2.1.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues from 5.5.4 to 5.5.5
Bumps Microsoft.Azure.Functions.Worker.Sdk from 2.0.7 to 2.1.0
Bumps Microsoft.Azure.SignalR.Management from 1.33.0 to 1.33.1
Bumps Microsoft.Identity.Web from 4.3.0 to 4.16.1
Bumps Moq from 4.20.72 to 4.21.0
Bumps Newtonsoft.Json from 13.0.3 to 13.0.4
Bumps xunit from 2.9.2 to 2.9.3

---
updated-dependencies:
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Azure.Data.Tables
  dependency-version: 12.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Monitor.Query
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Blobs
  dependency-version: 12.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Queues
  dependency-version: 12.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker
  dependency-version: 2.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.ApplicationInsights
  dependency-version: 2.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.SignalRService
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues
  dependency-version: 5.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Sdk
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.SignalR.Management
  dependency-version: 1.33.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Identity.Web
  dependency-version: 4.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Moq
  dependency-version: 4.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:26:42 +01:00
dependabot[bot] e8f8b876e3 chore(deps-dev): bump the web group across 1 directory with 2 updates (#202)
Bumps the web group with 2 updates in the /src/Web/autopilot-monitor-web directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [postcss](https://github.com/postcss/postcss).


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

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

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: postcss
  dependency-version: 8.5.32
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:15:54 +01:00
dependabot[bot] 83afd4f1c9 chore(deps-dev): bump @azure/static-web-apps-cli (#200)
Bumps the swa-cli group with 1 update in the /tools/swa-cli directory: [@azure/static-web-apps-cli](https://github.com/azure/static-web-apps-cli).


Updates `@azure/static-web-apps-cli` from 2.0.10 to 2.1.0
- [Release notes](https://github.com/azure/static-web-apps-cli/releases)
- [Commits](https://github.com/azure/static-web-apps-cli/compare/v2.0.10...v2.1.0)

---
updated-dependencies:
- dependency-name: "@azure/static-web-apps-cli"
  dependency-version: 2.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: swa-cli
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:11:36 +01:00
dependabot[bot] d695c9b8c4 chore(deps-dev): bump knip (#201)
Bumps the mcp group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


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

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:58:41 +01:00
dependabot[bot] 1ee7c48c92 Bump System.Text.Json from 9.0.1 to 10.0.14 (#207)
---
updated-dependencies:
- dependency-name: System.Text.Json
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:41 +01:00
dependabot[bot] 98ac414208 Bump Microsoft.NET.Test.Sdk from 17.11.1 to 18.11.0 (#206)
---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:35 +01:00
dependabot[bot] 5153e90e7c Bump Microsoft.Extensions.Http.Polly from 8.0.25 to 10.0.14 (#205)
---
updated-dependencies:
- dependency-name: Microsoft.Extensions.Http.Polly
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:29 +01:00
Oliver Kieselbach 5b139f90e7 Backend: Pkcs past two .NET DoS advisories, Moq 4.21-safe log assertion, no Dependabot major for Application Insights 3
- Microsoft.Identity.Web 4.3.0 brings System.Security.Cryptography.Pkcs 10.0.0 through its token
  cache, affected by GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7 (fixed in 10.0.13). A direct
  reference lifts it to 10.0.14 until Microsoft.Identity.Web 4.17.0 or later is in.
- Moq 4.21 annotates the It.IsAnyType match parameter as nullable. The GraphTokenService log
  assertion checks for null first, so the pending dotnet group update compiles.
- Dependabot no longer proposes Microsoft.ApplicationInsights.WorkerService 3: the OpenTelemetry
  rewrite drops telemetry processors and the Functions worker integration still builds on 2.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:45:55 +01:00
Oliver Kieselbach 05d21cb240 bump pointer: internal (D-418 released: plan review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:26:12 +01:00
Oliver Kieselbach 5cc69a696a bump pointer: internal, .claude/commands (D-418 get_api_usage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:24 +01:00
Oliver Kieselbach fd3ce44c0c get_api_usage reads the MCP usage overview; totals, minute shares and tenant names come from the backend
The MCP tool get_api_usage (Global Admin and Global Reader) shows what the
admin MCP Usage page shows: view=summary (totals, top tools and tenants,
newest quota refusals, accounts closest to a per-minute limit, each list
cut with its full length stated), view=tools|tenants|quotaHits|
minuteLimits|daily (one list in full), view=organization (one tenant's
budget by account and day) and view=account (one account's requests by
tool, path and day). tenantId and userId must be GUIDs. The overview call
outlasts the route's telemetry budget.

The overview response gains totals, a sharePercent per minute row and the
tenant's domainName, computed once in McpUsageOverviewService; the page
reads them instead of computing its own. The tenant configuration is read
once per tenant and a failed read only drops the name and the per-minute
override.

Removed: GET global/metrics/mcp-usage and /daily with their DTOs and the
repository reads behind them; nothing reads the per-user usage log as a
whole any more. Also removed: the unused web URL helper for the per-user
route and the MCP usage pager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:23 +01:00
Oliver Kieselbach 0b48b1ce63 bump pointer: internal (work files)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:54:47 +01:00
Oliver Kieselbach 5cb536c46e bump pointer: internal (D-416 MCP usage overview: plan, review, backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:11:32 +01:00
Oliver Kieselbach 3f97a418d4 MCP Usage page (GA): tenants, tools and limits at a glance
The Global Admin MCP Usage page reads the overview route. The header has
the window, a switch for platform admins and Refresh; tiles count tool
calls, charged requests, tenants, accounts and the accounts at a limit;
a chart stacks the calls per day by usage plan. Three tabs in ?view=:
- Tenants & accounts: grouped by tenant with domain, edition and the
  quota bars for today and the month, expandable to accounts and service
  principals, with search and a near-a-limit filter
- Tools: ranked by calls with reach, requests per call, error rate, p95,
  answer size and over-cap calls; the backend routes on expand
- Limits: the plan legend, the quota refusals, and each account's
  busiest 60 seconds against the MCP server's and the backend's
  per-minute limits

QuotaBar moves to components (the reporting page uses it unchanged), the
stacked bar chart takes a custom tooltip like the bar chart, and the
global and daily usage URLs leave the web API client (the routes stay
for the MCP tool).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:00:49 +01:00
Oliver Kieselbach cdd44d152a bump pointer: internal (D-417 Dependabot after D-415)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:57:45 +01:00
Oliver Kieselbach d26d877989 Dependabot: SWA CLI lockfile in tools/, overrides for adm-zip and tmp, private submodules skipped, postcss override follows its devDependency
The SWA CLI lockfile moves from .github/tools/swa-cli to tools/swa-cli:
Dependabot's update PR for the old folder was refused. deploy-web and the
Dependabot entry follow. Its package.json overrides adm-zip (^0.6.1) and tmp
(^0.2.7), which the CLI still pins below the versions that fix their
advisories. The CLI's own extract call works on adm-zip 0.6.1, and
swa --version and swa deploy --help load.

.gitmodules sets update = none for the three private submodules, so
recursive clones skip them. Public clones no longer fail on them, and
Dependabot's NuGet updater, which clones with --recurse-submodules and aborts
on any failure, can run again. A clone with access sets
submodule.<name>.update = checkout once.

The web postcss override becomes "$postcss": it follows the devDependency
that Dependabot bumps instead of conflicting with it. The lockfile is
unchanged.

zizmor.yml moves to .github/zizmor.yml, where zizmor finds it on its own;
CI, CodeQL and the workflow gates watch the new path.

Decision D-417.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:55:55 +01:00
Oliver Kieselbach 59959c663e MCP usage overview route: tenants, accounts, tools, refusals, peaks
GET global/metrics/mcp-usage/overview (GlobalReadOrAdmin) builds the
all-tenant MCP usage view the Global Admin metrics page will read. Calls
are counted from the request telemetry as one correlation id per tool
call; requests and quota windows come from the quota counters (one RowKey
range over McpTenantUsage, each account's state through ReadStateAsync).
Seven fixed queries cover calls, routes, quota and per-minute refusals,
peaks over sliding 60-second windows, tool quality and the MCP server's
own refusals and limit. A store that does not answer empties only its
parts and is named in the response.

The query call moves from the log proxy into LogQueryExecutor, shared by
both; the proxy answers as before. The request-row dimension names become
constants used by writer and queries, the access probe names its function,
and the organization quota node moves into McpQuotaNodes. A fixture under
tests/fixtures/mcp-log-lines pins the MCP log fields the queries read, on
both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:44:50 +01:00
Oliver Kieselbach ed2e3fd96c MCP quota: a quota view reads without counting
Since the previous commit an allowed quota check counts the request on the
instance's counters. The usage page (GET metrics/mcp-usage/me) also ran
that check to show the quota, so every page view counted as an MCP request
until the next storage read. The service now has two entries: AdmitAsync
decides and counts and is called only by the enforcement middleware;
ReadStateAsync returns the same decision without counting, for views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:48:46 +01:00
Oliver Kieselbach d35edeaf9f MCP quota: count admitted requests at once, McpQuotaExceeded ops event
The daily and monthly MCP quota now decides and counts in one step on the
instance's cached counters, so one instance blocks at the limit, also for
the parallel requests of one tool call. Concurrent first checks share one
counter (single-flight), and the UTC day is part of the counter key, so a
reading from before midnight never answers a check after it. Only what
other instances admitted inside their 60-second reading stays soft.

The first refusal per instance, account or organization, window and period
raises the new ops event McpQuotaExceeded (Tenant, Info), selectable in the
alert rules. The Warning lines of quota and per-minute refusals start with
shared prefix constants.

The MCP server writes its per-account message budget into the boot line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:42:27 +01:00
Oliver Kieselbach 21b802a028 bump pointer: internal (keep-awake D-413/D-414 released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:39:58 +01:00
dependabot[bot] ae258cd9a7 chore(deps): bump jose (#199)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [jose](https://github.com/panva/jose).


Updates `jose` from 6.2.13 to 6.2.14
- [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.13...v6.2.14)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.14
  dependency-type: direct:production
  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-05 17:22:50 +01:00
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
416 changed files with 28971 additions and 4083 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
## Code Quality
+23
View File
@@ -24,6 +24,11 @@ updates:
dotnet-security:
applies-to: security-updates
patterns: ['*']
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
ignore:
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
@@ -98,6 +103,24 @@ 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.
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
- package-ecosystem: npm
directory: /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
+56 -5
View File
@@ -1,15 +1,18 @@
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
# persistence, template injection, ...). Configuration and the
# reasoned ignores live in zizmor.yml at the repository root; the
# same run is expected locally before a workflow change is committed.
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
# its own; the same run is expected locally before a workflow change
# is committed.
# * 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,
@@ -37,10 +40,10 @@ name: Workflow gates
on:
push:
branches: [main]
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
pull_request:
branches: [main]
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
workflow_dispatch:
permissions:
@@ -123,3 +126,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.
+5 -5
View File
@@ -24,7 +24,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
@@ -56,7 +56,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
@@ -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
@@ -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.
+2 -2
View File
@@ -17,7 +17,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
@@ -48,7 +48,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
+22 -15
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 (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/**'
- '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
@@ -155,8 +153,9 @@ jobs:
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,11 +163,19 @@ 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: tools/swa-cli
run: npm ci --ignore-scripts
- name: Download build artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
@@ -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 \
tools/swa-cli/node_modules/.bin/swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
View File
+6
View File
@@ -1,9 +1,15 @@
# The three submodules are private. "update = none" makes recursive clones skip them (the
# public, Dependabot's NuGet updater); a clone with access sets
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
[submodule ".claude/commands"]
path = .claude/commands
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
update = none
[submodule "docs"]
path = internal
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
update = none
[submodule "infra"]
path = infra
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
update = none
+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": {
@@ -18,9 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -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)]
@@ -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
@@ -44,7 +44,7 @@
<Import Project="V2Isolation.targets" />
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -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();
@@ -356,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(
@@ -386,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 →
@@ -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; } }
}
}
}
@@ -78,7 +78,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
@@ -13,9 +13,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,9 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -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()
{
@@ -2,6 +2,7 @@ using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
""";
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
Assert.Null(partial);
var table = Assert.Single(tables);
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
"error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}}
""";
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
Assert.Single(tables);
Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial);
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
[Fact]
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
{
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
Assert.Empty(tables);
Assert.Null(partial);
}
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
[Fact]
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
{
var (msg, code) = AppInsightsQueryFunction.ExtractError(
var (msg, code) = LogQueryExecutor.ExtractError(
"""{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}""");
Assert.Equal("SyntaxError", code);
Assert.Equal("Query could not be parsed at 'foo'", msg);
var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
Assert.Equal("InsufficientAccessError", outerCode);
Assert.Equal("Forbidden", outerMsg);
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
Assert.Null(plainCode);
Assert.Equal("Telemetry store query failed", plainMsg);
}
@@ -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?>()))
@@ -190,7 +181,7 @@ public class AuthFunctionSideEffectTests
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
{
var services = new ServiceCollection();
services.AddOptions();
@@ -199,9 +190,11 @@ public class AuthFunctionSideEffectTests
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (requestContext != null)
items[RequestContext.ItemsKey] = requestContext;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
@@ -233,7 +226,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 +254,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 +342,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 +363,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 +381,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 +616,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 +642,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
@@ -1102,4 +1095,96 @@ public class AuthFunctionSideEffectTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
}
// -------------------------------------------------------------------------
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
// -------------------------------------------------------------------------
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
private static RequestContext ViewingAs(string role) => new()
{
TenantId = ViewedTenantId,
TargetTenantId = ViewedTenantId,
UserPrincipalName = Upn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(TenantId, role),
};
[Fact]
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
{
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
// state in which an ordinary sign-in would seed it, onboard, and record a login.
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
viewed.CompanyName = "Fabrikam";
viewed.DpaAcceptancePending = true;
var home = DefaultConfig();
home.DomainName = null!;
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
var response = await _sut.GetCurrentUser(req, ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadBodyAsync(response);
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
Assert.Contains("\"isGlobalAdmin\":false", body);
Assert.Contains("\"isGlobalReader\":false", body);
Assert.Contains("\"isDelegated\":false", body);
Assert.Contains("\"role\":\"Viewer\"", body);
Assert.Contains("\"isTenantAdmin\":false", body);
Assert.Contains("\"dpaAcceptancePending\":true", body);
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
Assert.Contains(
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
body);
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
Assert.Empty(_store.Writes);
Assert.Empty(viewedStore.Writes);
_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);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
}
[Fact]
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
{
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
var response = await _sut.GetCurrentUser(req, ctx);
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
}
}
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Functions.Services;
@@ -69,7 +70,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 +96,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 +169,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 +202,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 +214,66 @@ public class AutopilotDeviceValidatorTests
Assert.True(observed.CanBeCanceled);
}
// ── Registration fields the session keeps ────────────────────────────────
private const string Upn = "first.last@example.invalid";
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Device(
"{\"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 = Device(
"{\"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 = Device($"{{\"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);
}
}
@@ -13,10 +13,10 @@
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -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
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
Assert.Equal("preassociated", result.AssociationState);
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
Assert.Null(result.AssignedToUserPrincipalName);
Assert.NotNull(result.PreAssociationDateTime);
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
}
@@ -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;
}
@@ -130,6 +130,36 @@ public class ScriptDisplayNameResolverTests
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
}
[Theory]
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
[InlineData("{\"id\":\"nameless\"}")]
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
{
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
// is a cache hit instead of another Graph call.
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Platform,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Platform, "nameless");
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
Assert.Null(entry.DisplayName);
}
[Fact]
public async Task Empty_refs_returns_empty_dict()
{
@@ -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}}";
}
}
@@ -0,0 +1,658 @@
using System;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
/// deliberate difference of that move (listed in D-420).
/// </summary>
public class GraphResponseParsingTests
{
private const string Serial = "PF3XKQ7";
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
[Theory]
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
[InlineData("""{"access_token":"t"}""", null)]
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.Equal("t", token);
Assert.Equal(expiresIn, lifetime);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"access_token":""}""")]
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.True(string.IsNullOrEmpty(token));
Assert.Null(lifetime);
}
[Theory]
[InlineData("<html>busy</html>")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("""{"access_token":"t"} x""")]
public void Token_response_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
[Theory]
[InlineData("\"abc\"")]
[InlineData("\"\"")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("99999999999999999999")]
[InlineData("3599.5")]
[InlineData("true")]
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
Assert.Equal("t", token);
Assert.Null(lifetime);
}
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
[Theory]
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("\"AADSTS7000229\"")]
[InlineData("42")]
[InlineData("[1,2]")]
[InlineData("<html>busy</html>")]
[InlineData("""{"error_description":null}""")]
[InlineData("""{"error_description":42}""")]
[InlineData("""{"error_codes":[7000229]} x""")]
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
{
var rejection = EntraTokenRejection.Parse(400, body);
Assert.Equal(400, rejection.StatusCode);
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Entra_rejection_keeps_a_date_shaped_description_D3()
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
[Fact]
public void Entra_rejection_skips_an_out_of_range_code_D6()
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"value":[{"serialNumber":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Autopilot_list_without_the_serial_is_not_registered(string body)
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("""{"value":[]} x""")]
public void Autopilot_list_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
[Fact]
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
Serial);
Assert.NotNull(registration);
Assert.Equal("ap-1", registration!.DeviceId);
Assert.Equal("Sales", registration.GroupTag);
Assert.Equal("PO-7", registration.PurchaseOrderId);
Assert.Equal("KIOSK-1", registration.DisplayName);
Assert.True(registration.UserAssigned);
}
[Fact]
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
{
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
var registration = AutopilotDeviceValidator.FindRegistration(
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
Serial);
Assert.NotNull(registration);
Assert.Null(registration!.DeviceId);
Assert.Null(registration.GroupTag);
Assert.Null(registration.PurchaseOrderId);
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
Assert.False(registration.UserAssigned);
}
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Autopilot_registration_keeps_date_shaped_text_D3()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
Serial);
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
}
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
private static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
[Theory]
[InlineData("", "")]
[InlineData("null", "")]
[InlineData("{}", "")]
[InlineData("""{"value":null}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
public void Corporate_page_with_the_identifier_is_found(string body)
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
public void Corporate_page_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Fact]
public void Corporate_page_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
CorporateId));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
{
// The scan must stop as a failure: either the read throws or the url cannot be requested.
try
{
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
Assert.False(found);
Assert.False(string.IsNullOrEmpty(nextUrl));
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
// Throwing ends the scan as transient, like an unusable url does.
}
}
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
[Fact]
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
CorporateId).Found);
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("{} x")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Association_list_without_the_serial_is_not_found(string body)
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Association_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Association_list_reads_the_matching_device_and_stops_there()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
Serial);
Assert.True(result.IsValid);
Assert.Equal("associated", result.AssociationState);
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
Assert.Equal("md-1", result.ManagedDeviceId);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
}
[Theory]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
public void Association_date_that_is_unset_reads_as_null(string body)
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Association_fields_that_are_json_null_read_as_null_D1()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
Serial);
Assert.Null(result.AssociationState);
Assert.Null(result.AssignedToUserPrincipalName);
Assert.Null(result.ManagedDeviceId);
}
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
[Fact]
public void Association_text_with_a_broken_escape_reads_as_null_D4()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
Assert.True(result.IsValid);
Assert.Null(result.AssignedToUserPrincipalName);
}
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
Serial).PreAssociationDateTime);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
}
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
[InlineData("""{"value":[]} x""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
public void CloudPc_list_without_the_device_is_not_found(string body)
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
[Fact]
public void CloudPc_list_reads_the_matching_cloud_pc()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
IntuneId);
Assert.True(result.IsValid);
Assert.Equal("cpc-1", result.CloudPcId);
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
}
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
Assert.True(result.IsValid);
Assert.Null(result.CloudPcId);
Assert.Null(result.ManagedDeviceName);
Assert.Null(result.ServicePlanName);
}
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
[InlineData("""{"id":null}""")]
[InlineData("""{"id":42}""")]
[InlineData("""{"id":"other"}""")]
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
[Theory]
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("1", null)]
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
[Theory]
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
IntuneId);
Assert.Null(result.SerialNumber);
Assert.Null(result.OwnerType);
Assert.Null(result.EnrollmentType);
Assert.Null(result.EnrolledDateTime);
}
[Fact]
public void Managed_device_reads_its_fields()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal("DESKTOP-1", result.DeviceName);
Assert.Equal("aad-1", result.AzureAdDeviceId);
Assert.Equal("PF3XKQ7", result.SerialNumber);
Assert.Equal("company", result.OwnerType);
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
}
[Fact]
public void Managed_device_unset_enrollment_date_reads_as_null()
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.DeviceName);
Assert.Null(result.AzureAdDeviceId);
Assert.Null(result.ManagementState);
}
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
// fraction and swapped day and month on day-first cultures).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
// D4: an id that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Managed_device_with_a_numeric_id_is_not_found_D4()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
// depended on the host's time zone; it was only right on a UTC host.
[Theory]
[InlineData("2026-05-08T14:16:49+02:00")]
[InlineData("2026-05-08T07:16:49-05:00")]
[InlineData("2026-05-08T12:16:49Z")]
[InlineData("2026-05-08T12:16:49")]
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
{
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
Assert.Equal(expected, association);
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
Assert.Equal(new DateTimeOffset(expected), enrolled);
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
}
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
[Fact]
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
withFileName: true);
Assert.Equal(new[]
{
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
}, scripts);
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
}
[Theory]
[InlineData("""{"value":[{"id":"s1"}]}""")]
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("{}")]
public void Script_page_without_a_next_link_ends_the_pull(string body)
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
[Fact]
public void Script_page_of_health_scripts_reads_no_file_name()
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
public void Script_page_or_name_that_is_not_one_object_throws(string body)
{
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
}
[Theory]
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
[InlineData("""{"id":"s1"}""", null)]
public void Script_name_reads_the_display_name(string body, string? name)
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
[Fact]
public void Script_name_that_is_json_null_reads_as_null_D1()
{
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
}
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("th-TH")]
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
[Fact]
public void Script_name_that_is_a_number_reads_as_null_D4()
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
[Fact]
public void Token_response_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
[Fact]
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
{
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
[Fact]
public void Autopilot_list_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
[Fact]
public void Corporate_page_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
CorporateId));
[Fact]
public void Association_list_cut_off_is_not_found_D7()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_cut_off_is_not_found_D7()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Managed_device_cut_off_is_not_found_D7()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
private static T InCulture<T>(string culture, Func<T> read)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
return read();
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
})
.Build();
var svc = NewService(config, new StubHttpClientFactory(handler));
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
return (svc, handler);
}
@@ -43,16 +43,22 @@ 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,
IMemoryCache? cache = null) =>
new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
cache ?? 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"));
@@ -126,6 +132,53 @@ public class GraphTokenServiceTests
Assert.Equal(1, TokenPosts(handler, TenantA));
}
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
[Theory]
[InlineData("3599", 3299)]
[InlineData("\"3599\"", 3299)]
[InlineData("7200", 3300)]
[InlineData("120", 60)]
[InlineData("null", 1800)]
[InlineData("\"abc\"", 1800)]
[InlineData("3599.5", 1800)]
[InlineData("0", 1800)]
[InlineData("-5", 1800)]
[InlineData("9223372036854775807", 3300)]
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
{
var cache = new RecordingMemoryCache();
var (svc, _) = Create(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
}
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
private sealed class RecordingMemoryCache : IMemoryCache
{
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
public List<ICacheEntry> Entries { get; } = new();
public ICacheEntry CreateEntry(object key)
{
var entry = _inner.CreateEntry(key);
Entries.Add(entry);
return entry;
}
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
public void Remove(object key) => _inner.Remove(key);
public void Dispose() => _inner.Dispose();
}
[Fact]
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
{
@@ -240,7 +293,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 +314,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 != null && 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);
}
}
@@ -235,6 +235,26 @@ public class GrowthMetricsBuilderTests
Assert.Null(a.MedianDaysToFirstEnrollment);
}
[Fact]
public void Before_yesterday_is_aggregated_yesterdays_counters_count_as_activity_and_the_rows_end_the_day_before()
{
// Sessions yesterday after a 40-day pause; the first maintenance run after midnight has not written them yet.
var configs = new[] { Config('a', Utc(6, 1, 9)) };
var stats = new[] { Stats('a', 6, 5, Utc(10, 27, 22)) };
var rows = new[] { Active('a', 2026, 9, 17) };
var early = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions, yesterdayAggregated: false);
Assert.Equal("2026-10-26", early.UsageDaysThrough);
Assert.Equal(0, early.AllTenants.Dormant);
Assert.Equal(1, early.AllTenants.Active7Days);
// Aggregated, yesterday has no row: the counters moved for an old session, which is no activity.
var later = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions);
Assert.Equal("2026-10-27", later.UsageDaysThrough);
Assert.Equal(1, later.AllTenants.Dormant);
}
[Fact]
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
{
@@ -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");
}
}
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
Assert.Null(result.EnrolledDateTime);
}
// -- ParseEnrolledDateTime --
// -- enrolledDateTime --
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
[Fact]
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("20260824")]
[InlineData("true")]
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
{
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
Assert.Null(EnrolledDateTime(jsonValue));
}
[Fact]
public void ParseEnrolledDateTime_NormalizesToUtc()
public void EnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
@@ -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()
{
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
/// </summary>
internal static class LegacyUpstreamParsers
{
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
}
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
{
JObject? json = null;
if (!string.IsNullOrWhiteSpace(body))
{
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
catch (JsonException) { json = null; }
}
var description = (json?["error_description"] as JValue)?.Value as string;
string? code = null;
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null)
{
code = "AADSTS" + first.Value<long>();
}
else
{
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
if (start >= 0)
{
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
code = digits.Length == 0 ? null : "AADSTS" + digits;
}
}
string? shortened = null;
if (!string.IsNullOrWhiteSpace(description))
{
var line = description.Split('\n')[0].Trim();
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
}
return new EntraTokenRejection(statusCode, code, shortened);
}
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is not JArray devices || devices.Count == 0)
return null;
var exactDevice = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (exactDevice == null)
return null;
return new AutopilotRegistration
{
DeviceId = RegistrationText(exactDevice["id"]),
GroupTag = RegistrationText(exactDevice["groupTag"]),
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(exactDevice["displayName"]),
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
};
}
private static string? RegistrationText(JToken? token)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
: value;
}
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is JArray identities)
{
foreach (var identity in identities)
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
}
}
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
}
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
{
var notFound = new DeviceAssociationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
return notFound;
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
};
}
private static DateTime? ParseAssociationDate(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
return dt.Year <= 1 ? null : dt;
return null;
}
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
{
var notFound = new CloudPcValidationResult
{
IsValid = false,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
};
}
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
{
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
};
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
{
var notFound = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
return notFound;
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
};
}
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
foreach (var item in values.OfType<JObject>())
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
}
return (scripts, json["@odata.nextLink"]?.ToString());
}
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
}
@@ -45,6 +45,24 @@ public class LiveStreamPrincipalTests
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
{
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext
{
TenantId = OtherTenantId,
UserPrincipalName = OperatorUpn,
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
});
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
{
@@ -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);
}
}
@@ -96,7 +96,7 @@ public class McpOrganizationUsageAggregationTests
Row(Member, "20260903", 7), // read window past today (range only)
};
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
Assert.Equal("pro", quota.TenantPlan);
@@ -114,7 +114,7 @@ public class McpOrganizationUsageAggregationTests
public void OrganizationQuota_PurchasedSlots_CarryTheBreakdown()
{
// 3 000 + 3 × 900 = 5 700 — the limits arrive grown; the slot fields let the page show the sum.
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
new[] { Row(Member, "20260902", 3) },
new McpQuotaService.TenantPlanLimits("pro", 5700, 114000, PurchasedDelegatedSlots: 3, SlotTenantDailyLimit: 900, SlotTenantMonthlyLimit: 18000),
"20260902", "20260901");
@@ -145,7 +145,7 @@ public class McpOrganizationUsageAggregationTests
[Fact]
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
{
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
Assert.Equal(0, quota.DailyLimit);
@@ -96,6 +96,47 @@ public class McpQuotaEnforcementMiddlewareTests
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
}
// ── ExceededAlertKey — one McpQuotaExceeded per account or organization, window and period ──
private const string OidA = "00000000-0000-0000-0000-00000000000a";
private const string OidB = "00000000-0000-0000-0000-00000000000b";
private static McpQuotaDecision UserDailyBlock(DateTime now)
=> McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
dailyUsed: 50, monthlyUsed: 60, tenantDailyUsed: 60, tenantMonthlyUsed: 60, now);
[Fact]
public void AlertKey_AccountWindow_IsPerAccountAndDay()
{
var key = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now));
Assert.Equal($"user:daily:{OidA}:20260903", key); // the period is the reset date
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, UserDailyBlock(Now)));
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now.AddDays(1))));
}
[Fact]
public void AlertKey_OrganizationWindow_IsSharedByAllMembers()
{
// The organization's window is one budget: the refusal of any member raises one event for it.
var tenantBlock = Blocked("community");
var a = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home.ToUpperInvariant(), tenantBlock);
var b = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, tenantBlock);
Assert.Equal(a, b);
Assert.Equal($"tenant:daily:{Home}:20260903", a);
}
[Fact]
public void AlertKey_MonthlyWindow_IsItsOwnKey()
{
var monthly = McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
dailyUsed: 10, monthlyUsed: 250, tenantDailyUsed: 10, tenantMonthlyUsed: 10, Now);
Assert.Equal($"user:monthly:{OidA}:20261001", McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, monthly));
}
[Fact]
public void UserLevelBlock_ReportsTheUserWindow()
{
@@ -130,7 +130,8 @@ public class McpQuotaServiceTests
Func<string?, TenantEdition>? editionResolver = null,
Func<string?, string?>? tenantPlanOverrideResolver = null,
Func<string?, int>? purchasedSlotsResolver = null,
Func<string?, EditionResolution>? resolutionResolver = null)
Func<string?, EditionResolution>? resolutionResolver = null,
TestTimeProvider? time = null)
{
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
@@ -162,9 +163,8 @@ public class McpQuotaServiceTests
mcpUserService,
adminConfigService,
entitlements,
cache,
NullLogger<McpQuotaService>.Instance,
new TestTimeProvider(Now));
time ?? new TestTimeProvider(Now));
}
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
@@ -197,7 +197,7 @@ public class McpQuotaServiceTests
// 100 requests today (catalog Community fallback: 100/day) → blocked.
var svc = Build(UsageRepo(("20260707", 100)));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("community", d.Plan);
@@ -210,7 +210,7 @@ public class McpQuotaServiceTests
{
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("pro", d.Plan);
@@ -224,7 +224,7 @@ public class McpQuotaServiceTests
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("monthly", d.Scope);
@@ -238,7 +238,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal(5, d.DailyLimit);
@@ -251,7 +251,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -266,7 +266,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("community", d.Plan);
@@ -279,7 +279,7 @@ public class McpQuotaServiceTests
// Override names a plan that exists nowhere → Community fallback limits.
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("no-such-plan", d.Plan);
@@ -300,7 +300,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson,
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("msp", d.Plan);
@@ -318,7 +318,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json,
edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -335,7 +335,7 @@ public class McpQuotaServiceTests
// falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows).
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("msp", d.Plan);
@@ -351,7 +351,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: MspPlanJson,
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp", purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("msp", d.Plan);
Assert.Equal(2100, d.DailyLimit); // 1500 + 2 × 300
@@ -367,8 +367,8 @@ public class McpQuotaServiceTests
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
var second = await svc.CheckAsync(Oid, Upn, TenantId);
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
Assert.True(second.Allowed);
@@ -383,69 +383,144 @@ public class McpQuotaServiceTests
var repo = UsageRepo(("20260707", 1));
var svc = Build(repo);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
Times.Once);
}
// ── Soft-boundary contract (Codex finding 2026-07-07) ─────────────────────────
// The quota is DELIBERATELY soft: the per-user decision is cached for 60s, so an allowed
// decision keeps admitting requests inside that window even after the fire-and-forget
// increments push the stored counters past the limit. Overshoot is bounded (~TTL × request
// rate per instance) — the same posture as the sliding-window rate limiter. These tests PIN
// that contract; if strict limit+1 blocking is ever wanted, the decision cache must be
// reworked and these tests consciously rewritten.
// ── Boundary: exact per instance, soft across instances ──────────────────────
// The counters are read from storage at most every 60 s, and every request the instance admits is
// counted on them at once — so one instance blocks exactly at the limit, also when a tool call fans out
// into parallel requests. What stays soft is the part of OTHER instances: they learn of these requests
// only with their own next read.
[Fact]
public async Task Check_SoftBoundary_CachedAllowedDecision_KeepsAdmitting_WithinTtl()
public async Task Check_SequentialRequests_BlockExactlyAtTheLimit_WithoutRereading()
{
// First read: one request below the daily limit → allowed, decision cached.
var repo = UsageRepo(("20260707", 99));
// Community catalog: 100/day. 98 served before → the 99th and 100th pass, the 101st is refused.
var repo = UsageRepo(("20260707", 98));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
var ninetyNinth = await svc.AdmitAsync(Oid, Upn, TenantId);
var hundredth = await svc.AdmitAsync(Oid, Upn, TenantId);
var hundredFirst = await svc.AdmitAsync(Oid, Upn, TenantId);
// The async increments land: stored counters are now OVER the limit — but the cached
// allowed decision keeps admitting within the TTL, without re-reading storage.
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord>
{
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
});
var second = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(second.Allowed, "soft boundary: cached allowed decision persists within the TTL");
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
Times.Once);
Assert.True(ninetyNinth.Allowed);
Assert.Equal(98, ninetyNinth.DailyUsed); // the decision reports the count BEFORE this request
Assert.True(hundredth.Allowed);
Assert.False(hundredFirst.Allowed);
Assert.Equal("daily", hundredFirst.Scope);
Assert.Equal(100, hundredFirst.DailyUsed);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_SoftBoundary_AfterCacheEviction_OverLimitCounters_Block()
public async Task ReadState_NeverCounts_OnlyAdmitDoes()
{
// Same scenario as above, but once the cached decision is gone (TTL expiry — simulated
// via eviction), the next check reads the over-limit counters and blocks.
var cache = new MemoryCache(new MemoryCacheOptions());
// The usage page shows the quota; viewing it is not an MCP request. Three reads leave the count at 99,
// so the next real request still passes and only the one after it is refused.
var svc = Build(UsageRepo(("20260707", 99)));
for (var i = 0; i < 3; i++)
{
var view = await svc.ReadStateAsync(Oid, Upn, TenantId);
Assert.True(view.Allowed);
Assert.Equal(99, view.DailyUsed);
}
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.Equal(100, (await svc.ReadStateAsync(Oid, Upn, TenantId)).DailyUsed);
}
[Fact]
public async Task Check_ParallelBurst_AdmitsExactlyUpToTheLimit()
{
// A tool call's parallel requests must not all pass on the same reading: 95 used, 20 at once → 5 pass.
var repo = UsageRepo(("20260707", 95));
var svc = Build(repo);
var decisions = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => svc.AdmitAsync(Oid, Upn, TenantId))));
Assert.Equal(5, decisions.Count(d => d.Allowed));
// Single-flight: concurrent first checks share one counter, so storage was read once.
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_TenantWindow_CountsEveryMemberOnThisInstance()
{
// Community organization window 300/day, 298 used by the organization. Member A and member B each get one
// request through; the next one of A is refused at the organization level although A's own window is wide.
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 298L) });
repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = OtherOid, Date = "20260707", RequestCount = 1 } });
var svc = Build(repo);
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId)).Allowed);
var third = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(third.Allowed);
Assert.Equal("tenant", third.Level);
Assert.Equal(300, third.TenantDailyUsed);
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_SecondInstance_LearnsOfTheFirstOnlyWithItsOwnRead()
{
// The part that stays soft, pinned: two instances share storage, not memory. Storage still says 99 (the
// increments of instance A are on their way), so instance B admits one more on its own fresh reading.
var repo = UsageRepo(("20260707", 99));
var svc = Build(repo, cache: cache);
var instanceA = Build(repo);
var instanceB = Build(repo);
Assert.True((await svc.CheckAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
}
[Fact]
public async Task Check_CounterExpiry_RereadsStorage()
{
// After 60 s the counter is read again, so increments of other instances count from then on.
var time = new TestTimeProvider(Now);
var repo = UsageRepo(("20260707", 10));
var svc = Build(repo, time: time);
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord>
{
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
});
cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } });
time.SetUtcNow(Now.AddSeconds(61));
var decision = await svc.CheckAsync(Oid, Upn, TenantId);
var decision = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(decision.Allowed);
Assert.Equal("daily", decision.Scope);
Assert.Equal(150, decision.DailyUsed);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
}
[Fact]
public async Task Check_Midnight_StartsAFreshCounter_InsideTheSixtySeconds()
{
// A counter read at 23:59:50 never answers a check at 00:00:05: the day is part of the key.
var lateEvening = new DateTime(2026, 7, 7, 23, 59, 50, DateTimeKind.Utc);
var time = new TestTimeProvider(lateEvening);
var repo = UsageRepo(("20260707", 100));
var svc = Build(repo, time: time);
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
time.SetUtcNow(lateEvening.AddSeconds(15));
var afterMidnight = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(afterMidnight.Allowed);
Assert.Equal(0, afterMidnight.DailyUsed);
Assert.Equal(new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc), afterMidnight.ResetUtc);
}
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
@@ -456,7 +531,7 @@ public class McpQuotaServiceTests
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("tenant", d.Level);
@@ -474,7 +549,7 @@ public class McpQuotaServiceTests
new[] { ("20260707", 1L) },
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("tenant", d.Level);
@@ -492,7 +567,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
planDefinitionsJson: json, mcpUserPlanOverride: "power");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -509,7 +584,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(5, d.DailyLimit);
@@ -524,7 +599,7 @@ public class McpQuotaServiceTests
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
var svc = Build(repo, planDefinitionsJson: json);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(0, d.TenantDailyLimit);
@@ -537,7 +612,7 @@ public class McpQuotaServiceTests
var repo = UsageRepo(("20260707", 1));
var svc = Build(repo);
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
var d = await svc.AdmitAsync(Oid, Upn, tenantId: null);
Assert.True(d.Allowed);
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
@@ -551,8 +626,8 @@ public class McpQuotaServiceTests
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
var second = await svc.CheckAsync(Oid, Upn, TenantId);
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
Assert.True(second.Allowed);
@@ -573,7 +648,7 @@ public class McpQuotaServiceTests
// Pro home with 3 purchased slots: 1000 + 3 × 300 per account, 3000 + 3 × 900 for the organization.
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 3);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1900, d.DailyLimit);
Assert.Equal(38000, d.MonthlyLimit);
@@ -592,7 +667,7 @@ public class McpQuotaServiceTests
// A Pro tenant on its two included slots (purchased = 0) keeps the plain Pro windows.
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 0);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(3000, d.TenantDailyLimit);
@@ -607,7 +682,7 @@ public class McpQuotaServiceTests
var conferred = new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false);
var svc = Build(UsageRepo(("20260707", 1)), resolutionResolver: _ => conferred, purchasedSlotsResolver: _ => 3);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("pro", d.Plan);
Assert.Equal(1000, d.DailyLimit);
@@ -625,7 +700,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro,
mcpUserPlanOverride: "power", purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("power", d.Plan);
Assert.Equal(10600, d.DailyLimit);
@@ -639,7 +714,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":0,"slotMonthlyRequestLimit":0,"slotTenantDailyRequestLimit":0,"slotTenantMonthlyRequestLimit":0,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 5);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(3000, d.TenantDailyLimit);
@@ -653,7 +728,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":50,"slotMonthlyRequestLimit":500,"slotTenantDailyRequestLimit":100,"slotTenantMonthlyRequestLimit":1000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1100, d.DailyLimit);
Assert.Equal(21000, d.MonthlyLimit);
@@ -668,7 +743,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1300, d.DailyLimit);
Assert.Equal(3900, d.TenantDailyLimit);
@@ -682,7 +757,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":0,"monthlyRequestLimit":20000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":60000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 30000)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 4);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(0, d.DailyLimit);
@@ -705,10 +780,10 @@ public class McpQuotaServiceTests
{
// Pro 1000 + 1 × 300 = 1300: the 1300th request of the day is the first blocked one.
var below = Build(UsageRepo(("20260707", 1299)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
Assert.True((await below.CheckAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await below.AdmitAsync(Oid, Upn, TenantId)).Allowed);
var at = Build(UsageRepo(("20260707", 1300)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
var d = await at.CheckAsync(Oid, Upn, TenantId);
var d = await at.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("daily", d.Scope);
Assert.Equal("user", d.Level);
@@ -726,8 +801,8 @@ public class McpQuotaServiceTests
.ReturnsAsync(new List<UserUsageRecord>());
var svc = Build(repo);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId);
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
@@ -0,0 +1,323 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The pure fold behind GET global/metrics/mcp-usage/overview: which numbers come from the quota counters and
/// which from the telemetry, how platform admins drop out (but never out of a tenant's real organization
/// windows), what a call costs, MCP-only tools, the daily trend and the per-minute ranking.
/// </summary>
public class McpUsageOverviewAssemblyTests
{
private const string TenantA = "aaaaaaaa-0000-0000-0000-00000000000a";
private const string TenantB = "bbbbbbbb-0000-0000-0000-00000000000b";
private const string Alice = "alice@contoso.com";
private const string App = "app:11111111-2222-3333-4444-555555555555";
private const string Operator = "ops@fabrikam.com";
private const string Bob = "bob@fabrikam.com";
private const string Carol = "carol@contoso.com";
// Window 3 days (Nov 3–5); the month started Nov 1, so Nov 1 rows count for the quota, not for the window.
private static readonly Dates ThreeDays = new("20261103", "20261105", "20261101");
private static readonly DateTime At = new(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
private static TenantUsageRecord Counter(string tenant, string oid, string upn, string day, long requests)
=> new() { TenantId = tenant, UserId = oid, UserPrincipalName = upn, Date = day, RequestCount = requests, LastRequestAt = At };
private static readonly TenantUsageRecord[] Counters =
{
Counter(TenantA, "oid-alice", Alice, "20261101", 10),
Counter(TenantA, "oid-alice", Alice, "20261104", 20),
Counter(TenantA, "oid-alice", Alice, "20261105", 30),
Counter(TenantA, "oid-app", App, "20261105", 5),
Counter(TenantA, "oid-ops", Operator, "20261105", 100),
Counter(TenantB, "oid-bob", Bob, "20261103", 7),
};
private static CallRow Call(string day, string tool, string tenant, string principal, long calls, long served, long requests, string role)
=> new(day, tool, tenant, principal, calls, served, requests, At, role);
private static readonly CallRow[] Calls =
{
Call("20261104", "get_session_summary", TenantA, Alice, 2, 2, 10, "Admin"),
Call("20261105", "search_events", TenantA, Alice, 1, 1, 14, "Admin"),
Call("20261105", "get_session_events", TenantA, Alice, 3, 3, 3, "Admin"),
Call("20261105", "list_tenants", TenantA, Alice, 1, 1, 1, "Admin"),
Call("20261105", "get_session_summary", TenantA, App, 1, 1, 5, "Viewer"),
Call("20261105", "query_table", TenantA, Operator, 50, 50, 50, "GlobalAdmin"),
Call("20261103", "search_sessions", TenantB, Bob, 2, 2, 7, "Admin"),
Call("20261105", "get_time_attribution", TenantA, Carol, 4, 0, 0, "Admin"),
};
private static McpQuotaDecision State(string plan, int daily, int monthly, long dailyUsed, long monthlyUsed)
=> new() { Allowed = true, Plan = plan, DailyLimit = daily, MonthlyLimit = monthly, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed };
private static TelemetryResult Telemetry(bool available = true) => !available
? new TelemetryResult(false, false, false, new(), new(), new(), new(), new(), new(), new ServerFacts(null, null))
: new TelemetryResult(
RequestsAvailable: true, McpLogAvailable: true, Partial: false,
Calls: Calls.ToList(),
Routes: new()
{
new("get_session_summary", "GET sessions/{id}/events", 3),
new("get_session_summary", "GET sessions/{id}", 3),
},
Refusals: new()
{
new(RefusalQuota, "20261105", TenantA, Alice, "user", "daily", "community", 3, At, At.AddSeconds(9), 50, 50),
new(RefusalMinute, "20261103", TenantB, Bob, "", "", "", 2, At, At, 120, 120),
},
Peaks: new()
{
new(PeakCalls, TenantA, Alice, 12, At),
new(PeakRequests, TenantA, Alice, 40, At),
new(PeakRequests, TenantB, Bob, 120, At),
new(PeakCalls, TenantB, Bob, 5, At),
},
ToolQuality: new()
{
new("get_session_summary", 3, 0, 0, 0, 600, 19000.4),
new("search_docs", 4, 1, 0, 0, 80, 5000),
},
McpRefusals: new() { new(Alice, 7, At) },
ServerFacts: new ServerFacts(new DateTime(2026, 11, 4, 1, 0, 0, DateTimeKind.Utc), 60));
private static GetGlobalMcpUsageOverviewResponse Overview(bool includePlatformAdmins = false, bool telemetry = true)
{
var result = Telemetry(telemetry);
var facts = new Dictionary<(string Tenant, string Principal), AccountFacts>
{
[(TenantA, Alice)] = new("oid-alice", null, State("community", 50, 250, 30, 60)),
[(TenantA, App)] = new("oid-app", null, State("community", 50, 250, 5, 5)),
[(TenantA, Operator)] = new("oid-ops", "GlobalAdmin", State("community", 50, 250, 100, 100)),
[(TenantB, Bob)] = new("oid-bob", null, State("pro", 500, 6000, 0, 7)),
};
var limits = new Dictionary<string, McpQuotaService.TenantPlanLimits>
{
[TenantA] = new("community", 100, 250),
[TenantB] = new("pro", 1500, 15000),
};
// TenantB has no readable configuration: no name.
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
}
[Fact]
public void PlatformAdmins_DropOutOfEveryNumber_ButNotOutOfTheOrganizationWindows()
{
var overview = Overview();
var tenantA = overview.Tenants.Single(t => t.TenantId == TenantA);
Assert.DoesNotContain(tenantA.Accounts, a => a.Principal == Operator);
Assert.DoesNotContain(overview.Tools, t => t.Tool == "query_table");
Assert.Equal(55, tenantA.Requests); // alice 20 + 30, app 5 — the operator's 100 left out
// The organization windows stay the tenant's real counters: today 30 + 5 + 100, the month 10 + 20 + 30 + 5 + 100.
Assert.Equal(135, tenantA.Quota.DailyUsed);
Assert.Equal(165, tenantA.Quota.MonthlyUsed);
Assert.Equal("community", tenantA.Quota.TenantPlan);
}
[Fact]
public void PlatformAdmins_WhenIncluded_AreMarked()
{
var tenantA = Overview(includePlatformAdmins: true).Tenants.Single(t => t.TenantId == TenantA);
var op = tenantA.Accounts.Single(a => a.Principal == Operator);
Assert.True(op.IsPlatformAdmin);
Assert.Equal("GlobalAdmin", op.Role);
Assert.Equal(155, tenantA.Requests);
}
[Fact]
public void Accounts_RequestsComeFromTheCounters_CallsFromTheTelemetry()
{
var tenants = Overview().Tenants;
Assert.Equal(new[] { TenantA, TenantB }, tenants.Select(t => t.TenantId)); // most calls first
var accounts = tenants[0].Accounts;
Assert.Equal(new[] { Alice, Carol, App }, accounts.Select(a => a.Principal));
var alice = accounts[0];
Assert.Equal("oid-alice", alice.UserId);
Assert.Equal(7, alice.Calls);
Assert.Equal(50, alice.Requests); // Nov 1 lies before the window
Assert.Equal("20261105", alice.PeakDay);
Assert.Equal(30, alice.PeakDayRequests);
Assert.Equal(1, alice.QuotaHits);
Assert.Equal("Admin", alice.Role);
Assert.Equal(new[] { ("get_session_events", 3L), ("get_session_summary", 2L), ("list_tenants", 1L) },
alice.TopTools.Select(t => (t.Tool, t.Calls)));
Assert.Equal(("community", 50, 250, 30L, 60L),
(alice.Quota!.Plan, alice.Quota.DailyLimit, alice.Quota.MonthlyLimit, alice.Quota.DailyUsed, alice.Quota.MonthlyUsed));
}
[Fact]
public void AnAccountKnownOnlyFromTheTelemetry_HasNoObjectIdAndNoQuota()
{
// Every request refused: no counter row, but the calls happened.
var carol = Overview().Tenants.Single(t => t.TenantId == TenantA).Accounts.Single(a => a.Principal == Carol);
Assert.Null(carol.UserId);
Assert.Null(carol.Quota);
Assert.Equal(4, carol.Calls);
Assert.Equal(0, carol.Requests);
Assert.Null(carol.PeakDay);
}
[Fact]
public void Tools_RankByCalls_CostPerServedCall_AndMcpOnlyToolsFromTheLog()
{
var tools = Overview().Tools;
Assert.Equal(
new[] { "get_time_attribution", "search_docs", "get_session_events", "get_session_summary", "search_sessions", "list_tenants", "search_events" },
tools.Select(t => t.Tool));
var summary = tools.Single(t => t.Tool == "get_session_summary");
Assert.Equal(3, summary.Calls);
Assert.Equal(2, summary.Accounts);
Assert.Equal(1, summary.Tenants);
Assert.Equal(5.0, summary.RequestsPerCall); // (10 + 5) requests over 3 served calls
Assert.Equal(0.0, summary.ErrorRate);
Assert.Equal(600, summary.P95DurationMs);
Assert.Equal(19000, summary.AvgResultChars);
Assert.Equal(new[] { "GET sessions/{id}", "GET sessions/{id}/events" }, summary.Routes.Select(r => r.Route));
var docs = tools.Single(t => t.Tool == "search_docs");
Assert.Equal(4, docs.Calls);
Assert.Null(docs.Accounts);
Assert.Null(docs.Tenants);
Assert.Equal(0, docs.RequestsPerCall);
Assert.Equal(0.25, docs.ErrorRate);
// Every call refused: no served call, no cost to report.
Assert.Equal(0, tools.Single(t => t.Tool == "get_time_attribution").RequestsPerCall);
}
[Fact]
public void Daily_HasEveryDayOfTheWindow_WithCallsByPlan()
{
var daily = Overview().Daily;
Assert.Equal(new[] { "20261103", "20261104", "20261105" }, daily.Select(d => d.Date));
var today = daily[2];
Assert.Equal(10, today.Calls); // alice 5, app 1, carol 4 — the operator's 50 left out
Assert.Equal(35, today.Requests); // alice 30, app 5
Assert.Equal(3, today.Accounts);
Assert.Equal(new[] { ("community", 10L) }, today.CallsByPlan.Select(p => (p.Plan, p.Calls)));
Assert.Equal(new[] { ("pro", 2L) }, daily[0].CallsByPlan.Select(p => (p.Plan, p.Calls)));
}
[Fact]
public void QuotaHits_AndMinuteLimits_RankTheAccountsClosestToALimit()
{
var overview = Overview();
var hit = Assert.Single(overview.QuotaHits);
Assert.Equal((Alice, "user", "daily", 50L, 50L, 3L), (hit.Principal, hit.Level, hit.Scope, hit.Limit, hit.Used, hit.Refused));
Assert.Equal(60, overview.MinuteLimits.McpServerLimit);
var minute = overview.MinuteLimits.Accounts;
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
}
[Fact]
public void Totals_SumTheLists_OnceForEveryConsumer()
{
var totals = Overview().Totals;
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
Assert.Equal(4, totals.McpOnlyCalls);
Assert.Equal(7, totals.Tools);
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
Assert.Equal(2, totals.Tenants);
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
Assert.Equal(1, totals.ServicePrincipals);
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
Assert.Equal(2, totals.LimitAccounts);
}
[Fact]
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
{
var totals = Overview(telemetry: false).Totals;
Assert.Null(totals.Calls);
Assert.Null(totals.McpOnlyCalls);
Assert.Equal(0, totals.Tools);
Assert.Equal(62, totals.Requests); // the counters still answer
Assert.Equal(3, totals.Accounts);
}
[Fact]
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
{
var tenants = Overview().Tenants;
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
}
[Fact]
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
{
var config = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
// Only the configuration read is exercised; the other collaborators stay unused.
var service = new McpUsageOverviewService(
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
}
[Theory]
[InlineData(null, null, 30, false, null)]
[InlineData("7", "include", 7, true, null)]
[InlineData("90", "exclude", 90, false, null)]
[InlineData("91", null, 0, false, "days must be between 1 and 90")]
[InlineData("0", null, 0, false, "days must be between 1 and 90")]
[InlineData("-5", null, 0, false, "days must be between 1 and 90")]
[InlineData("7.5", null, 0, false, "days must be an integer")]
[InlineData("30", "all", 0, false, "platformAdmins must be include or exclude")]
public void OverviewQuery_DefaultsAndBounds(string? days, string? platformAdmins, int expectedDays, bool expectedInclude, string? expectedError)
{
var parsed = AutopilotMonitor.Functions.Functions.Metrics.McpUsageMetricsFunction.ParseOverviewQuery(days, platformAdmins);
Assert.Equal((expectedDays, expectedInclude, expectedError), parsed);
}
[Fact]
public void Status_NamesTheMcpLogCoverage_AndATelemetryOutageEmptiesOnlyItsParts()
{
Assert.Equal("20261104", Overview().Telemetry.McpLogFrom);
var dark = Overview(telemetry: false);
Assert.False(dark.Telemetry.RequestsAvailable);
Assert.False(dark.Telemetry.McpLogAvailable);
Assert.Empty(dark.Tools);
Assert.Empty(dark.QuotaHits);
// The counters still carry tenants, accounts, requests and the quota.
var tenantA = dark.Tenants.Single(t => t.TenantId == TenantA);
Assert.Equal(new[] { Alice, App }, tenantA.Accounts.Select(a => a.Principal).OrderBy(p => p, StringComparer.Ordinal));
Assert.Equal(0, tenantA.Calls);
Assert.Equal(55, tenantA.Requests);
}
}
@@ -0,0 +1,203 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The fixed KQL of the MCP usage overview: which rows it reads (the worker's request row, MCP only, no access
/// probe), that it counts calls exactly, leaves platform admins out unless asked, matches log lines by their
/// start, reads only fields the MCP server writes (tests/fixtures/mcp-log-lines, proven on the MCP side by
/// usage-log-fields.test.ts) — and that the parsers read the result tables by column name.
/// </summary>
public class McpUsageTelemetryQueriesTests
{
private static readonly Window Customers = new(new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 11, 6, 0, 0, 0, DateTimeKind.Utc), IncludePlatformAdmins: false);
private static readonly Window Everyone = Customers with { IncludePlatformAdmins = true };
private static IEnumerable<string> RequestQueries(Window w) => new[] { Calls(w), Routes(w), Refusals(w), Peaks(w) };
private static IEnumerable<string> McpLogQueries(Window w) => new[] { ToolQuality(w), McpRefusals(w), McpServerFacts(w) };
[Fact]
public void Window_CoversWholeUtcDays()
{
Assert.Equal("datetime(2026-10-07T00:00:00Z)", Customers.From);
Assert.Equal("datetime(2026-11-06T00:00:00Z)", Customers.To);
Assert.Equal("P31D", Customers.Timespan); // the where clauses bound the window; the timespan only has to cover it
foreach (var query in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
{
Assert.Contains(">= datetime(2026-10-07T00:00:00Z)", query);
Assert.Contains("< datetime(2026-11-06T00:00:00Z)", query);
}
}
[Fact]
public void RequestQueries_ReadTheWorkerRow_McpOnly_WithoutTheAccessProbe()
{
foreach (var query in RequestQueries(Customers))
{
Assert.Contains("customDimensions.Source) == 'WorkerMiddleware'", query);
Assert.Contains("customDimensions.ClientSource) =~ 'mcp'", query);
Assert.Contains("customDimensions.FunctionName) != 'GlobalAccessProbe'", query);
Assert.Contains("customDimensions.McpToolName", query);
Assert.Contains("customDimensions.CorrelationId", query);
Assert.Contains("tolower(tostring(customDimensions.TenantId))", query);
Assert.Contains("tolower(tostring(customDimensions.UserId))", query);
}
}
[Fact]
public void Calls_AreCountedOncePerCorrelationId_NeverEstimated()
{
var query = Calls(Customers);
Assert.Contains("by corr, tool, tenant, principal", query);
Assert.Contains("calls = count()", query);
foreach (var q in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
Assert.DoesNotContain("dcount(", q);
}
[Fact]
public void PlatformAdmins_AreLeftOut_UnlessIncluded()
{
foreach (var query in RequestQueries(Customers))
Assert.Contains("role !in ('GlobalAdmin', 'GlobalReader')", query);
Assert.Contains("tostring(d.scope) != 'ga'", ToolQuality(Customers));
foreach (var query in RequestQueries(Everyone).Concat(McpLogQueries(Everyone)))
{
Assert.DoesNotContain("'GlobalAdmin'", query);
Assert.DoesNotContain("!= 'ga'", query);
}
}
[Fact]
public void Refusals_AreClassifiedByTheLinesTheMiddlewaresWrite()
{
var query = Refusals(Customers);
Assert.Contains($"startswith '{McpQuotaEnforcementMiddleware.BlockedLogPrefix}'", query);
Assert.Contains($"startswith '{UserRateLimitMiddleware.ThrottledLogPrefix}'", query);
Assert.Equal("[McpQuota] BLOCKED", McpQuotaEnforcementMiddleware.BlockedLogPrefix);
Assert.Equal("[UserRateLimit] THROTTLED", UserRateLimitMiddleware.ThrottledLogPrefix);
Assert.Contains("join kind=inner lines on operation_Id", query);
}
[Fact]
public void Peaks_SlideASixtySecondWindowInTenSecondSteps()
{
var query = Peaks(Customers);
Assert.Contains("bin(timestamp, 10s)", query);
Assert.Contains("bin(at, 10s)", query);
Assert.Equal(2, Regex.Matches(query, Regex.Escape("mv-expand k = range(0, 5, 1)")).Count);
}
[Fact]
public void McpLogLines_AreMatchedByTheirStart_NeverByHas()
{
Assert.Contains($"Log_s startswith '{ToolCallLinePrefix}'", ToolQuality(Customers));
Assert.Contains($"Log_s startswith '{McpRefusalLinePrefix}'", McpRefusals(Customers));
Assert.Contains($"Log_s startswith '{BootLinePrefix}'", McpServerFacts(Customers));
foreach (var query in McpLogQueries(Customers))
Assert.DoesNotContain("Log_s has", query);
}
[Fact]
public void McpLogQueries_ReadOnlyWhatTheServerWrites()
{
using var contract = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "mcp-log-lines", "fields.json")));
var prefixes = contract.RootElement.GetProperty("prefixes");
var fields = contract.RootElement.GetProperty("fields");
Assert.Equal(ToolCallLinePrefix, prefixes.GetProperty("tool_call").GetString());
Assert.Equal(BootLinePrefix, prefixes.GetProperty("boot").GetString());
Assert.Equal(McpRefusalLinePrefix, prefixes.GetProperty("mcp_refusal").GetString());
var toolCallFields = fields.GetProperty("tool_call").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
var readFromToolCall = Regex.Matches(ToolQuality(Everyone), @"\bd\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
Assert.NotEmpty(readFromToolCall);
Assert.Subset(toolCallFields, readFromToolCall);
var bootFields = fields.GetProperty("boot").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
var readFromBoot = Regex.Matches(McpServerFacts(Everyone), @"parse_json\(Log_s\)\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
Assert.Equal(new[] { "rateLimitPerMinute" }, readFromBoot);
Assert.Subset(bootFields, readFromBoot);
}
// ── Parsers ──
private static KqlTable Table(string[] columns, params object?[][] rows)
=> new()
{
Name = "PrimaryResult",
Columns = columns.Select(c => new KqlColumn { Name = c, Type = "dynamic" }).ToList(),
Rows = rows.Select(r => (IReadOnlyList<JsonElement>)r.Select(cell => JsonSerializer.SerializeToElement(cell)).ToList()).ToList(),
};
[Fact]
public void ParseCalls_ReadsByColumnName_WhateverTheOrder()
{
var table = Table(
new[] { "role", "charged", "day", "principal", "tool", "calls", "tenant", "servedCalls", "firstAt" },
new object?[] { "Admin", 59L, "20261028", "user@contoso.com", "get_time_attribution", 64L, "t1", 59L, "2026-10-28T16:12:30.4442709Z" });
var row = Assert.Single(ParseCalls(table));
Assert.Equal(new CallRow("20261028", "get_time_attribution", "t1", "user@contoso.com", 64, 59, 59,
new DateTime(2026, 10, 28, 16, 12, 30, 444, DateTimeKind.Utc).AddTicks(2709), "Admin"), row);
Assert.Equal(DateTimeKind.Utc, row.LastAt!.Value.Kind);
}
[Fact]
public void ParseRefusals_KeepsMissingCountsMissing()
{
var table = Table(
new[] { "gate", "day", "tenant", "principal", "level", "scope", "plan", "refused", "firstAt", "lastAt", "usedCount", "limitValue" },
new object?[] { "minute", "20261026", "t1", "user@contoso.com", "", "", "", 2L, "2026-10-26T07:55:42Z", "2026-10-26T07:55:43Z", null, null });
var row = Assert.Single(ParseRefusals(table));
Assert.Equal("minute", row.Gate);
Assert.Equal(2, row.Refused);
Assert.Null(row.Used);
Assert.Null(row.Limit);
}
[Fact]
public void ParseToolQuality_RoundsAFractionalCount_AndKeepsTheMean()
{
var table = Table(
new[] { "tool", "calls", "errors", "cancelled", "overCap", "p95Ms", "avgChars" },
new object?[] { "search_events", 26L, 1L, 0L, 0L, 1029.6, 10602.5 });
var row = Assert.Single(ParseToolQuality(table));
Assert.Equal(26, row.Calls);
Assert.Equal(1030, row.P95Ms);
Assert.Equal(10602.5, row.AvgChars);
}
[Fact]
public void ParseServerFacts_WithoutABootLine_HasNoLimit()
{
var facts = ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" }, new object?[] { "2026-10-07T01:52:43.167992Z", null }));
Assert.Equal(new DateTime(2026, 10, 7, 1, 52, 43, DateTimeKind.Utc).AddTicks(1_679_920), facts.Oldest);
Assert.Null(facts.RateLimitPerMinute);
Assert.Equal(new ServerFacts(null, null), ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" })));
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
}
[Fact]
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
{
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
}
[Fact]
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
{
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
}
}
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
new GetMcpUserUsageResponse { UserId = userId, Records = records });
}
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
}
[Fact]
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var records = new List<UserUsageRecord>();
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
}
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var summaries = new List<UserUsageDailySummary>
{
new UserUsageDailySummary
{
Date = "20260830",
TenantId = tenantId,
TotalRequests = 321,
UniqueUsers = 4,
UniqueEndpoints = 11,
},
};
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
}
[Fact]
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var summaries = new List<UserUsageDailySummary>();
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
}
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
[Fact]
@@ -832,6 +774,250 @@ public class MetricsWireParityTests
});
}
// ---- GetGlobalMcpUsageOverview ------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageOverviewResponse_matches_the_overview_shape()
{
// One tenant with one account, one tool with one route, one quota refusal, one per-minute row — every
// optional slot filled, so every key is pinned in its wire position.
var at = new DateTime(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 35, MonthlyUsed = 60 };
AssertParity(
new
{
dateFrom = "20261103",
dateTo = "20261105",
includesPlatformAdmins = false,
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
totals = new
{
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
},
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
tenants = new[]
{
new
{
tenantId,
domainName = "contoso.com",
quota,
calls = 7L,
requests = 50L,
quotaHits = 1,
lastRequestAt = at,
accounts = new[]
{
new
{
userId = "oid-1",
principal = "admin@contoso.com",
role = "Admin",
isPlatformAdmin = false,
quota = new { plan = "community", dailyLimit = 50, monthlyLimit = 250, dailyUsed = 30L, monthlyUsed = 60L },
calls = 7L,
requests = 50L,
peakDay = "20261105",
peakDayRequests = 30L,
quotaHits = 1,
topTools = new[] { new { tool = "get_session_events", calls = 3L } },
lastRequestAt = at,
},
},
},
},
tools = new[]
{
new
{
tool = "get_session_summary",
calls = 3L,
accounts = 2,
tenants = 1,
requestsPerCall = 5.0,
errorRate = 0.0,
p95DurationMs = 600L,
avgResultChars = 19000L,
overCapCalls = 0L,
routes = new[] { new { route = "GET sessions/{id}", requests = 3L } },
},
},
quotaHits = new[]
{
new
{
date = "20261105", tenantId, principal = "admin@contoso.com", level = "user", scope = "daily", plan = "community",
limit = 50L, used = 50L, refused = 3L, firstAt = at, lastAt = at,
},
},
minuteLimits = new
{
mcpServerLimit = 60,
accounts = new[]
{
new
{
tenantId, principal = "admin@contoso.com", sharePercent = 33,
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
},
},
},
},
new GetGlobalMcpUsageOverviewResponse
{
DateFrom = "20261103",
DateTo = "20261105",
IncludesPlatformAdmins = false,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
Totals = new McpUsageTotalsNode
{
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
},
Daily = new List<McpUsageDayItem>
{
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
},
Tenants = new List<McpUsageTenantItem>
{
new()
{
TenantId = tenantId,
DomainName = "contoso.com",
Quota = quota,
Calls = 7,
Requests = 50,
QuotaHits = 1,
LastRequestAt = at,
Accounts = new List<McpUsageAccountItem>
{
new()
{
UserId = "oid-1",
Principal = "admin@contoso.com",
Role = "Admin",
IsPlatformAdmin = false,
Quota = new McpUsageAccountQuotaNode { Plan = "community", DailyLimit = 50, MonthlyLimit = 250, DailyUsed = 30, MonthlyUsed = 60 },
Calls = 7,
Requests = 50,
PeakDay = "20261105",
PeakDayRequests = 30,
QuotaHits = 1,
TopTools = new List<McpUsageToolCallsItem> { new() { Tool = "get_session_events", Calls = 3 } },
LastRequestAt = at,
},
},
},
},
Tools = new List<McpUsageToolItem>
{
new()
{
Tool = "get_session_summary",
Calls = 3,
Accounts = 2,
Tenants = 1,
RequestsPerCall = 5.0,
ErrorRate = 0.0,
P95DurationMs = 600,
AvgResultChars = 19000,
OverCapCalls = 0,
Routes = new List<McpUsageRouteItem> { new() { Route = "GET sessions/{id}", Requests = 3 } },
},
},
QuotaHits = new List<McpUsageQuotaHitItem>
{
new()
{
Date = "20261105", TenantId = tenantId, Principal = "admin@contoso.com", Level = "user", Scope = "daily", Plan = "community",
Limit = 50, Used = 50, Refused = 3, FirstAt = at, LastAt = at,
},
},
MinuteLimits = new McpUsageMinuteLimits
{
McpServerLimit = 60,
Accounts = new List<McpUsageMinuteItem>
{
new()
{
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
},
},
},
});
}
[Fact]
public void GetGlobalMcpUsageOverviewResponse_omits_what_a_store_did_not_answer()
{
// Telemetry down, an account known only from the telemetry shape (no object id, no quota), an MCP-only
// tool (no reach, no quality), no MCP limit reported yet: every nullable key vanishes.
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 0, MonthlyUsed = 0 };
AssertParity(
new
{
dateFrom = "20261105",
dateTo = "20261105",
includesPlatformAdmins = true,
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
daily = Array.Empty<object>(),
tenants = new[]
{
new
{
tenantId,
quota,
calls = 4L,
requests = 0L,
quotaHits = 0,
accounts = new[]
{
new { principal = "app:11111111-2222-3333-4444-555555555555", isPlatformAdmin = false, calls = 4L, requests = 0L, quotaHits = 0, topTools = Array.Empty<object>() },
},
},
},
tools = new[] { new { tool = "search_docs", calls = 4L, requestsPerCall = 0.0, routes = Array.Empty<object>() } },
quotaHits = Array.Empty<object>(),
minuteLimits = new { accounts = Array.Empty<object>() },
},
new GetGlobalMcpUsageOverviewResponse
{
DateFrom = "20261105",
DateTo = "20261105",
IncludesPlatformAdmins = true,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
Daily = new List<McpUsageDayItem>(),
Tenants = new List<McpUsageTenantItem>
{
new()
{
TenantId = tenantId,
Quota = quota,
Calls = 4,
Requests = 0,
QuotaHits = 0,
Accounts = new List<McpUsageAccountItem>
{
new() { Principal = "app:11111111-2222-3333-4444-555555555555", Calls = 4, TopTools = new List<McpUsageToolCallsItem>() },
},
},
},
Tools = new List<McpUsageToolItem> { new() { Tool = "search_docs", Calls = 4, RequestsPerCall = 0, Routes = new List<McpUsageRouteItem>() } },
QuotaHits = new List<McpUsageQuotaHitItem>(),
MinuteLimits = new McpUsageMinuteLimits { Accounts = new List<McpUsageMinuteItem>() },
});
}
// ---- GetGrowthMetrics ---------------------------------------------------------------
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
@@ -962,6 +1148,177 @@ public class MetricsWireParityTests
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
}
// ---------- GET global/metrics/tenant-activity ----------
private const string ActivityTenantId = "8bb31d22-0004-4c8d-b2e3-63f4bbbb0001";
private static readonly DateTime ActivityOnboardedAt = new(2026, 6, 30, 9, 0, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityCounterAt = new(2026, 11, 6, 7, 30, 0, DateTimeKind.Utc);
private static readonly DateTime ActivityVisitAt = new(2026, 11, 6, 8, 15, 0, DateTimeKind.Utc);
private static GetTenantActivityResponse TenantActivityResponse(bool full) => new()
{
WindowDays = 30,
WindowStart = "2026-10-07",
UsageDaysThrough = "2026-11-05",
PreviousWindowStart = "2026-09-07",
RowsFrom = "2026-05-10",
PortalActivityAvailable = full,
Totals = new TenantActivityTotals
{
SignedUpTenants = 2,
ActiveTenants = 1,
ActiveTenantsPrevious = 1,
ActiveTenantsTrendPercent = full ? 0 : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
EnrollmentsTrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
Top10SharePercent = full ? 100 : null,
Stages = new TenantActivityStageCounts { NeverEnrolled = 1, Active = 1 },
PortalUsers = full ? 3 : null,
PortalTenants = full ? 1 : null,
},
Daily = new List<TenantActivityDailyItem>
{
new() { Day = "2026-11-05", Enrollments = 6, Succeeded = 5, Failed = 1, ActiveTenants = 1 },
},
Tenants = new List<TenantActivityTenantItem>
{
new()
{
TenantId = ActivityTenantId,
DomainName = "contoso.example",
CompanyName = full ? "Contoso" : null,
Edition = TenantEditionNames.ProTrial,
Stage = TenantActivityStages.Active,
OnboardedAt = full ? ActivityOnboardedAt : null,
Enrollments = 6,
EnrollmentsPrevious = 4,
TrendPercent = full ? 50 : null,
Succeeded = 5,
Failed = 1,
SuccessPercent = full ? 83 : null,
ActiveDays = 1,
DailyEnrollments = full ? new[] { 6 } : null,
FirstEnrollmentDay = full ? "2026-07-01" : null,
LastActiveDay = full ? "2026-11-05" : null,
LastCounterUpdateAt = full ? ActivityCounterAt : null,
LifetimeEnrollments = 120,
LifetimeSucceeded = 100,
LifetimeEvents = 50_000,
PortalUsers = full ? 3 : null,
LastPortalVisitAt = full ? ActivityVisitAt : null,
},
},
};
[Fact]
public void GetTenantActivityResponse_matches_the_shape_with_every_optional_key()
{
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = true,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
activeTenantsTrendPercent = 0,
enrollments = 6,
enrollmentsPrevious = 4,
enrollmentsTrendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
top10SharePercent = 100,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
portalUsers = 3,
portalTenants = 1,
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
companyName = "Contoso",
edition = "pro-trial",
stage = "active",
onboardedAt = ActivityOnboardedAt,
enrollments = 6,
enrollmentsPrevious = 4,
trendPercent = 50,
succeeded = 5,
failed = 1,
successPercent = 83,
activeDays = 1,
dailyEnrollments = new[] { 6 },
firstEnrollmentDay = "2026-07-01",
lastActiveDay = "2026-11-05",
lastCounterUpdateAt = ActivityCounterAt,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
portalUsers = 3,
lastPortalVisitAt = ActivityVisitAt,
},
},
}, TenantActivityResponse(full: true));
}
[Fact]
public void GetTenantActivityResponse_omits_every_absent_figure()
{
// No sign-in history, no previous window, no finished session, no names beyond the domain.
AssertParity(new
{
windowDays = 30,
windowStart = "2026-10-07",
usageDaysThrough = "2026-11-05",
previousWindowStart = "2026-09-07",
rowsFrom = "2026-05-10",
portalActivityAvailable = false,
totals = new
{
signedUpTenants = 2,
activeTenants = 1,
activeTenantsPrevious = 1,
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
},
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
tenants = new[]
{
new
{
tenantId = ActivityTenantId,
domainName = "contoso.example",
edition = "pro-trial",
stage = "active",
enrollments = 6,
enrollmentsPrevious = 4,
succeeded = 5,
failed = 1,
activeDays = 1,
lifetimeEnrollments = 120L,
lifetimeSucceeded = 100L,
lifetimeEvents = 50_000L,
},
},
}, TenantActivityResponse(full: false));
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Contains Newtonsoft.Json (D-420): new code reads and writes JSON with System.Text.Json. Every shipped
/// source file under <c>src/</c> that uses Newtonsoft today is on a frozen baseline that may only shrink,
/// and only the projects in <see cref="ProjectsWithPackage"/> may reference the package. Test projects are
/// out of scope: they exercise Newtonsoft-bound code and never ship.
/// A file uses Newtonsoft when a code line names the <c>Newtonsoft.Json</c> namespace (using, alias,
/// fully qualified name, or the DLL name); comment lines do not count.
/// </summary>
public class NewtonsoftUsageGuardTests
{
private const string AgentWireAndState = "agent (net48): wire format and on-device state, which move only with an agent release";
private const string DecisionCore = "DecisionCore serialization shared by the agent and its tests";
private const string SummaryDialog = "summary dialog (net48): reads the agent's final status and checks the DLL ships beside it";
private const string Ingest = "agent ingest wire (D-200)";
private const string ConfigRules = "Newtonsoft-bound configuration and rule documents (PopulateObject)";
private const string QueueEnvelope = "queue envelope contract shared by producers and consumers";
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
private const string OutgoingPayload = "outgoing notification payloads";
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
{
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BuiltInConfigCatalog.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EventRecordWatermark.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/MdmRebootPolicyTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OsBuildChangeHost.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Persistence/StartupEventGate.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs"] = AgentWireAndState,
["Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs"] = DecisionCore,
["Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs"] = SummaryDialog,
["Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/EventDataNormalizer.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Ime/ImePatternHealthService.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityInventoryLoader.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Helpers/RequestBody.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInGatherRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInImeLogPatterns.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/RulesWrapper.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/Activation/AzureQueueTenantAutoApproveEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/AzureQueueCriticalTableBackupProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/CriticalTableBackupQueueWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/AzureQueueSessionDeletionMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Ime/AzureQueueImeMsiArchiveProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/AzureQueueTenantOffboardingEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueueEnvelopeReader.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueuePollingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/AzureQueueVulnerabilityCorrelateProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.ServerActions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelateHandler.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/SlackRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
};
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
private static readonly string[] ProjectsWithPackage =
{
"Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj",
"Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj",
"Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj",
"Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj",
"Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj",
};
private static readonly Regex NamespaceUse = new(@"\bNewtonsoft\.Json\b", RegexOptions.Compiled);
private static readonly Regex PackageReference = new(
@"<PackageReference\s+Include\s*=\s*""Newtonsoft\.Json""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
[Fact]
public void Newtonsoft_use_is_frozen_per_file()
{
var actual = ShippedSources()
.Where(s => UsesNewtonsoft(s.Text))
.Select(s => s.Relative)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = new List<string>();
foreach (var file in actual.OrderBy(f => f, StringComparer.Ordinal))
{
if (!Baseline.ContainsKey(file))
violations.Add($"{file}: NEW use of Newtonsoft.Json — read and write JSON with System.Text.Json; the Newtonsoft baseline is frozen (D-420).");
}
foreach (var file in Baseline.Keys.OrderBy(f => f, StringComparer.Ordinal))
{
if (!actual.Contains(file))
violations.Add($"{file}: on the baseline but no longer uses Newtonsoft.Json (or is gone) — remove its entry (ratchet down).");
}
Assert.True(violations.Count == 0, "Newtonsoft.Json baseline violated:\n " + string.Join("\n ", violations));
}
[Fact]
public void Newtonsoft_package_only_in_known_projects()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
var actual = SourceFiles(srcRoot, "*.csproj", includeTestProjects: true)
.Where(f => PackageReference.IsMatch(File.ReadAllText(f)))
.Select(f => Relative(srcRoot, f))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = actual.Except(ProjectsWithPackage, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: NEW Newtonsoft.Json package reference — use System.Text.Json (D-420).")
.Concat(ProjectsWithPackage.Except(actual, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: listed but no longer references Newtonsoft.Json — remove it from ProjectsWithPackage (ratchet down)."))
.OrderBy(v => v, StringComparer.Ordinal)
.ToList();
Assert.True(violations.Count == 0, "Newtonsoft.Json package references violated:\n " + string.Join("\n ", violations));
}
[Theory]
[InlineData("using Newtonsoft.Json;", true)]
[InlineData("using Newtonsoft.Json.Linq;", true)]
[InlineData("using JsonConvert = Newtonsoft.Json.JsonConvert;", true)]
[InlineData(" catch (Newtonsoft.Json.JsonException) { }", true)]
[InlineData("var dll = Path.Combine(dir, \"Newtonsoft.Json.dll\");", true)]
[InlineData("var url = \"https://x\"; Newtonsoft.Json.Linq.JObject.Parse(s); // trailing", true)]
[InlineData("var x = 1; // Newtonsoft.Json is not used here", false)]
[InlineData(" /// <summary>Newtonsoft.Json defaults on both sides.</summary>", false)]
[InlineData("// Newtonsoft.Json", false)]
[InlineData(" /* Newtonsoft.Json */", false)]
[InlineData(" * Newtonsoft.Json in a block comment", false)]
[InlineData("var rule = await RequestBody.ReadNewtonsoftAsync<AnalyzeRule>(req);", false)]
public void Use_detector_sees_code_but_not_comments(string line, bool expected)
=> Assert.Equal(expected, UsesNewtonsoft(line));
private static bool UsesNewtonsoft(string text)
{
foreach (var raw in text.Split('\n'))
{
var trimmed = raw.TrimStart();
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("/*", StringComparison.Ordinal) || trimmed.StartsWith('*'))
continue;
var line = raw;
var comment = line.IndexOf("//", StringComparison.Ordinal);
if (comment >= 0 && !line[..comment].Contains('"'))
line = line[..comment];
if (NamespaceUse.IsMatch(line))
return true;
}
return false;
}
private static IEnumerable<(string Relative, string Text)> ShippedSources()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
return SourceFiles(srcRoot, "*.cs", includeTestProjects: false)
.Select(f => (Relative(srcRoot, f), File.ReadAllText(f)));
}
/// <summary>
/// Walks <paramref name="root"/> without entering build output or package folders: the web and MCP
/// node_modules alone would make a plain recursive enumeration take tens of seconds.
/// </summary>
private static IEnumerable<string> SourceFiles(string root, string pattern, bool includeTestProjects)
{
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var dir = pending.Pop();
foreach (var file in Directory.EnumerateFiles(dir, pattern))
yield return file;
foreach (var sub in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(sub);
if (SkippedFolders.Contains(name) || (!includeTestProjects && name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)))
continue;
pending.Push(sub);
}
}
}
private static readonly HashSet<string> SkippedFolders = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".next" };
private static string Relative(string root, string path)
=> Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -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()
{
@@ -27,6 +27,8 @@ public class PlatformStatsStorageTests
{
public Mock<TableClient> Table { get; } = new();
public List<string> Filters { get; } = new();
/// <summary>The projection of every query, in order; null for a query without one.</summary>
public List<string[]?> Selects { get; } = new();
public List<List<TableTransactionAction>> Submitted { get; } = new();
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
public Queue<Exception> SubmitFailures { get; } = new();
@@ -35,9 +37,10 @@ public class PlatformStatsStorageTests
public Harness()
{
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
.Returns((string filter, int? _, IEnumerable<string>? select, CancellationToken _) =>
{
Filters.Add(filter);
Selects.Add(select?.ToArray());
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
return AsyncPageable<TableEntity>.FromPages(new[] { page });
});
@@ -54,6 +57,7 @@ public class PlatformStatsStorageTests
var service = new Mock<TableServiceClient>();
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
service.Setup(s => s.GetTableClient(Constants.TableNames.UserActivity)).Returns(Table.Object);
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
}
}
@@ -281,6 +285,76 @@ public class PlatformStatsStorageTests
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
}
[Fact]
public async Task Tenant_activity_days_project_and_carry_the_success_and_failure_counts()
{
var h = new Harness
{
RowsFor = _ => new[]
{
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 5, ["SessionsSucceeded"] = 3, ["SessionsFailed"] = 1 },
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 2 },
}
};
var days = await h.Sut.GetTenantActivityDaysAsync(
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(new[] { "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" }, Assert.Single(h.Selects));
Assert.Collection(days,
d => Assert.Equal((5, 3, 1), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)),
d => Assert.Equal((2, 0, 0), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)));
}
// ---------- portal visits (tenant activity) ----------
private static TableEntity Login(string tenantId, string upn, string objectId, DateTime? at)
{
var row = new TableEntity(tenantId, Guid.NewGuid().ToString("N")) { ["Upn"] = upn, ["ObjectId"] = objectId };
if (at is { } loginAt) row["LoginAt"] = loginAt;
return row;
}
[Fact]
public async Task Portal_visits_are_people_since_the_cutoff_keyed_by_object_id_else_upn()
{
const string upperTenant = "AAAAAAAA-0000-4000-8000-000000000000";
var at = new DateTime(2026, 11, 5, 9, 30, 0, DateTimeKind.Utc);
var h = new Harness
{
RowsFor = _ => new[]
{
Login(upperTenant, "alice@contoso.example", "OID-ALICE", at),
Login(Tenant, "Bob@Contoso.example", "", at), // no object id: the UPN
Login(Tenant, "app:11111111-2222-3333-4444-555555555555", "sp-object-id", at), // a service principal
Login(Tenant, "", " ", at), // no identity at all
Login(Tenant, "carol@contoso.example", "oid-carol", null), // no visit time
}
};
var visits = await h.Sut.GetPortalVisitsSinceAsync(new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal("LoginAt ge datetime'2026-08-07T12:00:00Z'", Assert.Single(h.Filters));
Assert.Equal(new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }, Assert.Single(h.Selects));
Assert.Collection(visits,
v =>
{
Assert.Equal(upperTenant.ToLowerInvariant(), v.TenantId);
Assert.Equal("oid-alice", v.PersonKey);
Assert.Equal(at, v.At);
Assert.Equal(DateTimeKind.Utc, v.At.Kind);
},
v => Assert.Equal((Tenant, "bob@contoso.example"), (v.TenantId, v.PersonKey)));
}
[Fact]
public async Task A_failing_portal_visit_read_throws_instead_of_reporting_nobody()
{
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetPortalVisitsSinceAsync(DateTime.UtcNow.AddDays(-90)));
}
// ---------- seen device models ----------
[Fact]
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
/// </summary>
public class PolicyEnforcementMiddlewareTests
public partial class PolicyEnforcementMiddlewareTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
/// <summary>
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
/// telemetry, so a delegated (MSP) reader never reaches them.
/// </summary>
public static TheoryData<string> OperatorFilterReads => new()
{
"/api/global/session-reports",
"/api/global/rule-submissions",
"/api/global/metrics/mcp-usage",
"/api/global/metrics/mcp-usage/daily",
"/api/global/metrics/mcp-usage/organization",
"/api/global/metrics/mcp-usage/overview",
"/api/global/metrics/verdict-calibration",
};
@@ -1470,6 +1471,8 @@ public class PolicyEnforcementMiddlewareTests
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
// Every tenant's use side by side (names, enrollments, portal users) — an operator view, never an MSP's.
[InlineData("GET", "/api/global/metrics/tenant-activity", TenantScoping.None)]
// Operator reads with a ?tenantId= filter: OperatorFilterReads above.
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
{
@@ -0,0 +1,237 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// "View as tenant": a Global Admin reads one tenant's portal as its member, read-only. DecideAsync admits the
/// headers only for a Global Admin, refuses every write, and decides the rest with the SAME evaluators over an
/// emulated member of the viewed tenant — the parity test pins that the emulation answers every read route
/// exactly like a real member of that tenant.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private const string GaUpn = "ga@vendor.example";
private static ViewAsTarget ViewB(string role = Constants.TenantRoles.Admin) => new(TenantB, role);
/// <summary>A Global Admin homed in TenantA (the default identity binding) and an onboarded TenantB.</summary>
private static Harness ViewAsHarness()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetGlobalRoleAsync(GaUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB });
return h;
}
[Fact]
public async Task ViewAs_GlobalAdmin_ReadsTheViewedTenantAsItsAdmin()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.True(r.Allowed);
var ctx = r.Context!;
Assert.Equal(TenantB, ctx.TenantId);
Assert.Equal(TenantB, ctx.TargetTenantId);
Assert.False(ctx.IsGlobalAdmin);
Assert.False(ctx.HasFleetScope);
Assert.Null(ctx.AllowedTenantIds);
Assert.Equal(Constants.TenantRoles.Admin, ctx.UserRole);
Assert.True(ctx.IsTenantAdmin);
// Identity stays the real caller's: throttling, logs and the SignalR binding key off it.
Assert.Equal(GaUpn, ctx.UserPrincipalName);
Assert.Equal(new ViewAsContext(TenantA, Constants.TenantRoles.Admin), ctx.ViewAs);
Assert.Equal(TenantA, ctx.IdentityTenantId());
}
[Theory]
[InlineData("tenant admin")]
[InlineData("global reader")]
[InlineData("delegated admin of the viewed tenant")]
[InlineData("no role")]
public async Task ViewAs_IsRefusedToEveryoneButAGlobalAdmin_AndReported(string caller)
{
const string upn = "someone@contoso.com";
var h = ViewAsHarness();
switch (caller)
{
case "tenant admin": h.AsTenantAdmin(TenantA, upn); break;
case "global reader": h.Repo.Setup(r => r.GetGlobalRoleAsync(upn)).ReturnsAsync(Constants.GlobalRoles.GlobalReader); break;
case "delegated admin of the viewed tenant": h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); break;
}
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, r.ErrorCode);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThroughMcpOrAForeignClientApp_IsRefusedEvenForAGlobalAdmin()
{
var h = ViewAsHarness();
var capped = AuthedPrincipal(TenantA, GaUpn);
ClientAppBinding.MarkCapped(capped, "33333333-3333-3333-3333-333333333333");
var mcp = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), isMcpClient: true, viewAs: ViewB());
var foreign = await h.Middleware.DecideAsync("GET", "/api/sessions", null, capped, viewAs: ViewB());
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, mcp.ErrorCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, foreign.ErrorCode);
}
[Fact]
public async Task ViewAs_AdmissionAnswers_ForMalformedHeaders_UnknownTenants_AndAnonymousCallers()
{
var h = ViewAsHarness();
var malformed = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
var unknown = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn),
viewAs: new ViewAsTarget("33333333-3333-3333-3333-333333333333", Constants.TenantRoles.Admin));
var anonymous = await h.Middleware.DecideAsync("GET", "/api/sessions", null, principal: null, viewAs: ViewB());
Assert.Equal((400, Constants.ApiErrorCodes.ViewAsInvalid), (malformed.StatusCode, malformed.ErrorCode));
Assert.Equal((404, Constants.ApiErrorCodes.ViewAsTenantNotFound), (unknown.StatusCode, unknown.ErrorCode));
Assert.Equal((401, Constants.ApiErrorCodes.AuthenticationRequired), (anonymous.StatusCode, anonymous.ErrorCode));
}
[Fact]
public async Task ViewAs_RefusesWrites_RunsTheReadsDeclaredOnPostRoutes_AndRefusesTheGetThatStartsAnAction()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
// A test send would reach the customer's channels.
var write = await h.Middleware.DecideAsync("POST", $"/api/config/{TenantB}/test-notification", null, ga, viewAs: ViewB());
var join = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, ga, viewAs: ViewB());
var consentFlow = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/autopilot-device-validation/consent-url", null, ga, viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (write.StatusCode, write.ErrorCode));
Assert.Equal(ViewAsTarget.ReadOnlyMessage, write.ErrorMessage);
Assert.True(join.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (consentFlow.StatusCode, consentFlow.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(write, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThePlatformSurface_IsClosedLikeForAMember_AndTheDenialIsNotReported()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (r.StatusCode, r.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
// The same denial without view-as headers is the assume-breach report it always was.
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task ViewAs_AnotherTenantsRoute_IsCrossTenantDenied_EvenTheGlobalAdminsHomeTenant()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.CrossTenantAccessDenied), (r.StatusCode, r.ErrorCode));
}
[Fact]
public async Task ViewAs_ASuspendedTenant_ShowsTheSuspension()
{
var h = ViewAsHarness();
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB, Disabled = true, DisabledReason = "Suspended for review" });
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.TenantSuspended), (r.StatusCode, r.ErrorCode));
Assert.Equal("Suspended for review", r.ErrorMessage);
}
[Fact]
public async Task ViewAs_AsViewer_ReadsTheTenantButStaysOutOfTheAdminTier()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
var sessions = await h.Middleware.DecideAsync("GET", "/api/sessions", null, ga, viewAs: ViewB(Constants.TenantRoles.Viewer));
var adminTier = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/notification-channel-health", null, ga,
viewAs: ViewB(Constants.TenantRoles.Viewer));
Assert.True(sessions.Allowed);
Assert.Equal(Constants.TenantRoles.Viewer, sessions.Context!.UserRole);
Assert.False(sessions.Context.IsTenantAdmin);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (adminTier.StatusCode, adminTier.ErrorCode));
}
[Fact]
public async Task ViewAs_RoutesWithoutAJwtTier_IgnoreTheHeaders()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/health", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
Assert.True(r.Allowed);
Assert.Null(r.Context!.ViewAs);
}
/// <summary>
/// The core promise: what the Global Admin sees is what the member sees. For every route that runs in view-as
/// mode and every tenant role, the emulated decision equals the decision for a real member of that role in the
/// viewed tenant — admission, status, error code, role, admin flag, scope and tenants.
/// </summary>
[Fact]
public async Task ViewAs_DecidesEveryReadRoute_ExactlyLikeARealMemberOfTheViewedTenant()
{
const string memberUpn = "member@tenant-b.example";
const string memberOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
var mismatches = new List<string>();
var compared = 0;
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
{
var h = ViewAsHarness();
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = memberUpn, TenantId = TenantB, Role = role, IsEnabled = true });
foreach (var entry in EndpointAccessPolicyCatalog.Entries)
{
if (entry.ViewAsAccess != ViewAsAccess.Read
|| entry.Policy is EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth)
continue;
var path = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{([^}]+)\}",
m => m.Groups[1].Value == "tenantId" ? TenantB : "x1");
var member = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantB, memberUpn, memberOid));
var viewed = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB(role));
compared++;
var expected = Describe(member);
var actual = Describe(viewed);
if (expected != actual)
mismatches.Add($"{role} {entry.HttpMethod} {entry.RouteTemplate}: member={expected} viewAs={actual}");
}
}
Assert.Empty(mismatches);
Assert.True(compared > 300, $"only {compared} decisions compared — the catalog walk is broken");
}
private static string Describe(PolicyEnforcementMiddleware.PolicyResult r)
=> r.Allowed
? $"allow role={r.Context!.UserRole} admin={r.Context.IsTenantAdmin} scope={r.Context.HasFleetScope} tenant={r.Context.TenantId} target={r.Context.TargetTenantId}"
: $"deny {r.StatusCode} {r.ErrorCode}";
}
@@ -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
});
}
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
{
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
// sleep outlasting a fixed wall-clock threshold.
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
// delays are independent timers, so under load the severe one can complete first; the cancel
// in the finally would then drop a warn delay whose callback had not run yet.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
{
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
// Both watchdogs fired while the body was still running.
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
}
@@ -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",
@@ -152,6 +152,10 @@ public sealed class SharedManifestParityTests
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
// Tenant activity (GET global/metrics/tenant-activity): the lifecycle stages in their test order —
// the MCP tool's stage filter enumerates them — and the edition names the portal's plan facet uses.
["tenantActivityStages"] = TenantActivityStages.All,
["tenantEditions"] = TenantEditionNames.All,
// Vocabularies of the diagnosis headline (GET sessions/{id}/diagnosis): the portal's verdict
// texts and culprit labels key on the same values the builder writes.
["diagnosis"] = new Dictionary<string, object?>
@@ -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));
}
}

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