Compare commits

..

77 Commits

Author SHA1 Message Date
Oliver Kieselbach 7055a6f0fc bump pointer: internal, .claude/commands (D-431 bounded collector stop: decision, plan, backlog, health marker)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:11:26 +01:00
Oliver Kieselbach 587de860dd Agent: termination no longer waits on a collector
Stopping the collector hosts is bounded per host (30 s), and so is their final
dispose. A host that does not return is left behind and reported once as
agent_trace (decision=collector_host_stop_timeout); the remaining hosts are
still stopped, and the diagnostics upload, the completion marker and the
cleanup follow as before. Until now a single host that hung in Stop kept the
agent process alive until the next reboot, without upload and without cleanup.

Removes the lock cycles that could cause such a hang. The Office registry,
binary and process watchers and their host, the IME process watcher and the
RealmJoin watcher's Stop raised events or disposed a child while holding a
lock the other thread needed. A watcher now raises its events with its lock
released, and an owner never disposes a child under a lock the child's
callbacks take.

Office process watcher: the WMI subscription is set up and cancelled off the
caller's thread (cancelling it could delay the stop by tens of seconds), and a
worker that had already exited when the watcher subscribed now closes the
install window instead of keeping the Delivery Optimization collector polling.
The Office registry watch is no longer tied to the thread that armed it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:10:47 +01:00
Oliver Kieselbach 409794c037 bump pointer: internal (IME 1.109.102.0 diff note, B-acx update)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:47:54 +01:00
Oliver Kieselbach 337b3b012a bump pointer: internal (promo videos as one project, Ignite 2026 social plan, D-429 marketing pages, D-430 setup guide manufacturers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:42:27 +01:00
Oliver Kieselbach c2ae5ddd83 Merge pull request #215 from okieselbach/dependabot/npm_and_yarn/src/Web/autopilot-monitor-web/web-30eaf40955
Bump the web group in /src/Web/autopilot-monitor-web with 4 updates
2026-11-10 11:35:40 +01:00
Oliver Kieselbach 60baf5317c Merge pull request #214 from okieselbach/dependabot/npm_and_yarn/src/McpServer/autopilot-monitor-mcp/mcp-3237aae3e3
Bump the mcp group in /src/McpServer/autopilot-monitor-mcp with 2 updates
2026-11-10 11:34:51 +01:00
Oliver Kieselbach a5d5a37dc1 Setup guide: Samsung instead of Getac among the one-click manufacturers
The hardware step offers well-known manufacturers outside the default
list; the comment no longer claims an order by rejected devices.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:07:09 +01:00
Oliver Kieselbach d4b56c417d Notifications: Teams channel hint names the current Workflows path
The hint for the Teams (Workflow Webhook) provider now points to
More options (...) next to the channel -> Workflows -> "Send webhook alerts
from anyone to Teams" instead of the old Manage channel path and template.
It also says that the variants limited to specific people or the org reject
the alerts, because the backend posts without an Entra sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:34:45 +01:00
Oliver Kieselbach a5bbdf9f80 Marketing pages: setup in four short steps, one script, a few minutes
/get-started tells the flow in four steps with one sentence each: sign in,
follow the setup guide, add one script in Intune, watch live. The separate
consent step, the "same day" line and the statements about the tenant that
no longer match the product are gone.

The landing page's "How it works" and story say "one script in Intune"
instead of "bootstrapper"; About and its FAQ name the loader script, the
one customers assign. The setup checklist says "A few minutes, Intune
included".

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:18:07 +01:00
dependabot[bot] 389d4f27dc Bump the web group in /src/Web/autopilot-monitor-web with 4 updates
Bumps the web group in /src/Web/autopilot-monitor-web with 4 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer), [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


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

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

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

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

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.44.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-10 01:06:19 +00:00
dependabot[bot] ce5f22bd19 Bump the mcp group
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 2 updates: [jose](https://github.com/panva/jose) and [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `jose` from 6.2.14 to 6.2.15
- [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.14...v6.2.15)

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

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.44.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-10 01:05:56 +00:00
Oliver Kieselbach 85fadc8ac4 bump pointer: internal (D-428 local admin allowlist roles released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:57:43 +01:00
Oliver Kieselbach 7633c02a1c bump pointers: internal, .claude/commands (D-428 local admin allowlist roles: plan, decision, concept doc, backlog; import_session scrub)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:30:01 +01:00
Oliver Kieselbach bd66ad7097 Local Admin Analyzer: allowed accounts carry a role; WsiAccount allowed as standard user
An allowed-list entry is now `name` (any role, meaning unchanged) or
`name|user`: the account is allowed only while it is not a member of the
local Administrators group. Of all entries matching an account, a
standard-user entry wins over one that allows more, so no entry lifts a
built-in restriction. '|' cannot occur in a Windows account name; an older
agent matches such an entry against no account and keeps flagging it.

Built-ins move to Shared (LocalAdminAllowList), one source for agent,
backend and portal: the built-in Administrator (RID 500) in any role;
Guest, DefaultAccount, WDAGUtilityAccount (by RID, any language),
kioskUser0 and the Web sign-in account WsiAccount only as standard users,
like any other account that only carries one of these names;
defaultuser0-2 in any role; Public, Default, Default User and All Users as
profile folders only. The four built-in accounts are decided by RID alone.

Gaps closed on the way:
- Guest-type accounts and accounts named like profile folders were
  allowed as administrators.
- A logged-in user at shutdown allowed a local administrator of that name
  when the WMI inventory failed; the dynamic entry now allows only the
  profile folder.
- Administrators missing from the WMI inventory scored 40 with an empty
  rule summary; they now count as unexpected accounts too (80).
- Glob matching was culture-sensitive (i/I under a Turkish culture).

Event: checks.role_violations, account_details[].rid, allowed_accounts in
canonical form ("Guest|user"). The backend validates entries that are new
against the stored list (grammar, length, forbidden characters, SID, too
broad, duplicate pattern); the too-broad check existed only in the portal.
The portal renders the built-ins from the shared manifest (the hand copy
lacked kioskUser0), offers "Standard user only" per entry and marks
entries the built-ins already cover. ANALYZE-ID-002 1.5.0 explains the
role rule and names the card correctly. Shared grammar cases:
tests/fixtures/local-admin-allow-list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:28:51 +01:00
Oliver Kieselbach be13d8f5ef bump pointer: internal (onboarding setup guide released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:58:23 +01:00
Oliver Kieselbach 8296fa7291 bump pointer: internal (onboarding setup guide: D-425..D-427, concept docs, backlog), .claude/commands (setup guide telemetry in growth, ux-insights, platform-health)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:35:29 +01:00
Oliver Kieselbach 4faca8692b Web: setup checklist shows turned-away devices once validation is on
Agents can connect as soon as a device validation is on, so the list of
devices the backend turned away and the "no device after an hour" checks
now show from then on, not only once the recommended steps are done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:26:37 +01:00
Oliver Kieselbach ba715d5aa7 Web: setup tour never turns on Autopilot validation for device preparation
With "I use device preparation" selected, the tour's own "Detect existing
access" checked access for Autopilot Device Validation and turned that on.
The bubble now points to the access check under each of the two
device-preparation options in the highlighted block. The approve button
only exists for Windows Autopilot, so it starts that consent directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:22:52 +01:00
Oliver Kieselbach ed73dc3a87 Web: reported hardware values never become wildcard entries
Manufacturer and model strings from device reports reach the backend
unauthenticated, and the whitelist matcher has no escape for '*' and '?'.
reportedValueEntry turns such a value into one literal entry, or into
nothing when it holds a wildcard (no real SMBIOS value does). The setup
checklist's one-click accept and the hardware rejection insights in
Settings only offer an addition for that entry and name the pattern they
add. The settings insights had the same gap before the guide.

Also drops the docs path of the removed welcome card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:20:10 +01:00
Oliver Kieselbach 1e88d1db4b Web: setup guide for new tenants
A tenant without enrollments gets a "Get started" checklist on the dashboard
instead of the welcome card, plus a sidebar entry while it is open. Four
steps: recommended settings (timezone, diagnostics package with hosted
storage and upload mode "on failure", software inventory), hardware
whitelist, device validation, agent deployment. Done states come from the
tenant's flags; local marks bridge the backend's configuration cache.

- Guide dialog mounted once in the root layout, opened from the card, the
  sidebar, the help menu and the empty-dashboard note.
- Device validation runs as a spotlight tour on the real settings page; the
  tour sits in sessionStorage and survives the consent redirect. Plan B
  copies the approval link for a Global Administrator.
- "Show me where" spotlights the three settings on their pages.
- While waiting for the first enrollment the card lists devices the backend
  turned away (hardware whitelist, not registered) with a one-click accept.
- The first session to arrive replaces the card with a success card.
- The red "no device validation" banner only shows while the checklist
  does not, and waits for the checklist's flags so it never flashes.
- Writes go through the field PATCH and POST config/{tenantId}/setup-guide;
  an open settings page receives guide writes through a patch signal.
- Company name in the navbar on wide screens when the tenant set one.
- Hardware whitelist helpers move to lib/; a typed "VMware, Inc." stays
  one entry.
- Privacy page: hosted diagnostics upload wording made general.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:12:06 +01:00
Oliver Kieselbach f3778fbad6 auth/me: company name only for callers with a tenant role
The navbar label went to every signed-in user of the tenant, including members
without a role, who see only the Progress Portal and whom the config GET
refuses. It now follows the config GET: a tenant role or a Global Admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:22:45 +01:00
Oliver Kieselbach 1a85a75af1 Setup guide state: markers on the tenant, checklist flags, company name in auth/me
The setup guide has to know which of its steps a tenant finished and whether an
admin hid it, and the navbar shows the tenant's company name.

- TenantConfiguration: SetupGuideSettingsReviewedAt, SetupGuideHardwareReviewedAt,
  SetupGuideDismissedAt/By. System-owned: denied in the field patch, kept on
  revert, not counted as a backup-worthy change.
- POST config/{tenantId}/setup-guide (tenant admin or global admin, refused in
  view-as) records a finished step, hides the guide for the tenant or brings it
  back, with server time, the caller's UPN and an audit entry.
- Feature flags: setupGuideSettingsDone and setupGuideHardwareDone (finished in the
  guide, or already true in the configuration), setupGuideDismissed, and
  hasEnrollmentSessions from the tenant's enrollment counter (strict read, omitted
  when unknown, remembered per instance once true).
- auth/me: companyName when one is set.
- PlanManagement's update-outcome responses move to a shared helper.
- The software inventory analyzer's null default is documented as off, which is
  how the agent config and the flags already read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:16:26 +01:00
Oliver Kieselbach 5f13c71f1d bump pointer: internal (D-424 start push urgency and credential URL redaction released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:04:40 +01:00
Oliver Kieselbach 041803e55c Backend: hardware whitelist rejections reach the tenant
The agent reports every backend 403 as DeviceNotRegistered, but the hardware
whitelist is checked before the registration lookup. A device outside the whitelist
was stored as not registered: the bell, the hardware-rejection webhook and the
rejected-hardware list never saw it, and the not-registered list showed hardware
rejections instead.

- ReportDistress resolves the type on the server: a DeviceNotRegistered 403 whose
  manufacturer or model fails the tenant's whitelist becomes HardwareNotAllowed,
  unless the validator refuses or admits the request before the hardware check
  (suspended tenant, no validation, bootstrap auth). The agent's own claim stays in
  the log and in the AgentDistressReport event as ReportedErrorType.
- The per-tenant distress lists read one type on the server instead of filtering the
  newest 200 rows of all types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:56:42 +01:00
Oliver Kieselbach eff89fd09f Backend: paged table reads never ask Azure for more than 1000 rows
A fan-out reader asks each partition for pageSize + 1 rows. At pageSize 1000 that
became $top=1001, which Azure Tables refuses with 400; the reader caught the error
and returned an empty page, so the global audit log and the cross-category ops
events answered pageSize=1000 with success and zero rows.

- AzureTablesPaginator.ServerPageSize caps a computed maxPerPage at 1000; the
  readers keep enumerating server pages until they have their rows.
- Every computed maxPerPage goes through it, including the per-tenant distress read
  and three session-event reads that had no cap.
- The paged audit-log and ops-event readers no longer turn a storage error into an
  empty page; the function answers 500 instead.
- TableServerPageSizeGuardTests: ratchet over every maxPerPage argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:56:32 +01:00
Oliver Kieselbach 0cc35d2bae bump pointers: internal, .claude/commands (D-424 start push urgency, credential URL redaction: plan, decision, docs, health marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:33:24 +01:00
Oliver Kieselbach 27cb286118 Push: start alerts go out at high urgency; webhook URLs stay out of dependency telemetry (D-424)
A start alert is Info and was sent with Urgency low. Push services hold anything below
high while a phone dozes, and the session's verdict shares the start's Topic, so the
verdict could replace a start that was still waiting. Start alerts (enrollment started,
pre-provisioning resumed) now go out at high urgency; every other alert keeps the urgency
of its severity.

The webhook client's dependency rows carried the full webhook URL, which is a credential
(token in the path, signature in the query). CredentialUrlRedactionProcessor replaces the
push-only processor: the webhook and push HttpClients carry its MarkingHandler, which marks
each request's async flow, and every dependency row tracked inside a marked flow keeps
scheme + host and the method only, whatever the host. A test drives the real dependency
collector over a loopback socket to prove the mark reaches the processor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:33:03 +01:00
Oliver Kieselbach c88e5411a8 bump pointer: internal (D-423 settings unsaved changes released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:22:22 +01:00
Oliver Kieselbach 5eba39f658 bump pointers: internal, .claude/commands (D-423 settings unsaved changes: plan, decision, docs, health marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:02:06 +01:00
Oliver Kieselbach 5f95648c73 Settings: unsaved edits are marked, Save stays in reach, leaving asks first (D-423)
A settings card with unsaved edits says so next to Save, and its Save bar sticks to
the bottom of the viewport while the card reaches below it; without edits Save is
greyed out. The sidebar marks every page with unsaved edits, also on a collapsed
group, an icon or a collapsed category.

Moving between settings pages keeps the edits. Leaving the settings area asks first
(Stay / Leave without saving): links of any kind, the global search, a notification,
sign-out and the admin-consent redirect. Closing or reloading the tab gets the
browser's prompt, never while a sign-in redirect is in flight. Discarding stays tied
to leaving; there is still no discard button next to Save.

The edited values live in one SettingsForm with two pure projections (formFromConfig,
configFromForm); loading, the save payload and the unsaved check share them, so model
defaults, trimming and the synthesized legacy channel never count as edits. The
section map names each section's sidebar page and marks the sections that save on
change. The scroll-to-top and feedback buttons rise above a pinned bar.
settings_unsaved_prompt reports the prompt's outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:00:37 +01:00
Oliver Kieselbach e4d527b6d5 bump pointer: internal (push device rename released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:41:53 +01:00
Oliver Kieselbach 258d7cd5f4 Push devices: the platform card manages its own devices again
The home-tenant view (D-334) marks a platform device as managed elsewhere;
the check compared against "platform" instead of the panel's own scope, so on
the platform card every device lost Send test, Remove and now Rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:36:17 +01:00
Oliver Kieselbach 668ae14657 bump pointers: internal, .claude/commands (push device rename: plan, backlog, health marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:24:53 +01:00
Oliver Kieselbach 16bd82b148 Push devices: rename at confirm and in the list, panel always visible, desktop permission hint
- Backend: confirm takes an optional { label } (an empty body keeps the name
  the device sent); new PATCH push/devices/{id} and PATCH global/push/devices/{id}
  rename a device under the remove rule (owner or scope admin), 400 for a blank
  name, audit UPDATE with previousLabel, PushPairing action "renamed".
  Both routes in the policy catalog; push route pin 22 -> 24.
- Portal: the pairing dialog has a Device name field prefilled with the sent
  name and pauses its poll while confirming; Push devices rows get Rename
  (Enter saves, Esc cancels). The panel shows without a Push channel and says
  which channel to add (Operator wording when the channel list is read-only);
  showPushDevicesPanel gives way to hasScopeDevice.
- Receiver: the pair page tells computer users that the browser may ask only
  through an address-bar icon; opening the app stores a name changed in the
  portal for offline display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:24:52 +01:00
Oliver Kieselbach 225d0feca9 Merge pull request #213 from okieselbach/fix/view-as-bar-layout
View as: keep the navbar and sidebar clear of the view-as bar
2026-11-06 18:47:05 +01:00
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 12b29c2e08 bump pointer: internal (D-422 tenant activity released and verified live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:32:11 +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
352 changed files with 23809 additions and 3173 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
+7 -1
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
@@ -101,8 +106,9 @@ updates:
# 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: /.github/tools/swa-cli
directory: /tools/swa-cli
schedule:
interval: weekly
cooldown:
-8
View File
@@ -1,8 +0,0 @@
{
"name": "autopilot-monitor-swa-cli",
"private": true,
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
"devDependencies": {
"@azure/static-web-apps-cli": "2.0.10"
}
}
+5 -4
View File
@@ -5,8 +5,9 @@ name: Workflow gates
# 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).
@@ -39,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:
+2 -2
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/**'
+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/**'
+4 -4
View File
@@ -1,6 +1,6 @@
name: Deploy Web (Static Web App)
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
# 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.
@@ -19,7 +19,7 @@ on:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/tools/swa-cli/**'
- 'tools/swa-cli/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
@@ -173,7 +173,7 @@ jobs:
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
# build zone can touch the install.
- name: Install the SWA CLI from its lockfile
working-directory: .github/tools/swa-cli
working-directory: tools/swa-cli
run: npm ci --ignore-scripts
- name: Download build artifact
@@ -193,7 +193,7 @@ jobs:
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
.github/tools/swa-cli/node_modules/.bin/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
+23 -4
View File
@@ -2,10 +2,10 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ID-002",
"title": "Unexpected Local Admin Accounts Detected",
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.4.0",
"version": "1.5.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -65,6 +65,18 @@
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
},
{
"signal": "role_violations",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.role_violations",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
}
],
"confidenceFactors": [
@@ -80,7 +92,7 @@
}
],
"confidenceThreshold": 50,
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
"remediation": [
{
"title": "Review the unexpected accounts",
@@ -94,7 +106,8 @@
"title": "Add expected accounts to the allowed list (false positive)",
"steps": [
"Go to Settings → Agent Analyzers section",
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
"Save the settings — future enrollments will no longer flag these accounts"
]
},
@@ -104,6 +117,7 @@
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
"Consider re-imaging the device if unauthorized admin access is confirmed"
]
}
@@ -126,6 +140,11 @@
"label": "In Administrators",
"value": "{{unexpected_admin_members.value}}",
"format": "code"
},
{
"label": "Allowed only as standard user",
"value": "{{role_violations.value}}",
"format": "code"
}
]
},
+23 -4
View File
@@ -4103,10 +4103,10 @@
{
"ruleId": "ANALYZE-ID-002",
"title": "Unexpected Local Admin Accounts Detected",
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.4.0",
"version": "1.5.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -4166,6 +4166,18 @@
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
},
{
"signal": "role_violations",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.role_violations",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
}
],
"confidenceFactors": [
@@ -4181,7 +4193,7 @@
}
],
"confidenceThreshold": 50,
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
"remediation": [
{
"title": "Review the unexpected accounts",
@@ -4195,7 +4207,8 @@
"title": "Add expected accounts to the allowed list (false positive)",
"steps": [
"Go to Settings → Agent Analyzers section",
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
"Save the settings — future enrollments will no longer flag these accounts"
]
},
@@ -4205,6 +4218,7 @@
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
"Consider re-imaging the device if unauthorized admin access is confirmed"
]
}
@@ -4227,6 +4241,11 @@
"label": "In Administrators",
"value": "{{unexpected_admin_members.value}}",
"format": "code"
},
{
"label": "Allowed only as standard user",
"value": "{{role_violations.value}}",
"format": "code"
}
]
},
@@ -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>
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
TimeSpan? drainInterval = null,
TimeSpan? terminalDrainTimeout = null,
TimeSpan? agentMaxLifetime = null) =>
TimeSpan? agentMaxLifetime = null,
TimeSpan? collectorHostStopTimeout = null) =>
new EnrollmentOrchestrator(
sessionId: "S1",
tenantId: "T1",
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
agentMaxLifetime: agentMaxLifetime);
agentMaxLifetime: agentMaxLifetime,
collectorHostStopTimeout: collectorHostStopTimeout);
public void Dispose() => Tmp.Dispose();
}
@@ -0,0 +1,45 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
{
/// <summary>
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
/// </summary>
public sealed class LocalAdminAllowListFixtureTests
{
private static IEnumerable<JToken> ParseCases()
{
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
return JObject.Parse(File.ReadAllText(path))["parse"]!;
}
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
[Theory]
[MemberData(nameof(ParseEntries))]
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
{
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
if (c.Value<bool?>("ignored") == true)
{
Assert.False(parsed);
return;
}
Assert.True(parsed);
Assert.Equal(c.Value<string>("pattern"), pattern);
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
}
}
}
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
private const string Machine = "DESKTOP-TEST01";
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
private static readonly List<string> Allowed = new List<string>
{
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
};
// The real built-in catalog plus one tenant glob.
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
[Fact]
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
{
// WMI failed or did not list the account — the SAM group membership alone is enough.
// WMI failed or did not list the account — the SAM group membership alone is enough,
// and the account counts as unexpected too, like any unknown administrator (score 80).
var members = new List<LocalGroupMember>
{
LocalMember("Administrator", 500),
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
var result = LocalAdminAnalyzer.EvaluateAccounts(
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
Assert.Empty(result.Unexpected);
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
Assert.Empty(result.RoleViolations);
}
[Fact]
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
}
[Fact]
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
public void Machine_account_rid_is_read_only_from_local_machine_sids()
{
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
}
[Fact]
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
{
// Name-based allowance is unchanged: the RID match only ADDS coverage.
// Only the RID makes a built-in: an ordinary account merely named like a localized
// built-in is no entry at all.
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
{
Account("Gast", disabled: false, rid: 1001)
@@ -0,0 +1,349 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
{
/// <summary>
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
/// built-in name by name) allow an account only while it is not an Administrators member. A
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
/// </summary>
public sealed class LocalAdminAnalyzerRoleTests
{
private const string Machine = "DESKTOP-TEST01";
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
private static LocalGroupMember Member(string name, int rid) =>
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
bool enumerated = true)
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
// -------------------------------------------------------------- WsiAccount (the field case)
[Fact]
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
{
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
var accounts = new[]
{
Account("Administrator", 500, disabled: true),
Account("DefaultAccount", 503, disabled: true),
Account("defaultuser0", 1000, disabled: true),
Account("Gast", 501, disabled: true),
Account("WDAGUtilityAccount", 504, disabled: true),
Account("WsiAccount", 1001, disabled: true),
Account("ADM-0A1B2C3D", 1002),
};
var admins = new List<LocalGroupMember>
{
Member("Administrator", 500),
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
Member("ADM-0A1B2C3D", 1002),
};
var result = Evaluate(accounts, admins, Entries("ADM-*"));
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
Assert.Empty(result.Unexpected);
Assert.Empty(result.UnexpectedAdminMembers);
Assert.Empty(result.RoleViolations);
Assert.Equal(0, score);
}
[Fact]
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
{
var result = Evaluate(
new[] { Account("WsiAccount", 1001, disabled: true) },
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
Entries());
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
}
[Theory]
[InlineData("WsiAccount")]
[InlineData("wsiaccount")]
[InlineData("Wsi*")]
[InlineData("*")]
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
{
var result = Evaluate(
new[] { Account("WsiAccount", 1001) },
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
Entries(tenantEntry));
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
}
[Fact]
public void KioskUser0_in_administrators_is_a_role_violation()
{
var result = Evaluate(
new[] { Account("kioskUser0", 1003) },
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
Entries());
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
}
// -------------------------------------------------------------- built-ins by RID
[Theory]
[InlineData("Guest", 501)]
[InlineData("Gast", 501)]
[InlineData("Invité", 501)]
[InlineData("DefaultAccount", 503)]
[InlineData("WDAGUtilityAccount", 504)]
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
{
// A tenant entry naming the localized account (or even "*") cannot lift it either.
var result = Evaluate(
new[] { Account(name, rid, disabled: true) },
new List<LocalGroupMember> { Member(name, rid) },
Entries(name, "*"));
Assert.Equal(new[] { name }, result.Unexpected);
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
Assert.Equal(new[] { name }, result.RoleViolations);
}
[Theory]
[InlineData("Administrator")]
[InlineData("Administrateur")]
[InlineData("Renamed_Admin")]
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
{
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
var result = Evaluate(
new[] { Account(name, 500) },
new List<LocalGroupMember> { Member(name, 500) },
Entries("Admin*|user", "Renamed*|user"));
Assert.Empty(result.Unexpected);
Assert.Empty(result.RoleViolations);
Assert.True(result.BuiltInAdministratorEnabled);
}
[Fact]
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
{
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
var accounts = new[]
{
Account("Administrateur", 500, disabled: true),
Account("Administrator", 1004),
};
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
Assert.Empty(decoy.Unexpected);
var impostor = Evaluate(accounts,
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
}
[Fact]
public void The_oobe_account_may_be_an_administrator()
{
var result = Evaluate(
new[] { Account("defaultuser0", 1000) },
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
Entries());
Assert.Empty(result.Unexpected);
}
[Fact]
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
{
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
{
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
};
var admins = new List<LocalGroupMember>
{
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
};
var result = Evaluate(accounts, admins, Entries());
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
Assert.DoesNotContain("Administrator", result.Unexpected);
}
// -------------------------------------------------------------- tenant roles
[Fact]
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
{
var accounts = new[] { Account("frontdesk", 1005) };
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
}
[Fact]
public void A_standard_user_glob_restricts_each_matching_account()
{
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
}
[Fact]
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
{
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
Assert.DoesNotContain("adm-4711", result.Unexpected);
}
[Fact]
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
{
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
Assert.Empty(result.Unexpected);
Assert.False(result.AdministratorsGroupEnumerated);
}
// -------------------------------------------------------------- folders are no accounts
[Fact]
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
{
var entries = Entries();
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
Assert.Equal(new[] { "Public" }, result.Unexpected);
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
Assert.Empty(result.RoleViolations);
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
}
// -------------------------------------------------------------- accounts WMI did not return
[Fact]
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
{
var members = new List<LocalGroupMember>
{
Member("Administrator", 500),
Member("WsiAccount", 1001),
Member("Gast", 501),
Member("adm-4711", 1009),
};
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
}
[Fact]
public void A_member_wmi_already_returned_is_not_judged_twice()
{
var result = Evaluate(
new[] { Account("backdoor", 1011) },
new List<LocalGroupMember> { Member("backdoor", 1011) },
Entries());
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
}
[Fact]
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
{
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
var entries = Entries();
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
}
// -------------------------------------------------------------- the entry list
[Fact]
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
{
var ignored = new List<string>();
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
Assert.Contains("WsiAccount|user", accounts);
Assert.Contains("kioskUser0|user", accounts);
Assert.Contains("defaultuser0", accounts);
Assert.Contains("kiosk|user", accounts);
Assert.Contains("legacy*", accounts);
Assert.Contains("CONTOSO\\admin", accounts);
Assert.Single(accounts, a => a == "Guest|user");
Assert.DoesNotContain("Public", accounts);
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
}
// -------------------------------------------------------------- scoring
[Fact]
public void Score_follows_the_signals()
{
var none = new List<string>();
var backdoor = new List<string> { "backdoor" };
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
public void Dispose()
{
Watcher.Dispose();
ReleaseProcessesAndFiles();
}
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
public void ReleaseProcessesAndFiles()
{
foreach (var p in Running)
{
try { if (!p.HasExited) p.Kill(); } catch { }
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
Assert.Equal(new object[] { first.Id, second.Id }, pids);
}
[Fact]
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
{
var f = new Fixture();
var child = f.Spawn(trusted: true);
// The watcher gets a Process object that already carries a handler. .NET runs that one
// first and holds its own lock on the Process object for as long as it delivers Exited.
var delivering = new ManualResetEventSlim(false);
var release = new ManualResetEventSlim(false);
f.Watcher.ProcessSource = () =>
{
var handle = Process.GetProcessById(child.Id);
handle.EnableRaisingEvents = true;
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
return new[] { handle };
};
f.Watcher.TryAttachNow();
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
Task dispose;
try
{
child.Kill();
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
dispose = Task.Run(() => f.Watcher.Dispose());
Thread.Sleep(300); // let Dispose reach the Process object
}
finally
{
release.Set();
}
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
// hang the test run instead of failing this test.
f.ReleaseProcessesAndFiles();
Assert.True(returned,
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
}
[Fact]
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
{
@@ -2,11 +2,14 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
{
/// <summary>
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
}
[Fact]
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
{
using var tmp = new TempDirectory();
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
var start = Task.Run(() => watcher.Start());
try
{
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
var dispose = Task.Run(() => watcher.Dispose());
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
}
finally
{
release.Set();
start.Wait(TimeSpan.FromSeconds(10));
watcher.Dispose();
}
}
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
{
var deadline = Environment.TickCount + timeoutMs;
@@ -0,0 +1,152 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
{
/// <summary>
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
/// already gone when the watcher subscribed.
/// </summary>
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
public sealed class OfficeProcessWatcherTests : IDisposable
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly List<Process> _spawned = new List<Process>();
public OfficeProcessWatcherTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose()
{
foreach (var p in _spawned)
{
try { if (!p.HasExited) p.Kill(); } catch { }
try { p.Dispose(); } catch { }
}
_tmp.Dispose();
}
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
private Process Spawn()
{
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
{
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
});
Assert.NotNull(p);
_spawned.Add(p!);
return p!;
}
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
private static Process HandleOn(Process child)
{
var handle = Process.GetProcessById(child.Id);
handle.EnableRaisingEvents = true;
return handle;
}
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
{
watcher.WorkerSource = () => new[] { handle };
watcher.Start(armWmi: false);
}
[Fact]
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
{
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
using var started = new ManualResetEventSlim(false);
using var stopped = new ManualResetEventSlim(false);
watcher.Started += (_, __) => started.Set();
watcher.Stopped += (_, __) => stopped.Set();
var child = Spawn();
StartWith(watcher, HandleOn(child));
Assert.True(started.IsSet, "Started was not raised by the startup probe");
Assert.True(watcher.IsActive);
child.Kill();
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
Assert.False(watcher.IsActive);
}
[Fact]
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
{
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
var child = Spawn();
var handle = HandleOn(child);
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
// the Process object for as long as it delivers Exited.
using var delivering = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
StartWith(watcher, handle);
Task dispose;
try
{
child.Kill();
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
dispose = Task.Run(() => watcher.Dispose());
Thread.Sleep(300); // let Dispose reach the Process object
}
finally
{
release.Set();
}
Assert.True(dispose.Wait(Timeout),
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
}
[Fact]
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
{
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
using var stopped = new ManualResetEventSlim(false);
watcher.Stopped += (_, __) => stopped.Set();
var child = Spawn();
var handle = HandleOn(child);
using var exitDelivered = new ManualResetEventSlim(false);
handle.Exited += (_, __) => exitDelivered.Set();
child.Kill();
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
StartWith(watcher, handle);
Assert.True(stopped.Wait(Timeout),
"the window never closed: the worker exited before the watcher subscribed to Exited");
Assert.False(watcher.IsActive);
}
}
}
@@ -0,0 +1,199 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Microsoft.Win32;
using Xunit;
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
{
/// <summary>
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
/// after it returns.
/// </summary>
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
public sealed class RegistryChangeWatcherTests : IDisposable
{
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
private const string TargetSubKey = RootSubKey + @"\Target";
private const uint RegNotifyThreadAgnostic = 0x10000000;
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
public RegistryChangeWatcherTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
DeleteTestKeys();
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
}
public void Dispose()
{
DeleteTestKeys();
_tmp.Dispose();
}
private static void DeleteTestKeys()
{
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
catch { /* teardown noise must not fail a test */ }
}
private static void CreateTarget()
{
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
}
private static void WriteValue(string name)
{
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
{
Assert.NotNull(key);
key!.SetValue(name, Guid.NewGuid().ToString("N"));
}
}
private RegistryChangeWatcher NewWatcher() =>
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
[Fact]
public void Changed_fires_when_a_value_is_written_under_the_target()
{
CreateTarget();
using var watcher = NewWatcher();
using var fired = new ManualResetEventSlim(false);
watcher.Changed += (_, __) => fired.Set();
watcher.Start();
WriteValue("Probe");
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
}
[Fact]
public void Changed_fires_once_a_missing_target_key_appears()
{
using var watcher = NewWatcher();
using var fired = new ManualResetEventSlim(false);
watcher.Changed += (_, __) => fired.Set();
watcher.Start(); // target absent: watches the parent for a new child key
CreateTarget();
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
}
[Fact]
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
{
CreateTarget();
var watcher = NewWatcher();
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
try
{
watcher.Start();
WriteValue("Probe");
Assert.True(entered.Wait(Timeout), "handler was not invoked");
var dispose = Task.Run(() => watcher.Dispose());
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
}
finally
{
release.Set();
watcher.Dispose();
}
}
[Fact]
public void A_handler_may_dispose_its_own_watcher()
{
CreateTarget();
var watcher = NewWatcher();
using var disposedInHandler = new ManualResetEventSlim(false);
int calls = 0;
watcher.Changed += (_, __) =>
{
Interlocked.Increment(ref calls);
watcher.Dispose();
disposedInHandler.Set();
};
watcher.Start();
WriteValue("First");
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
WriteValue("Second");
Thread.Sleep(300);
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
}
[Fact]
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
{
CreateTarget();
using var watcher = NewWatcher();
using var firstEntered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
using var second = new ManualResetEventSlim(false);
int calls = 0, running = 0, maxRunning = 0;
watcher.Changed += (_, __) =>
{
var now = Interlocked.Increment(ref running);
int seen;
while (now > (seen = Volatile.Read(ref maxRunning)))
Interlocked.CompareExchange(ref maxRunning, now, seen);
if (Interlocked.Increment(ref calls) == 1)
{
firstEntered.Set();
release.Wait(Timeout);
}
else
{
second.Set();
}
Interlocked.Decrement(ref running);
};
watcher.Start();
WriteValue("First");
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
WriteValue("DuringHandler");
Thread.Sleep(300);
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
release.Set();
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
Assert.Equal(1, Volatile.Read(ref maxRunning));
}
[Fact]
public void Target_watch_is_not_tied_to_the_arming_thread()
{
using var watcher = NewWatcher();
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
// the event when such a thread ends, which looks like a change that never happened.
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
}
}
}
@@ -0,0 +1,97 @@
#nullable enable
using System;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Microsoft.Win32;
using Xunit;
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
/// flight. The callback runs on a real registry watcher over the signed-in user's
/// <c>HKU\&lt;sid&gt;\SOFTWARE</c> (writable without elevation); the key probes are injected.
/// </summary>
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
public sealed class RealmJoinWatcherStopTests : IDisposable
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly TempDirectory _tmp = new TempDirectory();
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
public void Dispose()
{
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
catch { /* teardown noise must not fail a test */ }
_tmp.Dispose();
}
[Fact]
public void Stop_returns_while_the_key_appeared_callback_is_running()
{
var sid = WindowsIdentity.GetCurrent().User!.Value;
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
var appearedProbe = new ManualResetEventSlim(false);
var release = new ManualResetEventSlim(false);
int armed = 0;
bool KeyExists(RegistryHive hive, string subPath)
{
// Everything is "absent" while the watcher arms. Afterwards the user-scope
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
return false;
appearedProbe.Set();
release.Wait(Timeout);
return true;
}
var watcher = new RealmJoinWatcher(
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
enumeratePackageIds: (_, __) => Array.Empty<string>(),
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
{
snapshot = default!;
return false;
},
keyExists: KeyExists,
readVersionInfo: () => default,
readUninstallDisplayVersion: () => null,
readDeploymentPhase: () => null);
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
watcher.ArmHku(sid);
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
Volatile.Write(ref armed, 1);
Task stop;
try
{
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
stop = Task.Run(() => watcher.Stop());
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
}
finally
{
release.Set();
}
var returned = stop.Wait(Timeout);
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
if (returned) watcher.Dispose();
Assert.True(returned,
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
}
}
}
@@ -0,0 +1,158 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
using Xunit;
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
/// as before.
/// </summary>
[Collection("SerialThreading")]
public sealed class EnrollmentOrchestratorCollectorStopTests
{
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
private sealed class Host : ICollectorHost
{
private readonly List<string> _order;
private readonly ManualResetEventSlim? _hangUntil;
private readonly Exception? _throwOnStop;
private int _stopCalls;
private int _disposeCalls;
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
{
Name = name;
_order = order;
_hangUntil = hangUntil;
_throwOnStop = throwOnStop;
}
public string Name { get; }
public int StopCalls => Volatile.Read(ref _stopCalls);
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
public void Start() { }
public void Stop()
{
Interlocked.Increment(ref _stopCalls);
lock (_order) _order.Add(Name);
if (_throwOnStop != null) throw _throwOnStop;
_hangUntil?.Wait();
}
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
}
private sealed class Factory : IComponentFactory
{
private readonly IReadOnlyList<ICollectorHost> _hosts;
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
public CollectorSurfaces CreateCollectorHosts(
string sessionId,
string tenantId,
AgentLogger logger,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock,
ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null,
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
}
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
sut.SpoolForTests!.Peek(1000)
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
.Select(i => JObject.Parse(i.PayloadJson))
.FirstOrDefault();
[Fact]
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
{
using var rig = new EnrollmentOrchestratorRig();
using var release = new ManualResetEventSlim(false);
var order = new List<string>();
var before = new Host("Before", order);
var hanging = new Host("Hanging", order, hangUntil: release);
var after = new Host("After", order);
var sut = rig.Build(
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
collectorHostStopTimeout: Bound);
sut.Start();
try
{
var stop = Task.Run(() => sut.StopCollectorHosts());
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
JObject? evt = null;
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
"the hanging host was not reported");
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
var data = (JObject)evt["Data"]!;
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
Assert.Equal("Hanging", (string?)data["host"]);
Assert.Equal(1, (int)data["timeoutSeconds"]!);
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
var fullStop = Task.Run(() => sut.Stop());
Assert.True(fullStop.Wait(Wait), "Stop never returned");
Assert.Equal(1, before.DisposeCalls);
Assert.Equal(0, hanging.DisposeCalls);
Assert.Equal(1, after.DisposeCalls);
Assert.Equal(1, hanging.StopCalls);
}
finally
{
release.Set();
}
}
[Fact]
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
{
using var rig = new EnrollmentOrchestratorRig();
var order = new List<string>();
var first = new Host("First", order);
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
var last = new Host("Last", order);
var hosts = new[] { first, throwing, last };
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
sut.Start();
sut.StopCollectorHosts();
sut.StopCollectorHosts(); // idempotent
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
Thread.Sleep(200);
Assert.Null(StopTimeoutEvent(sut));
sut.Stop();
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
}
}
}
@@ -0,0 +1,174 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using Microsoft.Win32;
using Xunit;
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
/// must never wait for a watcher callback, and a completion that coincides with a registry
/// callback must not leave the host unable to stop.
/// </summary>
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
public sealed class OfficeInstallDetectorHostTests : IDisposable
{
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private readonly string _installPath;
private Func<OfficeC2RSnapshot> _snapshot;
public OfficeInstallDetectorHostTests()
{
DeleteTestKey();
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
_installPath = Path.Combine(_tmp.Path, "Office");
Directory.CreateDirectory(_installPath);
_snapshot = Idle;
}
public void Dispose()
{
DeleteTestKey();
_tmp.Dispose();
}
private static void DeleteTestKey()
{
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
catch { /* teardown noise must not fail a test */ }
}
private static void TouchRegistry()
{
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
{
Assert.NotNull(key);
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
}
}
private OfficeInstallDetectorHost NewHost() =>
new OfficeInstallDetectorHost(
sessionId: "S1",
tenantId: "T1",
ingress: _sink,
clock: new VirtualClock(At),
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
settleSeconds: 0,
statePersistence: null,
resumeState: null,
registryHive: RegistryHive.CurrentUser,
registrySubKey: RegistrySubKey,
snapshotProvider: () => _snapshot());
// No install scenario, no install path: the detector stays idle and the host keeps
// raising OfficeExpected on registry changes.
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
private OfficeC2RSnapshot Installing()
{
var s = new OfficeC2RSnapshot
{
ConfigurationKeyPresent = true,
ActiveScenarioPresent = true,
ActiveScenarioName = "INSTALL",
InstallScenarioPresent = true,
InstallScenarioName = "INSTALL",
InstallationPath = _installPath,
VersionToReport = "16.0.1.1",
};
s.Products.Add("O365ProPlusRetail");
return s;
}
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType);
[Fact]
public void Stop_returns_while_a_registry_handler_is_running()
{
var host = NewHost();
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
try
{
host.Start();
TouchRegistry();
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
var stop = Task.Run(() => host.Stop());
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
"Stop waited for the running registry handler");
}
finally
{
release.Set();
}
}
[Fact]
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
{
var host = NewHost();
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
try
{
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
host.Start();
TouchRegistry();
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
// 2) The install starts and completes meanwhile: the binary watcher sees the core
// binary, the detector reports completed, the host tears its watchers down.
_snapshot = Installing;
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
var versionDir = Path.Combine(_installPath, "root", "Office16");
Directory.CreateDirectory(versionDir);
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
Assert.True(
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
"the install was not reported as completed");
// 3) The agent stops while that registry callback is still running.
var stop = Task.Run(() => host.Stop());
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
"Stop waited behind the completion's teardown, which waited for the running registry handler");
}
finally
{
release.Set();
}
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
}
}
}
@@ -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>
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public void Dispose()
{
Process attached;
lock (_lock)
{
if (_disposed) return;
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_discoveryTimer?.Dispose();
_discoveryTimer = null;
if (_imeProcess != null)
{
try { _imeProcess.Exited -= OnImeExited; } catch { }
_imeProcess.Dispose();
_imeProcess = null;
}
attached = _imeProcess;
_imeProcess = null;
}
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
if (attached != null)
{
try { attached.Exited -= OnImeExited; } catch { }
try { attached.Dispose(); } catch { }
}
}
}
@@ -194,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// subtree) into RJ's lifecycle.
private bool _packageWatchersArmed;
private bool _disposed;
// Set by Stop under _stateLock: a registry callback that is still running then must not
// arm or attach another watcher.
private bool _stopped;
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
@@ -228,6 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public void Start()
{
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
lock (_stateLock) { _stopped = false; }
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
@@ -277,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
try
{
var stoppedAny = false;
var watchers = new List<RegistryWatcher>(8);
lock (_stateLock)
{
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
_stopped = true;
Take(ref _servicesAppearanceWatcher, watchers);
Take(ref _machineSoftwareAppearanceWatcher, watchers);
Take(ref _userSoftwareAppearanceWatcher, watchers);
Take(ref _uninstallAppearanceWatcher, watchers);
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
Take(ref _serviceRealmjoinWatcher, watchers);
Take(ref _machineRealmJoinWatcher, watchers);
Take(ref _userRealmJoinWatcher, watchers);
Take(ref _uninstallWatcher, watchers);
}
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
// still running, and the appearance callback takes _stateLock.
foreach (var watcher in watchers)
{
try { watcher.Dispose(); } catch { }
}
// Hosts call Stop and then Dispose, which stops again; only the pass that
// stopped a watcher logs.
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
}
catch (Exception ex)
{
@@ -391,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Action clearSlot,
Action onAppeared)
{
lock (_stateLock) { if (_stopped) return; }
try
{
var watcher = new RegistryWatcher(
@@ -440,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
lock (_stateLock)
{
if (_serviceRealmjoinWatcher != null) return;
if (_stopped || _serviceRealmjoinWatcher != null) return;
try
{
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
@@ -486,7 +501,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
lock (_stateLock)
{
if (_machineRealmJoinWatcher != null) return;
if (_stopped || _machineRealmJoinWatcher != null) return;
try
{
// Seed dedup sets with every package sub-key already present at arming time.
@@ -529,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
lock (_stateLock)
{
if (_userRealmJoinWatcher != null) return;
if (_stopped || _userRealmJoinWatcher != null) return;
try
{
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
@@ -711,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
lock (_stateLock)
{
if (_disposed || _uninstallWatcher != null) return;
if (_disposed || _stopped || _uninstallWatcher != null) return;
try
{
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
@@ -941,12 +956,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
private static bool DisposeAndNull(ref RegistryWatcher? w)
// Caller holds _stateLock; the taken watchers are disposed after it is released.
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
{
if (w == null) return false;
try { w.Dispose(); } catch { }
w = null;
return true;
if (slot == null) return;
taken.Add(slot);
slot = null;
}
// Test seams — drive the read paths deterministically without touching the real registry.
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Management;
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
/// transiently is not established, so the field first collects evidence.
///
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
/// do not flag their own disabled Guest/Administrator.
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
/// Administrators member. A restriction always wins over an entry that allows more. The four
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
/// can lift or impose a role on them. Profile folders have no role.
///
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
/// catch and is never exempted by its own login (CWE-807: the login state is under the
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
///
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
/// enabling delta detection between pre- and post-enrollment state.
///
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
/// see <see cref="MatchesAllowedEntry"/>.
/// </summary>
public class LocalAdminAnalyzer : IAgentAnalyzer
{
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly List<string> _allowedAccounts;
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
{
"Administrator",
"Guest",
"DefaultAccount",
"WDAGUtilityAccount",
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
"defaultuser1", // Sometimes seen in OOBE, but not always present
"defaultuser2", // Sometimes seen in OOBE, but not always present
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
"Public", // Profile folder (not a user account)
"Default", // Default user profile template
"Default User", // Symlink to Default in some Windows versions
"All Users" // Junction pointing to C:\ProgramData, always present
};
private readonly List<AllowedEntry> _allowedEntries;
public string Name => "LocalAdminAnalyzer";
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_startupGate = startupGate;
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
if (allowedAccounts != null)
{
foreach (var account in allowedAccounts)
{
if (!string.IsNullOrWhiteSpace(account) &&
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
{
_allowedAccounts.Add(account);
}
}
}
var ignored = new List<string>();
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
foreach (var entry in ignored)
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
}
public void AnalyzeAtStartup()
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
foreach (var user in loggedIn.LocalAccounts)
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
var effectiveAllowed = _allowedAccounts;
// Effective list: static entries + the profile folders of non-local logged-in users at
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
// inventory failed and the split above could not recognise a local name.
var effectiveEntries = _allowedEntries;
if (loggedIn.Allowed.Count > 0)
{
effectiveAllowed = new List<string>(_allowedAccounts);
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
foreach (var user in loggedIn.Allowed)
{
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
{
effectiveAllowed.Add(user);
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
}
}
}
var bypassNroResult = CheckBypassNroRegistry();
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
var profilesResult = CheckUserProfiles(effectiveAllowed);
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
var profilesResult = CheckUserProfiles(effectiveEntries);
int confidenceScore = 0;
if (bypassNroResult.Value == 1)
confidenceScore += 20;
if (accountsResult.Unexpected.Count > 0)
confidenceScore += 40;
// An unexpected account that is also a member of Administrators is the
// actual backdoor (enabled or dormant) — high indicator on its own.
if (accountsResult.UnexpectedAdminMembers.Count > 0)
confidenceScore += 40;
// Profile overlap: unexpected account AND matching C:\Users folder
bool profileOverlap = accountsResult.Unexpected.Any(a =>
profilesResult.Unexpected.Any(p =>
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
if (profileOverlap)
confidenceScore += 40;
confidenceScore = Math.Min(confidenceScore, 100);
var confidenceScore = ComputeConfidenceScore(
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
EventSeverity severity;
string findingLabel;
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
$"bypassNro={bypassNroResult.Value}, " +
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
$"roleViolations={accountsResult.RoleViolations.Count}, " +
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{ "finding", findingLabel },
{ "triggered_at", trigger },
{ "enrollment_phase_at_check", phase.ToString() },
{ "allowed_accounts", effectiveAllowed },
// Account entries in canonical form ("Guest|user"); the folder-only entries are
// constant or listed in dynamically_allowed_users.
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
{ "dynamically_allowed_users", loggedIn.Allowed },
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
{ "checks", new Dictionary<string, object>
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
},
{ "unexpected_accounts", accountsResult.Unexpected },
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
{ "role_violations", accountsResult.RoleViolations },
{ "unexpected_profiles", profilesResult.Unexpected },
{ "accounts_checked", accountsResult.AllChecked },
{ "account_details", accountsResult.AccountDetails },
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
}
/// <summary>
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
/// </summary>
internal static int ComputeConfidenceScore(
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
{
var score = 0;
if (bypassNro)
score += 20;
if (unexpectedAccounts.Count > 0)
score += 40;
if (unexpectedAdminMembers.Count > 0)
score += 40;
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
score += 40;
return Math.Min(score, 100);
}
// -----------------------------------------------------------------------
// Allowed-list matching
// -----------------------------------------------------------------------
/// <summary>
/// Matches an account or profile-folder name against one allowed-list entry.
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
/// Matches an account or profile-folder name against one allowed-list pattern.
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
/// <c>?</c> are invalid characters in Windows account names.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool MatchesAllowedEntry(string name, string entry)
internal static bool MatchesAllowedEntry(string name, string pattern)
{
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
var regexPattern = "^" + Regex.Escape(entry)
var regexPattern = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
try
{
// Timeout guards against pathological patterns (ReDoS)
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
}
catch (RegexMatchTimeoutException)
{
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
}
private static bool IsAllowed(string name, List<string> allowedAccounts)
/// <summary>
/// The effective static list: the built-in account-name and profile-folder entries of
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
/// </summary>
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
{
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
var entries = new List<AllowedEntry>();
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
{
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
}
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
{
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
else if (!string.IsNullOrWhiteSpace(raw))
ignored.Add(raw);
}
return entries;
}
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
{
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
entries.Add(entry);
}
/// <summary>
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
/// RID. Every other account is allowed when an account entry matches it, unless it is an
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
/// Pure; internal for unit-testing.
/// </summary>
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
{
var builtIn = FindBuiltInRidEntry(name, sid);
if (builtIn != null)
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
var covered = false;
var restricted = false;
foreach (var entry in allowed)
{
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
continue;
covered = true;
if (entry.Role == LocalAccountRole.StandardUser)
restricted = true;
}
if (!covered)
return AccountDecision.Unlisted;
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
}
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
{
var rid = MachineAccountRid(sid);
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
{
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
continue;
var matches = rid.HasValue
? builtIn.Rid == rid.Value
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
if (matches)
return builtIn;
}
return null;
}
/// <summary>
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
/// and the member filter already restrict to local accounts), else null.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static bool IsWellKnownBuiltInRid(string sid)
internal static int? MachineAccountRid(string sid)
{
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
return false;
return null;
var dash = sid.LastIndexOf('-');
if (dash < 0 || dash == sid.Length - 1)
return false;
switch (sid.Substring(dash + 1))
{
case "500":
case "501":
case "503":
case "504":
return true;
default:
return false;
}
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
? rid
: (int?)null;
}
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
/// <summary>
/// Splits the logged-in user names into those that may be allowed dynamically (not a
/// local SAM account — Entra / domain identities whose only artifact is a profile
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
return accounts;
}
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
{
// Administrators-group membership is read from the SAM, independent of WMI and of
// the enabled state — a dormant backdoor with admin rights is still a member.
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
if (!group.Succeeded)
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
result.AdministratorsGroupErrorCode = group.ErrorCode;
foreach (var name in result.Unexpected)
_logger.Debug($"{Name}: Unexpected local account: {name}");
foreach (var name in result.UnexpectedAdminMembers)
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
foreach (var name in result.RoleViolations)
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
return result;
}
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// - every local account is checked, disabled ones included (state is reported);
/// - a local account is an Administrators member when its SID matches a group member,
/// or (SID unavailable) its name matches a member of the local machine domain;
/// - an account is allowed by name (allowed list, globs) or by well-known RID
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
/// members not on the allowed list that the WMI inventory did not return at all;
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
/// holds membership; the latter also a role violation);
/// - local members the WMI inventory did not return at all are decided as administrators
/// and land in both unexpected lists, like any unknown administrator;
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
/// standard-user entry acts like a plain one;
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
/// listed for delta comparison but never flagged — they are expected on joined devices.
/// </summary>
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
IList<LocalGroupMember> adminMembers,
bool adminGroupEnumerated,
string machineName,
List<string> allowedAccounts)
IReadOnlyList<AllowedEntry> allowed)
{
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
}
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var account in accounts ?? new List<LocalAccountInfo>())
{
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|| adminLocalNames.Contains(account.Name);
var rid = MachineAccountRid(account.Sid);
if (!string.IsNullOrEmpty(account.Sid))
inventorySids.Add(account.Sid);
inventoryNames.Add(account.Name);
result.AllChecked.Add(account.Name);
result.AccountDetails.Add(new Dictionary<string, object>
{
{ "name", account.Name },
{ "disabled", account.Disabled },
{ "administrators_member", isAdmin }
{ "administrators_member", isAdmin },
{ "rid", rid }
});
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
if (rid == 500)
result.BuiltInAdministratorEnabled = !account.Disabled;
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
continue;
result.Unexpected.Add(account.Name);
if (isAdmin)
result.UnexpectedAdminMembers.Add(account.Name);
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
}
// Local members the WMI inventory did not surface (WMI failure, or an account the
// provider does not list) — still an unexpected admin when not on the allowed list.
// provider does not list): the SAM is authoritative for membership.
foreach (var member in adminLocalMembers)
{
var name = member.Name;
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
? inventorySids.Contains(member.Sid)
: inventoryNames.Contains(member.Name);
if (returnedByWmi)
continue;
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
continue;
result.UnexpectedAdminMembers.Add(name);
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
}
return result;
}
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
{
if (decision == AccountDecision.Allowed)
return;
AddOnce(result.Unexpected, name);
if (isAdmin)
AddOnce(result.UnexpectedAdminMembers, name);
if (decision == AccountDecision.RoleViolation)
AddOnce(result.RoleViolations, name);
}
private static void AddOnce(List<string> names, string name)
{
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
names.Add(name);
}
private static bool IsLocalMember(LocalGroupMember member, string machineName)
{
var domain = member.Domain;
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
}
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
{
var allFound = new List<string>();
var unexpected = new List<string>();
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
allFound.Add(folderName);
if (!IsAllowed(folderName, allowedAccounts))
if (!IsAllowedFolder(folderName, allowed))
{
unexpected.Add(folderName);
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
public List<string> RoleViolations { get; set; } = new List<string>();
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
public bool? BuiltInAdministratorEnabled { get; set; }
}
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
internal sealed class AllowedEntry
{
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
{
Pattern = pattern;
Role = role;
ProfileFolderOnly = profileFolderOnly;
}
public string Pattern { get; }
public LocalAccountRole Role { get; }
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
public bool ProfileFolderOnly { get; }
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
public string Display => LocalAdminAllowList.Format(Pattern, Role);
}
internal enum AccountDecision
{
Allowed,
/// <summary>No entry allows the account.</summary>
Unlisted,
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
RoleViolation,
}
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
internal sealed class LoggedInUserSplit
{
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
public void Start()
{
bool raise;
lock (_lock)
{
if (_disposed) return;
TryArm();
raise = TryArm();
}
if (raise) Raise();
}
/// <summary>
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
}
// Caller holds _lock.
private void TryArm()
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
private bool TryArm()
{
if (_disposed || _fsw != null) return;
if (_disposed || _fsw != null) return false;
// Initial scan — the binaries may already be present (armed late / update over existing).
if (CoreBinariesPresent())
{
Raise();
return;
}
if (CoreBinariesPresent()) return true;
if (!Directory.Exists(_installationPath))
{
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
}
return;
return false;
}
try
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
// Race: a binary may have appeared between the scan above and arming — re-scan once.
if (CoreBinariesPresent()) Raise();
return CoreBinariesPresent();
}
catch (Exception ex)
{
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
return false;
}
}
private void OnArmRetry(object? state)
{
lock (_lock) { TryArm(); }
bool raise;
lock (_lock) { raise = TryArm(); }
if (raise) Raise();
}
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Management;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
/// idle polling:
/// <list type="bullet">
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
/// pattern as <c>ImeProcessWatcher</c>).</item>
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
/// startup probe still covers the already-running case — no polling fallback by design.
/// </para>
/// <para>
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
/// </para>
/// </summary>
public sealed class OfficeProcessWatcher : IDisposable
{
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
/// </summary>
public event EventHandler? Stopped;
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
/// declared closed; a new worker within it keeps the window open. &lt;=0 closes immediately.</param>
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
get { lock (_lock) { return _active; } }
}
public void Start()
public void Start() => Start(armWmi: true);
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
internal void Start(bool armWmi)
{
lock (_lock)
{
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
_started = true;
}
// 1) Startup probe — catch a worker already running before the watcher armed.
// 1) Startup probe — catch a worker already running before the subscription exists. It
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
// reported even when the subscription below never completes.
ProbeRunningWorkers();
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
// after another on it).
if (armWmi) Task.Run(() => ArmStartTrace());
}
private void ProbeRunningWorkers()
{
try
{
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
foreach (var proc in WorkerSource())
{
TrackWorker(proc);
}
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
}
}
// 2) ETW-backed push on worker creation — no polling.
private void ArmStartTrace()
{
ManagementEventWatcher? watcher = null;
try
{
var query = new WqlEventQuery(
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
_startWatcher = new ManagementEventWatcher(query);
_startWatcher.EventArrived += OnProcessStartTrace;
_startWatcher.Start();
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
watcher = new ManagementEventWatcher(new WqlEventQuery(
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
watcher.EventArrived += OnProcessStartTrace;
watcher.Start();
}
catch (Exception ex)
{
// No polling fallback by design — the startup probe already covers the in-flight case.
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
_startWatcher = null;
if (watcher != null) TearDownStartTrace(watcher);
return;
}
// Published only once it is armed, so Dispose never meets a half-started subscription; one
// that completes after Dispose is taken down right here.
bool keep;
lock (_lock)
{
keep = !_disposed;
if (keep) _startWatcher = watcher;
}
if (!keep)
{
TearDownStartTrace(watcher);
return;
}
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
// A worker may have started between the probe in Start and the subscription.
ProbeRunningWorkers();
}
private void TearDownStartTrace(ManagementEventWatcher watcher)
{
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
try { watcher.Stop(); } catch { }
try { watcher.Dispose(); } catch { }
}
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
@@ -128,39 +181,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
private void TrackWorker(Process proc)
{
bool tracked = false;
bool raiseStarted = false;
try
{
lock (_lock)
{
if (_disposed) { try { proc.Dispose(); } catch { } return; }
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
if (!_disposed && !_workers.ContainsKey(proc.Id))
{
try
{
proc.EnableRaisingEvents = true;
proc.Exited += OnWorkerExited;
tracked = true;
}
catch
{
// Process exited between discovery and Exited-hookup — drop it.
}
try
{
proc.EnableRaisingEvents = true;
proc.Exited += OnWorkerExited;
}
catch
{
// Process exited between discovery and Exited-hookup — drop it.
try { proc.Dispose(); } catch { }
return;
}
_workers[proc.Id] = proc;
// A worker is back — cancel any pending settle (the window must stay open).
CancelSettleTimer();
if (!_active)
{
_active = true;
raiseStarted = true;
if (tracked)
{
_workers[proc.Id] = proc;
// A worker is back — cancel any pending settle (the window must stay open).
CancelSettleTimer();
if (!_active)
{
_active = true;
raiseStarted = true;
}
}
}
}
}
catch (Exception ex)
{
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
}
if (!tracked)
{
// Disposed, already tracked under another handle, or gone before the hookup.
try { proc.Dispose(); } catch { }
return;
}
@@ -169,6 +231,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
RaiseStarted();
}
// .NET raises Exited once per Process object. A worker that exited before the hookup
// above was subscribed would stay tracked forever and the window would never close.
if (HasExitedSafe(proc))
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
}
private void OnWorkerExited(object? sender, EventArgs e)
@@ -178,11 +245,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
lock (_lock)
{
if (_workers.Remove(SafePid(proc)))
{
try { proc.Exited -= OnWorkerExited; } catch { }
try { proc.Dispose(); } catch { }
}
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
// and TrackWorker's own re-check can deliver the same exit a second time.
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
return;
_workers.Remove(SafePid(proc));
if (_active && _workers.Count == 0)
{
if (_settleSeconds <= 0)
@@ -200,6 +268,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
}
try { proc.Exited -= OnWorkerExited; } catch { }
try { proc.Dispose(); } catch { }
if (raiseStoppedNow)
{
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
@@ -260,31 +331,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
try { return proc.Id; } catch { return -1; }
}
private static bool HasExitedSafe(Process proc)
{
try { return proc.HasExited; } catch { return false; }
}
public void Dispose()
{
ManagementEventWatcher? startWatcher;
Process[] workers;
lock (_lock)
{
if (_disposed) return;
_disposed = true;
}
if (_startWatcher != null)
{
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
try { _startWatcher.Stop(); } catch { }
try { _startWatcher.Dispose(); } catch { }
startWatcher = _startWatcher;
_startWatcher = null;
CancelSettleTimer();
workers = new Process[_workers.Count];
_workers.Values.CopyTo(workers, 0);
_workers.Clear();
}
lock (_lock)
if (startWatcher != null)
{
CancelSettleTimer();
foreach (var kv in _workers)
{
try { kv.Value.Exited -= OnWorkerExited; } catch { }
try { kv.Value.Dispose(); } catch { }
}
_workers.Clear();
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
// is the agent's stop path, so it never waits for it.
var armed = startWatcher;
Task.Run(() => TearDownStartTrace(armed));
}
foreach (var worker in workers)
{
try { worker.Exited -= OnWorkerExited; } catch { }
try { worker.Dispose(); } catch { }
}
}
}
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
/// </para>
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
/// <para>
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
/// an owner that disposes this watcher under a lock its handler also takes.
/// </para>
/// </summary>
public sealed class RegistryChangeWatcher : IDisposable
{
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
// The registration must outlive the thread that issued it: re-arming happens on rotating
// ThreadPool threads, and without this flag Windows signals the event when such a thread
// ends — reported here as a change that never happened.
ThreadAgnostic = 0x10000000,
}
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
private const RegNotifyFilter PrimaryFilter =
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
// Cheap: direct-child create/delete only.
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
[DllImport("advapi32.dll", SetLastError = true)]
private static extern int RegNotifyChangeKeyValue(
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
private enum WatchMode { None, Primary, Bootstrap }
private readonly RegistryHive _hive;
private readonly string _hiveLabel;
private readonly string _targetSubKey;
private readonly AgentLogger _logger;
private readonly object _lock = new object();
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
: this(RegistryHive.LocalMachine, targetSubKey, logger)
{
}
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
{
_hive = hive;
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>The filter the full target watch is armed with (tests).</summary>
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
public void Start()
{
bool raise = false;
lock (_lock)
{
if (_started || _disposed) return;
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
// resolve the stale WOW6432Node mirror).
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
_signal = new AutoResetEvent(false);
ArmBestEffort(raiseOnPromote: false);
raise = ArmBestEffort(targetIsNews: false);
}
catch (Exception ex)
{
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
}
}
if (raise) RaiseChanged();
}
/// <summary>
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
/// for it — after releasing the lock.
/// </summary>
private void ArmBestEffort(bool raiseOnPromote)
/// <param name="targetIsNews">False when finding the target right away is the expected start
/// state; true when the watch was waiting for it.</param>
private bool ArmBestEffort(bool targetIsNews)
{
if (_disposed || _baseKey == null) return;
if (_disposed || _baseKey == null) return false;
CloseWatchedKey();
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
{
_watchedKey = target;
_mode = WatchMode.Primary;
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
if (raiseOnPromote) RaiseChanged();
return;
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
}
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
if (ancestor == null)
{
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
return;
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
return false;
}
_watchedKey = ancestor;
_mode = WatchMode.Bootstrap;
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
using (var recheck = TryOpen(_targetSubKey))
{
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
return recheck != null && ArmBestEffort(targetIsNews: true);
}
}
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
if (rc != 0)
{
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
return false;
}
UnregisterWait();
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
catch (Exception ex)
{
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
return false;
}
}
private void OnSignalled(object? state, bool timedOut)
{
bool raise;
bool rearmAfterHandler;
lock (_lock)
{
UnregisterWait();
if (_disposed || timedOut) return;
if (_mode == WatchMode.Primary)
{
RaiseChanged();
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
}
else
{
// A direct child changed under the ancestor — promote to the target watch if it now
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
ArmBestEffort(raiseOnPromote: true);
}
rearmAfterHandler = _mode == WatchMode.Primary;
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
}
if (raise) RaiseChanged();
if (!rearmAfterHandler) return;
lock (_lock)
{
// Re-arm on the same target key, and only now: no wait was registered while the handler
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
// registration at once.
ArmNotify(subtree: true, PrimaryFilter);
}
}
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
}
}
// Never called with _lock held.
private void RaiseChanged()
{
var handler = Changed;
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
/// this only ever ends the wait for a host that hangs.
/// </summary>
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
private readonly string _sessionId;
private readonly string _tenantId;
private readonly string _stateDirectory;
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly int _quarantineThreshold;
private readonly TimeSpan _drainInterval;
private readonly TimeSpan _terminalDrainTimeout;
private readonly TimeSpan _collectorHostStopTimeout;
private readonly TimeSpan? _agentMaxLifetime;
private readonly int _uploadBatchSize;
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private SessionTraceOrdinalProvider? _traceCounter;
private SignalIngress? _ingress;
private IReadOnlyList<ICollectorHost>? _collectorHosts;
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
// so nothing calls into them again. Locked on itself.
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int uploadBatchSize = 100,
IDeadlineScheduler? schedulerOverride = null,
Func<bool>? traceEventsEnabled = null,
Func<bool>? espContinueAnywayObservationEnabled = null)
Func<bool>? espContinueAnywayObservationEnabled = null,
TimeSpan? collectorHostStopTimeout = null)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_quarantineThreshold = quarantineThreshold;
_drainInterval = drainInterval ?? DefaultDrainInterval;
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
/// <para>
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
/// self-destruct come after this call, so it has to return whatever a collector does.
/// </para>
/// </summary>
public void StopCollectorHosts()
{
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
foreach (var host in _collectorHosts)
{
try { host.Stop(); }
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
RunHostStepBounded(host, "stop", h => h.Stop());
}
}
/// <summary>
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
/// <see cref="_collectorHostStopTimeout"/> for it.
/// </summary>
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
{
lock (_unresponsiveHosts)
{
if (_unresponsiveHosts.Contains(host)) return;
}
Exception? failure = null;
var worker = new Thread(() =>
{
try { action(host); }
catch (Exception ex) { failure = ex; }
})
{
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
// be the very thing that is stuck. Background, so it cannot keep the process alive.
IsBackground = true,
Name = $"CollectorHost.{step}:{host.Name}",
};
try { worker.Start(); }
catch (Exception ex)
{
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
return;
}
if (worker.Join(_collectorHostStopTimeout))
{
if (failure == null) return;
if (step == "stop")
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
else
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
return;
}
lock (_unresponsiveHosts)
{
_unresponsiveHosts.Add(host);
}
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
_logger.Warning(
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
"termination continues without it.");
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
}
/// <summary>
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
/// Dispose is logged only — the ingress is already down by then.)
/// </summary>
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
{
var ingress = _ingress;
if (ingress == null) return;
try
{
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.AgentTrace,
Severity = Shared.Models.EventSeverity.Warning,
Source = "EnrollmentOrchestrator",
Phase = Shared.Models.EnrollmentPhase.Unknown,
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
Timestamp = _clock.UtcNow,
Data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["decision"] = "collector_host_stop_timeout",
["host"] = hostName,
["timeoutSeconds"] = timeoutSeconds,
},
});
}
catch (Exception ex)
{
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
}
}
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
// 7) Dispose aller Disposables.
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
if (_collectorHosts != null)
{
foreach (var host in _collectorHosts)
{
try { host.Dispose(); } catch { /* best-effort */ }
RunHostStepBounded(host, "dispose", h => h.Dispose());
}
}
try { _transport?.Dispose(); } catch { /* best-effort */ }
@@ -4,6 +4,7 @@ using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
using AutopilotMonitor.DecisionCore.Engine;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly OfficeInstallDetector _detector;
private readonly OfficeProcessWatcher _processWatcher;
private readonly AgentLogger _logger;
// Guards the fields below, nothing else. Never held across a call into a watcher or the
// detector: their callbacks come back into this host and take it.
private readonly object _lock = new object();
private readonly OfficeInstallStateData? _resumeState;
private readonly RegistryHive _registryHive;
private readonly string _registrySubKey;
private RegistryChangeWatcher? _registryWatcher;
private OfficeBinaryWatcher? _binaryWatcher;
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int settleSeconds,
OfficeInstallStatePersistence? statePersistence = null,
OfficeInstallStateData? resumeState = null)
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
{
}
/// <summary>
/// Test seam: points the registry watcher at a key a test may write to and feeds the
/// detector synthetic snapshots. Production uses the constructor above.
/// </summary>
internal OfficeInstallDetectorHost(
string sessionId,
string tenantId,
ISignalIngressSink ingress,
IClock clock,
AgentLogger logger,
int settleSeconds,
OfficeInstallStatePersistence? statePersistence,
OfficeInstallStateData? resumeState,
RegistryHive registryHive,
string registrySubKey,
Func<OfficeC2RSnapshot>? snapshotProvider)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_resumeState = resumeState;
_registryHive = registryHive;
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
var post = new InformationalEventPost(ingress, clock);
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
onInstallationPathObserved: OnInstallationPathObserved,
statePersistence: statePersistence);
statePersistence: statePersistence)
{
SnapshotProvider = snapshotProvider,
};
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
_processWatcher.Started += OnWorkerStarted;
}
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
// case where the ClickToRun key does not exist yet on a clean first install.
lock (_lock)
{
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
_registryWatcher.Changed += OnRegistryChanged;
_registryWatcher.Start();
}
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
registryWatcher.Changed += OnRegistryChanged;
lock (_lock) { _registryWatcher = registryWatcher; }
registryWatcher.Start();
_processWatcher.Start();
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
bool raise;
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
if (raise)
// A callback can still be running after Dispose returned — Dispose never waits for one.
if (raise && Volatile.Read(ref _disposed) == 0)
{
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private void OnInstallationPathObserved(string? installationPath)
{
OfficeBinaryWatcher watcher;
lock (_lock)
{
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
if (Volatile.Read(ref _disposed) == 1) return;
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
_binaryWatcher.Start();
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
watcher.BinaryAppeared += OnBinaryAppeared;
_binaryWatcher = watcher;
}
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
// ends in DisposeWatchers.
watcher.Start();
}
private void OnBinaryAppeared(object? sender, EventArgs e)
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private void DisposeWatchers()
{
RegistryChangeWatcher? registryWatcher;
OfficeBinaryWatcher? binaryWatcher;
lock (_lock)
{
if (_registryWatcher != null)
{
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
try { _registryWatcher.Dispose(); } catch { }
_registryWatcher = null;
}
if (_binaryWatcher != null)
{
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
try { _binaryWatcher.Dispose(); } catch { }
_binaryWatcher = null;
}
registryWatcher = _registryWatcher;
_registryWatcher = null;
binaryWatcher = _binaryWatcher;
_binaryWatcher = null;
}
if (registryWatcher != null)
{
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
try { registryWatcher.Dispose(); } catch { }
}
if (binaryWatcher != null)
{
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
try { binaryWatcher.Dispose(); } catch { }
}
}
}
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
// after `diagnostics_collecting` and the snapshot captured in the package
// matches the timeline. Best-effort — never blocks termination.
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
// a collector that hangs is left behind, so the upload and the cleanup below
// still run.
StopPeripheralCollectorsBestEffort();
RunUploadDiagnosticsWithEvents(args);
@@ -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>
@@ -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);
}
@@ -181,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();
@@ -190,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);
@@ -1093,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));
}
}
@@ -93,6 +93,39 @@ public class AuthFunctionTests
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
}
[Fact]
public void HappyPath_MemberWithARoleGetsTheCompanyName()
{
var config = DefaultConfig();
config.CompanyName = " Contoso Ltd. ";
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
}
[Fact]
public void HappyPath_RolelessMemberGetsNoCompanyName()
{
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
var config = DefaultConfig();
config.CompanyName = "Contoso Ltd.";
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.Null((string?)ToDynamic(result.Body).companyName);
}
[Theory]
[InlineData("platform", "platform")]
[InlineData("Platform", "platform")]
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Functions.Services;
@@ -218,10 +219,12 @@ public class AutopilotDeviceValidatorTests
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 = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
@@ -241,7 +244,7 @@ public class AutopilotDeviceValidatorTests
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
@@ -256,7 +259,7 @@ public class AutopilotDeviceValidatorTests
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
@@ -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>
@@ -202,6 +202,28 @@ public class ConfigBackupHookTests
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_SetupGuideMarkers_NoSnapshot()
{
// The setup guide's markers would otherwise push real settings out of the two backup slots.
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
incoming.SetupGuideDismissedBy = "admin@contoso.com";
incoming.LastUpdated = DateTime.UtcNow;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", 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()
{
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
// ---- UpdateTenantConfiguration -------------------------------------------------------
// ---- RecordSetupGuideStep ------------------------------------------------------------
[Fact]
public void SetupGuideStateResponse_matches_the_marker_shape()
{
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
DateTime? dismissedAt = null;
string? dismissedBy = null;
AssertParity(
new
{
success = true,
settingsReviewedAt,
hardwareReviewedAt,
dismissedAt,
dismissedBy
},
new SetupGuideStateResponse
{
Success = true,
SettingsReviewedAt = settingsReviewedAt,
HardwareReviewedAt = hardwareReviewedAt,
DismissedAt = null,
DismissedBy = null
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,198 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Functions.Telemetry;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.DependencyCollector;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.DependencyInjection;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
/// and both credential clients (webhooks, push) carry the mark.
/// </summary>
public class CredentialUrlRedactionProcessorTests
{
private sealed class CapturingProcessor : ITelemetryProcessor
{
public List<ITelemetry> Received { get; } = new();
public void Process(ITelemetry item) => Received.Add(item);
}
private sealed class CapturingChannel : ITelemetryChannel
{
private readonly List<ITelemetry> _items = new();
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
public bool? DeveloperMode { get; set; }
public string? EndpointAddress { get; set; }
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
public void Flush() { }
public void Dispose() { }
}
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
processor.Process(row);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
}
}
private static DependencyTelemetry Row(string url, string method = "POST")
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
[Theory]
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
{
var dependency = Row(url);
CredentialUrlRedactionProcessor.Redact(dependency);
Assert.Equal(expected, dependency.Data);
Assert.Equal("POST " + expected, dependency.Name);
}
[Fact]
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
{
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
CredentialUrlRedactionProcessor.Redact(dependency);
Assert.Equal("discord.com", dependency.Data);
Assert.Equal("POST discord.com", dependency.Name);
}
[Fact]
public void Rows_outside_a_marked_request_are_untouched()
{
var next = new CapturingProcessor();
var processor = new CredentialUrlRedactionProcessor(next);
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
processor.Process(dependency);
Assert.Same(dependency, Assert.Single(next.Received));
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
Assert.Equal("GET /v1.0/devices", dependency.Name);
}
[Fact]
public async Task The_mark_covers_the_request_and_ends_with_it()
{
var next = new CapturingProcessor();
var processor = new CredentialUrlRedactionProcessor(next);
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
{
InnerHandler = new TrackingInnerHandler(processor, inside),
});
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
processor.Process(after);
Assert.Equal("https://discord.com", inside.Data);
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
}
[Fact]
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
{
var channel = new CapturingChannel();
using var configuration = new TelemetryConfiguration
{
TelemetryChannel = channel,
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
};
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
using var collector = new DependencyTrackingTelemetryModule();
collector.Initialize(configuration);
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = AnswerNoContentAsync(listener, connections: 2);
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
using (var plain = new HttpClient(new SocketsHttpHandler()))
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
await server;
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
Assert.Equal(2, rows.Count);
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
}
finally
{
listener.Stop();
}
}
[Theory]
[InlineData(nameof(WebhookNotificationService))]
[InlineData(PushHttpClientRegistration.ClientName)]
public void Both_credential_clients_carry_the_mark(string clientName)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ResiliencePolicies>();
services.AddWebhookNotificationHttpClient();
services.AddPushSender();
using var provider = services.BuildServiceProvider();
var handlers = new List<HttpMessageHandler>();
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
handler != null;
handler = (handler as DelegatingHandler)?.InnerHandler)
{
handlers.Add(handler);
}
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
}
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
{
for (var i = 0; i < connections; i++)
{
using var socket = await listener.AcceptTcpClientAsync();
var stream = socket.GetStream();
var buffer = new byte[8192];
var received = new StringBuilder();
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
{
var read = await stream.ReadAsync(buffer);
if (read == 0) break;
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
}
// The request body ("{}") follows the headers; the 204 does not wait for it.
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
}
}
}
@@ -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)
}
@@ -640,4 +640,116 @@ public class DistressValidationTests
{
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
}
// =========================================================================
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
// the whitelist is stored as HardwareNotAllowed.
// =========================================================================
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
{
var config = TenantConfiguration.CreateDefault(ResolveTenant);
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
change?.Invoke(config);
return config;
}
private static DistressErrorType Resolve(
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
DistressCertSourceState? certState = null)
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
[Fact]
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
{
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
}
[Fact]
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
{
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
}
[Theory]
[InlineData("Dell Inc.", "Latitude 5440")]
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
[InlineData("HP", "EliteBook 840 G10")]
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
{
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
}
[Fact]
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
{
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
}
[Theory]
[InlineData(null, "VMware20,1")]
[InlineData("VMware, Inc.", null)]
[InlineData("", "")]
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
{
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
}
[Fact]
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
{
// The validator refuses a suspended tenant before it looks at the hardware.
var config = ValidatingTenant(c => c.Disabled = true);
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
}
[Fact]
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
{
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
}
[Fact]
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
{
var config = ValidatingTenant(c =>
{
c.ValidateAutopilotDevice = false;
c.AllowInsecureAgentRequests = true;
});
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
}
[Fact]
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
{
// Without a certificate the agent authenticates with a bootstrap token, which skips the
// hardware check.
Assert.Equal(DistressErrorType.DeviceNotRegistered,
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
}
[Theory]
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
{
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
}
}
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
var reports = new List<DistressReportEntry>
{
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
@@ -64,6 +64,11 @@ public class GetTenantFeatureFlagsPayloadTests
"graphAccessRejected",
"intuneDeviceBindingPermissionMissing",
"isTrial",
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
"setupGuideDismissed",
"setupGuideHardwareDone",
"setupGuideSettingsDone",
"showScriptOutput",
"trialAvailable",
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
@@ -520,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.DoesNotContain("Secret Holdings", json);
}
// ── setup guide ──
[Fact]
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
{
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
Assert.False(payload.SetupGuideSettingsDone);
Assert.False(payload.SetupGuideHardwareDone);
Assert.False(payload.SetupGuideDismissed);
Assert.False(payload.HasEnrollmentSessions);
}
[Fact]
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
{
var config = TenantConfiguration.CreateDefault("t");
config.SetupGuideSettingsReviewedAt = Now;
config.SetupGuideHardwareReviewedAt = Now;
config.SetupGuideDismissedAt = Now;
config.SetupGuideDismissedBy = "admin@contoso.com";
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
Assert.True(payload.SetupGuideSettingsDone);
Assert.True(payload.SetupGuideHardwareDone);
Assert.True(payload.SetupGuideDismissed);
}
[Fact]
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
{
var element = Serialize(TenantConfiguration.CreateDefault("t"));
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
}
[Fact]
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
{
var config = TenantConfiguration.CreateDefault("t");
config.SetupGuideDismissedAt = Now;
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("hidden.admin", json);
}
}
@@ -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,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);
}
@@ -47,11 +47,12 @@ public class GraphTokenServiceTests
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
IMemoryCache? cache = null) =>
new GraphTokenService(
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>(),
@@ -131,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()
{
@@ -451,7 +499,7 @@ public class GraphTokenServiceTests
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.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);
}
@@ -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()
{
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
mcpClientRegistrationEnabled = true,
whatsNewSeenPlatformUtc = platformSeen,
whatsNewSeenAgentUtc = agentSeen,
dpaAcceptancePending = true
dpaAcceptancePending = true,
companyName = "Contoso Ltd."
},
new AuthMeResponse
{
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
McpClientRegistrationEnabled = true,
WhatsNewSeenPlatformUtc = platformSeen,
WhatsNewSeenAgentUtc = agentSeen,
DpaAcceptancePending = true
DpaAcceptancePending = true,
CompanyName = "Contoso Ltd."
});
}
@@ -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);
@@ -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()
{
@@ -0,0 +1,144 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> is the one definition of the allowed-list entry
/// grammar; the agent tests run its parse cases against the netstandard2.0 build, the portal runs the file
/// against <c>allowedAccounts.ts</c>. Plus the list-level write rules of
/// <see cref="TenantConfigValidation.ValidateLocalAdminAllowedAccounts"/>.
/// </summary>
public class LocalAdminAllowListParityTests
{
private static readonly Lazy<JObject> Fixture = new(() =>
JObject.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "local-admin-allow-list", "cases.json"))));
public static IEnumerable<object[]> ParseEntries() => Fixture.Value["parse"]!.Select(c => new object[] { c.Value<string>("entry")! });
public static IEnumerable<object[]> WriteEntries() => Fixture.Value["write"]!.Select(c => new object[] { c.Value<string>("entry")! });
[Theory]
[MemberData(nameof(ParseEntries))]
public void Parse_matches_the_shared_case(string entry)
{
var c = Fixture.Value["parse"]!.First(x => x.Value<string>("entry") == entry);
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
if (c.Value<bool?>("ignored") == true)
{
Assert.False(parsed);
return;
}
Assert.True(parsed);
Assert.Equal(c.Value<string>("pattern"), pattern);
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
}
[Theory]
[MemberData(nameof(WriteEntries))]
public void New_entry_validation_matches_the_shared_case(string entry)
{
var c = Fixture.Value["write"]!.First(x => x.Value<string>("entry") == entry);
var error = LocalAdminAllowList.ValidateNewEntry(entry);
if (c.Value<bool>("valid"))
Assert.Null(error);
else
Assert.NotNull(error);
}
[Fact]
public void Shared_cases_are_present()
{
Assert.True(Fixture.Value["parse"]!.Count() >= 15, "cases.json lost parse cases.");
Assert.True(Fixture.Value["write"]!.Count() >= 25, "cases.json lost write cases.");
}
[Fact]
public void Builtin_catalog_keeps_the_four_rid_accounts_and_the_standard_user_system_accounts()
{
var rid = LocalAdminAllowList.BuiltIns.Where(b => b.Match == LocalAdminBuiltInMatch.Rid).ToDictionary(b => b.Rid!.Value, b => b.Role);
Assert.Equal(LocalAccountRole.Any, rid[500]);
Assert.Equal(LocalAccountRole.StandardUser, rid[501]);
Assert.Equal(LocalAccountRole.StandardUser, rid[503]);
Assert.Equal(LocalAccountRole.StandardUser, rid[504]);
string RoleOf(string name) => LocalAdminAllowList.BuiltIns
.Single(b => b.Match == LocalAdminBuiltInMatch.AccountName && b.Name == name).Role.ToString();
Assert.Equal("StandardUser", RoleOf("WsiAccount"));
Assert.Equal("StandardUser", RoleOf("kioskUser0"));
Assert.Equal("StandardUser", RoleOf("Administrator"));
Assert.Equal("Any", RoleOf("defaultuser0"));
}
// ── list-level write rules ───────────────────────────────────────────────
[Theory]
[InlineData(null, "[\"adm-*\",\"kiosk|user\"]")]
[InlineData("[\"adm-*\"]", "[\"adm-*\",\"kiosk|user\"]")]
[InlineData("[\"adm-*\"]", "[]")]
[InlineData("[\"adm-*\"]", "")]
[InlineData("[\"*\"]", "[\"*\"]")]
public void Valid_or_unchanged_lists_pass(string? stored, string candidate)
{
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, stored));
}
[Fact]
public void A_stored_entry_is_never_rechecked_when_another_entry_is_added()
{
// Lists written before the grammar existed (a bare "*", an Entra SID) stay saveable.
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\",\"kiosk|user\"]",
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\"]"));
}
[Theory]
[InlineData("{\"a\":1}", "JSON array")]
[InlineData("[1,2]", "JSON array")]
[InlineData("not json", "JSON array")]
[InlineData("[\"*\"]", "too broad")]
[InlineData("[\"kiosk|admin\"]", "is not valid")]
[InlineData("[\"kiosk\",\"kiosk|user\"]", "listed twice")]
[InlineData("[\"Kiosk\",\"kiosk\"]", "listed twice")]
public void Invalid_new_entries_are_rejected(string candidate, string expected)
{
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, null);
Assert.NotNull(error);
Assert.Contains(expected, error);
}
[Fact]
public void A_new_entry_repeating_a_stored_pattern_is_rejected()
{
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[\"WsiAccount\",\"WsiAccount|user\"]", "[\"WsiAccount\"]");
Assert.NotNull(error);
Assert.Contains("listed twice", error);
}
[Fact]
public void The_entry_count_is_capped()
{
var entries = Enumerable.Range(0, LocalAdminAllowList.MaxEntries + 1).Select(i => $"\"svc{i:D3}\"");
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[" + string.Join(",", entries) + "]", null);
Assert.NotNull(error);
Assert.Contains($"at most {LocalAdminAllowList.MaxEntries}", error);
}
private static string FindRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (dir != null && !File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
dir = Directory.GetParent(dir)?.FullName;
return dir ?? throw new DirectoryNotFoundException("Could not locate repo root (AutopilotMonitor.sln).");
}
}
@@ -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;
}
}
@@ -175,6 +175,7 @@ public class PerPartitionFanOutMergeTests
[InlineData(5)]
[InlineData(15)]
[InlineData(100)]
[InlineData(1000)]
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
{
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
@@ -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}";
}
@@ -158,6 +158,7 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
case "P256dh": d.P256dh = (string)value!; break;
case "Auth": d.Auth = (string)value!; break;
case "VapidKid": d.VapidKid = (string)value!; break;
case "Label": d.Label = (string)value!; break;
default: throw new InvalidOperationException($"unexpected patch key {key}");
}
}
@@ -3,12 +3,10 @@ using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Telemetry;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -18,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// 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 E-mail stays behind the GA gate — and one Push channel per scope),
/// the raw-table deny-list and the dependency redaction.
/// and the raw-table deny-list. The dependency redaction lives in CredentialUrlRedactionProcessorTests.
/// </summary>
public class PushChannelRoutingTests
{
@@ -182,30 +180,4 @@ public class PushChannelRoutingTests
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
}
[Theory]
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
{
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
PushDependencyRedactionProcessor.Redact(dependency);
Assert.Equal(expected, dependency.Data);
Assert.Equal("POST " + expected, dependency.Name);
}
[Fact]
public void Dependency_rows_for_other_hosts_are_untouched()
{
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
PushDependencyRedactionProcessor.Redact(dependency);
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
}
}
@@ -103,6 +103,43 @@ public class PushDeliveryServiceTests
Assert.Equal((int)status, row.LastStatusCode);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Start_alert_goes_out_at_high_urgency(bool isResume)
{
// D-424: an Info start below "high" waits at FCM while the phone dozes, and the verdict sharing
// its session Topic then replaces it — the start never reaches the phone.
var h = new Harness();
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
"DESKTOP-4711", "SN-0001", "Dell Inc.", "Latitude 7450", isResume, Constants.PortalSessionUrl("session-1"));
await h.Sut.SendCoreAsync(Tenant, alert);
var request = Assert.Single(h.Http.Requests);
Assert.Equal("high", Assert.Single(request.Headers.GetValues("Urgency")));
}
[Theory]
[InlineData(NotificationSeverity.Error, "high")]
[InlineData(NotificationSeverity.Warning, "high")]
[InlineData(NotificationSeverity.Success, "normal")]
[InlineData(NotificationSeverity.Info, "low")]
public async Task Other_alerts_take_their_urgency_from_the_severity(NotificationSeverity severity, string expected)
{
var h = new Harness();
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
var alert = Alert();
alert.EventType = "whats_new";
alert.Severity = severity;
await h.Sut.SendCoreAsync(Tenant, alert);
var request = Assert.Single(h.Http.Requests);
Assert.Equal(expected, Assert.Single(request.Headers.GetValues("Urgency")));
}
[Fact]
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
{
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
/// Plus the label bodies of the portal routes: optional on confirm, required on rename.
/// </summary>
public class PushPairingFunctionsTests
{
@@ -39,6 +40,59 @@ public class PushPairingFunctionsTests
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
}
[Theory]
[InlineData(null, "iPhone")] // a portal without the name field sends no body
[InlineData("{\"label\":\"\"}", "iPhone")]
[InlineData("{\"label\":\"iPhone Otto\"}", "iPhone Otto")]
public async Task Confirm_reads_an_optional_name(string? body, string expectedLabel)
{
var h = new PushPairingServiceTests.Harness();
var sut = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
var pairingId = await RedeemedPairingAsync(h, sut);
var response = await sut.Confirm(EndpointHarness.Request(TenantId, jsonBody: body).Req, pairingId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var device = h.Repo.Devices.Values.Single();
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
Assert.Equal(expectedLabel, device.Label);
}
[Fact]
public async Task Rename_answers_204_and_maps_a_blank_name_to_400_and_an_unknown_device_to_404()
{
var h = new PushPairingServiceTests.Harness();
var pairing = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
var pairingId = await RedeemedPairingAsync(h, pairing);
await pairing.Confirm(EndpointHarness.Request(TenantId).Req, pairingId);
var deviceId = h.Repo.Devices.Values.Single().DeviceId;
var sut = new PushDevicesFunctions(h.Sut, NullLogger<PushDevicesFunctions>.Instance);
var renamed = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"iPhone IT\"}").Req, deviceId);
Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode);
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
var blank = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\" \"}").Req, deviceId);
Assert.Equal(HttpStatusCode.BadRequest, blank.StatusCode);
var noBody = await sut.Rename(EndpointHarness.Request(TenantId).Req, deviceId);
Assert.Equal(HttpStatusCode.BadRequest, noBody.StatusCode);
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
var unknown = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"x\"}").Req, "unknownDevice");
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
}
/// <summary>A pairing created through the tenant route by the harness caller and redeemed by a device named "iPhone".</summary>
private static async Task<string> RedeemedPairingAsync(PushPairingServiceTests.Harness h, PushPairingFunctions sut)
{
var created = await sut.Create(EndpointHarness.Request(TenantId).Req);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
created.Body.Position = 0;
var pairing = (await JsonSerializer.DeserializeAsync<CreatePairingResponse>(created.Body, ApiJsonOptions.Read))!;
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(pairing.Code, label: "iPhone"))).Ok);
return pairing.PairingId;
}
private static PushPairingFunctions RefusingFunctions()
{
var pairing = new PushPairingServiceTests.Harness();
@@ -400,6 +400,54 @@ public class PushPairingServiceTests
Assert.Empty(h.Repo.Devices);
}
[Fact]
public async Task Confirm_takes_the_name_chosen_on_the_computer_and_a_blank_one_keeps_the_devices()
{
var h = new Harness();
var renamed = await h.Sut.CreateAsync(Tenant, Admin);
await h.Sut.RedeemAsync(h.RedeemRequest(renamed.Value!.Code, label: "iPhone"));
Assert.True((await h.Sut.ConfirmAsync(Tenant, renamed.Value.PairingId, Admin, " iPhone \u0007IT ")).Ok);
var device = h.Repo.Devices.Values.Single();
Assert.Equal("iPhone IT", device.Label);
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "CREATE", "PushDevice", device.DeviceId, Admin.Upn,
It.Is<Dictionary<string, string>>(d => d["label"] == "iPhone IT" && !d.ContainsKey("previousLabel"))), Times.Once);
var kept = await h.Sut.CreateAsync(Tenant, Operator);
await h.Sut.RedeemAsync(h.RedeemRequest(kept.Value!.Code, label: "ops phone"));
Assert.True((await h.Sut.ConfirmAsync(Tenant, kept.Value.PairingId, Operator, " \u0007 ")).Ok);
Assert.Equal("ops phone", h.Repo.Devices.Values.Single(d => d.OwnerUpn == Operator.Upn).Label);
}
[Fact]
public async Task Rename_follows_the_remove_rule_cleans_the_name_and_audits_only_a_change()
{
var h = new Harness();
var adminDevice = await PairAsync(h, Admin);
var opsDevice = await PairAsync(h, Operator);
// An Operator renames only their own device; someone else's answers like a missing one.
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, adminDevice, "taken over", Operator));
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, "Ops phone", Operator));
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, "missing", "x", Admin));
// A scope admin renames every device of the scope.
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, " iPhone\u0007 Otto " + new string('z', 60), Admin));
var renamed = h.Repo.Devices[(TenantId, opsDevice)];
Assert.StartsWith("iPhone Otto zz", renamed.Label);
Assert.Equal(Constants.Push.MaxLabelLength, renamed.Label.Length);
Assert.Equal(renamed.Label, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Single(d => d.DeviceId == opsDevice).Label);
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "PushDevice", opsDevice, Admin.Upn,
It.Is<Dictionary<string, string>>(d => d["label"] == renamed.Label && d["previousLabel"] == "Ops phone")), Times.Once);
// Blank after cleaning is refused; the same name again writes nothing and audits nothing.
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, " \u0007 ", Admin));
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, null, Admin));
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, renamed.Label, Admin));
h.Maintenance.Verify(m => m.LogAuditEntryAsync(It.IsAny<string>(), "UPDATE", It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Exactly(2));
}
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
private static async Task<string> PairAsync(Harness h, PushCaller owner)
{
@@ -467,6 +515,8 @@ public class PushPairingServiceTests
public VapidKeyRing Keys { get; }
public Mock<PushEligibility> Eligibility { get; }
public Mock<OpsEventService> OpsEvents { get; }
/// <summary>The tenant audit log the pairing service writes to.</summary>
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public RecordingHandler Http { get; } = new();
public PushPairingService Sut { get; }
public PushDeliveryService Delivery { get; }
@@ -499,7 +549,7 @@ public class PushPairingServiceTests
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
Maintenance.Object, tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
/// route, that the surface is exactly these 24 routes (a 25th cannot appear unnoticed), that only the
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
/// </summary>
@@ -14,7 +14,7 @@ public class PushRoutePolicyTests
{
private const string SampleId = "22222222-2222-2222-2222-222222222222";
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
/// <summary>The 24 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
private static readonly string[] PinnedRoutes =
{
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
@@ -29,6 +29,7 @@ public class PushRoutePolicyTests
"POST push/pairings/{pairingId}/confirm",
"POST push/pairings/{pairingId}/reject",
"GET push/devices",
"PATCH push/devices/{deviceId}",
"DELETE push/devices/{deviceId}",
"POST push/devices/{deviceId}/test",
// Session watches: ?tenantId= like the annotations route.
@@ -41,6 +42,7 @@ public class PushRoutePolicyTests
"POST global/push/pairings/{pairingId}/confirm",
"POST global/push/pairings/{pairingId}/reject",
"GET global/push/devices",
"PATCH global/push/devices/{deviceId}",
"DELETE global/push/devices/{deviceId}",
"POST global/push/devices/{deviceId}/test",
};
@@ -60,6 +62,7 @@ public class PushRoutePolicyTests
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
[InlineData("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
@@ -72,6 +75,7 @@ public class PushRoutePolicyTests
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
[InlineData("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
@@ -85,7 +89,7 @@ public class PushRoutePolicyTests
}
[Fact]
public void The_push_surface_is_exactly_the_22_pinned_routes()
public void The_push_surface_is_exactly_the_24_pinned_routes()
{
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
@@ -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");
}
@@ -0,0 +1,162 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The setup guide's markers (POST config/{tenantId}/setup-guide) and the done rules the feature
/// flags derive from the row.
/// </summary>
public class SetupGuideFunctionTests
{
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
private const string Admin = "admin@contoso.com";
// ── step parsing ──
[Theory]
[InlineData("settings", "settings")]
[InlineData(" Hardware ", "hardware")]
[InlineData("DISMISS", "dismiss")]
[InlineData("restore", "restore")]
[InlineData("validation", null)]
[InlineData("", null)]
[InlineData(null, null)]
public void NormalizeStep_accepts_only_the_four_steps(string? input, string? expected)
{
Assert.Equal(expected, SetupGuideFunction.NormalizeStep(input));
}
// ── ApplyStep ──
[Fact]
public void Settings_step_is_stamped_once()
{
var row = new TenantConfiguration();
Assert.True(SetupGuideFunction.ApplyStep(row, "settings", Admin, Now));
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
Assert.Equal(Admin, row.UpdatedBy);
// A second completion keeps the first one on record and writes nothing.
Assert.False(SetupGuideFunction.ApplyStep(row, "settings", "other@contoso.com", Now.AddHours(1)));
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
}
[Fact]
public void Hardware_step_is_stamped_once()
{
var row = new TenantConfiguration();
Assert.True(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now));
Assert.Equal(Now, row.SetupGuideHardwareReviewedAt);
Assert.False(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now.AddMinutes(5)));
}
[Fact]
public void Dismiss_records_who_and_restore_clears_it()
{
var row = new TenantConfiguration();
Assert.True(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now));
Assert.Equal(Now, row.SetupGuideDismissedAt);
Assert.Equal(Admin, row.SetupGuideDismissedBy);
Assert.False(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now.AddMinutes(1)));
Assert.True(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(2)));
Assert.Null(row.SetupGuideDismissedAt);
Assert.Null(row.SetupGuideDismissedBy);
Assert.False(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(3)));
}
[Fact]
public void Unknown_step_writes_nothing()
{
var row = new TenantConfiguration { UpdatedBy = "earlier" };
Assert.False(SetupGuideFunction.ApplyStep(row, "validation", Admin, Now));
Assert.Equal("earlier", row.UpdatedBy);
}
// ── SetupGuideState.SettingsDone ──
[Fact]
public void Settings_are_open_on_a_new_tenant()
{
Assert.False(SetupGuideState.SettingsDone(TenantConfiguration.CreateDefault("t")));
}
[Fact]
public void Settings_are_done_once_reviewed_even_if_nothing_was_applied()
{
var config = TenantConfiguration.CreateDefault("t");
config.SetupGuideSettingsReviewedAt = Now;
Assert.True(SetupGuideState.SettingsDone(config));
}
[Fact]
public void Settings_are_done_when_all_three_are_already_on()
{
var config = TenantConfiguration.CreateDefault("t");
config.EnableTimezoneAutoSet = true;
config.EnableSoftwareInventoryAnalyzer = true;
config.DiagnosticsUploadDestination = "Hosted";
config.DiagnosticsUploadMode = "OnFailure";
Assert.True(SetupGuideState.SettingsDone(config));
}
[Fact]
public void Settings_stay_open_while_one_of_the_three_is_off()
{
var config = TenantConfiguration.CreateDefault("t");
config.EnableTimezoneAutoSet = true;
config.EnableSoftwareInventoryAnalyzer = true;
config.DiagnosticsUploadDestination = "Hosted";
config.DiagnosticsUploadMode = "Off";
Assert.False(SetupGuideState.SettingsDone(config));
}
// ── SetupGuideState.HardwareDone ──
[Fact]
public void Hardware_is_open_with_the_default_whitelist()
{
Assert.False(SetupGuideState.HardwareDone(TenantConfiguration.CreateDefault("t")));
}
[Fact]
public void Hardware_is_done_once_reviewed()
{
var config = TenantConfiguration.CreateDefault("t");
config.SetupGuideHardwareReviewedAt = Now;
Assert.True(SetupGuideState.HardwareDone(config));
}
[Theory]
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation,VMware*", "*")]
[InlineData("*", "*")]
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation", "Latitude*")]
public void Hardware_is_done_when_the_whitelist_differs_from_the_default(string manufacturers, string models)
{
var config = TenantConfiguration.CreateDefault("t");
config.ManufacturerWhitelist = manufacturers;
config.ModelWhitelist = models;
Assert.True(SetupGuideState.HardwareDone(config));
}
[Fact]
public void Hardware_ignores_order_case_and_spacing_of_the_default_entries()
{
var config = TenantConfiguration.CreateDefault("t");
config.ManufacturerWhitelist = "microsoft corporation, lenovo*,HP*, dell*";
Assert.False(SetupGuideState.HardwareDone(config));
}
}
@@ -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?>
@@ -171,6 +175,30 @@ public sealed class SharedManifestParityTests
// Limits of feedback and reports (text lengths, the report body cap, the daily feedback cap):
// the portal's counters and upload budget read the same numbers the backend enforces.
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
// Local Admin Analyzer allowed list: the built-in entries and the entry grammar. The portal's
// Allowed Local Accounts card renders and checks against these; agent and backend use the class.
["localAdminAllowList"] = new Dictionary<string, object?>
{
["roleSeparator"] = LocalAdminAllowList.RoleSeparator.ToString(),
["standardUserQualifier"] = LocalAdminAllowList.StandardUserQualifier,
["maxEntries"] = LocalAdminAllowList.MaxEntries,
["maxEntryLength"] = LocalAdminAllowList.MaxEntryLength,
["minPatternLiterals"] = LocalAdminAllowList.MinPatternLiterals,
["forbiddenNameCharacters"] = LocalAdminAllowList.ForbiddenNameCharacters,
["builtIns"] = LocalAdminAllowList.BuiltIns.Select(b => new Dictionary<string, object?>
{
["name"] = b.Name,
["match"] = b.Match switch
{
LocalAdminBuiltInMatch.Rid => "rid",
LocalAdminBuiltInMatch.AccountName => "account",
LocalAdminBuiltInMatch.ProfileFolder => "folder",
_ => throw new InvalidOperationException($"Unmapped built-in match {b.Match}"),
},
["role"] = b.Role == LocalAccountRole.StandardUser ? "user" : "any",
["rid"] = b.Rid,
}).ToArray(),
},
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
@@ -0,0 +1,112 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Pagination;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Azure Tables refuses <c>$top</c> above 1000 with 400 InvalidInput. A computed page size
/// handed to <c>maxPerPage</c> unguarded reaches that limit only at the edge (a fan-out reader
/// that asked for <c>pageSize + 1</c> answered every <c>pageSize=1000</c> request with an empty
/// page). Ratchet: every <c>maxPerPage</c> argument is a literal of at most 1000 or goes through
/// <see cref="AzureTablesPaginator.ServerPageSize"/> / <c>Math.Min(…, 1000)</c>.
/// </summary>
public class TableServerPageSizeGuardTests
{
private static readonly Regex AllowedForm = new(
@"^(\d+|(AzureTablesPaginator\.)?ServerPageSize\(.+\)|Math\.Min\(.+,\s*1000\s*\))$",
RegexOptions.Compiled | RegexOptions.Singleline);
[Theory]
[InlineData(1, 1)]
[InlineData(16, 16)]
[InlineData(1000, 1000)]
[InlineData(1001, 1000)]
[InlineData(int.MaxValue, 1000)]
public void ServerPageSize_never_exceeds_the_Azure_limit(int rows, int expected)
{
Assert.Equal(expected, AzureTablesPaginator.ServerPageSize(rows));
}
[Fact]
public void Every_maxPerPage_argument_stays_within_the_Azure_limit()
{
var root = FunctionsRoot();
var offenders = new List<string>();
var found = 0;
foreach (var path in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
{
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
if (rel.StartsWith("bin/", StringComparison.Ordinal) || rel.StartsWith("obj/", StringComparison.Ordinal)) continue;
var source = File.ReadAllText(path);
foreach (var arg in MaxPerPageArguments(source))
{
found++;
if (!IsAllowed(arg)) offenders.Add($"{rel}: maxPerPage: {arg}");
}
}
// Positive control: the scan must see the known call sites, or it proves nothing.
Assert.True(found > 20, $"Only {found} maxPerPage arguments found — the scan lost its call sites.");
Assert.True(offenders.Count == 0,
"maxPerPage beyond Azure's $top limit is possible — pass AzureTablesPaginator.ServerPageSize(n) " +
"or a literal of at most 1000:\n " + string.Join("\n ", offenders));
}
[Theory]
[InlineData("1000", true)]
[InlineData("25", true)]
[InlineData("1001", false)]
[InlineData("limit", false)]
[InlineData("pageSize + 1", false)]
[InlineData("AzureTablesPaginator.ServerPageSize(limit)", true)]
[InlineData("Math.Min(maxResults + 1, 1000)", true)]
[InlineData("Math.Min(maxResults, 5000)", false)]
public void IsAllowed_accepts_only_bounded_forms(string argument, bool expected)
{
Assert.Equal(expected, IsAllowed(argument));
}
private static bool IsAllowed(string argument)
{
if (!AllowedForm.IsMatch(argument)) return false;
return !int.TryParse(argument, out var literal) || literal <= AzureTablesPaginator.MaxServerPageSize;
}
/// <summary>The argument text after each <c>maxPerPage:</c>, up to the comma or parenthesis that ends it.</summary>
private static IEnumerable<string> MaxPerPageArguments(string source)
{
const string marker = "maxPerPage:";
var at = 0;
while ((at = source.IndexOf(marker, at, StringComparison.Ordinal)) >= 0)
{
var i = at + marker.Length;
at = i;
// A mention inside a comment ("(maxPerPage: 1)") is not a call site.
var lineStart = source.LastIndexOf('\n', i) + 1;
if (source.AsSpan(lineStart, i - lineStart).TrimStart().StartsWith("//")) continue;
var depth = 0;
var start = i;
for (; i < source.Length; i++)
{
var c = source[i];
if (c == '(') depth++;
else if (c == ')') { if (depth == 0) break; depth--; }
else if (c == ',' && depth == 0) break;
}
yield return source[start..i].Trim();
}
}
private static string FunctionsRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
}
}
@@ -0,0 +1,370 @@
using AutopilotMonitor.Functions.Functions.Metrics;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Counting rules of the tenant activity (GA → Metrics → Tenant Activity, MCP get_tenant_activity): the window is
/// the N full UTC days through the last aggregated day, every signup has exactly one lifecycle stage, dormant and
/// never-enrolled agree with the growth figures, the totals add up from the tenant rows, and the portal figures
/// count people inside the window only.
/// </summary>
public class TenantActivityBuilderTests
{
// Friday; yesterday (2026-11-05) is aggregated unless a test says otherwise.
private static readonly DateTime Now = new(2026, 11, 6, 15, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Today = Now.Date;
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
private static DateTime DaysAgo(int days, int hour = 0) => Today.AddDays(-days).AddHours(hour);
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example")
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt };
private static TenantStats Stats(char id, long total, DateTime lastUpdated, long succeeded = 0, long events = 0)
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, TotalEventsProcessed = events, LastUpdated = lastUpdated };
private static TenantActivityDay Row(char id, int daysAgo, int total, int succeeded = 0, int failed = 0)
=> new() { TenantId = Tid(id), Day = DaysAgo(daysAgo), SessionsTotal = total, SessionsSucceeded = succeeded, SessionsFailed = failed };
private static PortalVisit Visit(char id, string person, DateTime at)
=> new() { TenantId = Tid(id), PersonKey = person, At = at };
private static GetTenantActivityResponse Build(
IReadOnlyList<TenantConfiguration> configs,
IReadOnlyList<TenantStats> stats,
IReadOnlyList<TenantActivityDay> rows,
int days = 30,
IReadOnlyList<PortalVisit>? visits = null,
bool portalAvailable = true,
bool yesterdayAggregated = true)
=> TenantActivityBuilder.Build(
configs, stats, rows, portalAvailable ? visits ?? Array.Empty<PortalVisit>() : null, Now, days, yesterdayAggregated);
private static TenantActivityTenantItem Tenant(GetTenantActivityResponse response, char id)
=> Assert.Single(response.Tenants, t => t.TenantId == Tid(id));
// ---------- window ----------
[Fact]
public void Window_is_the_n_full_days_through_yesterday_and_the_previous_window_before_it()
{
var response = Build(
new[] { Config('a', DaysAgo(100)) },
new[] { Stats('a', 7, DaysAgo(1, 9)) },
new[] { Row('a', 1, 2), Row('a', 30, 1), Row('a', 31, 4) });
Assert.Equal("2026-10-07", response.WindowStart);
Assert.Equal("2026-11-05", response.UsageDaysThrough);
Assert.Equal("2026-09-07", response.PreviousWindowStart);
Assert.Equal("2026-05-10", response.RowsFrom);
var tenant = Tenant(response, 'a');
Assert.Equal(3, tenant.Enrollments);
Assert.Equal(4, tenant.EnrollmentsPrevious);
Assert.Equal(30, response.Daily.Count);
Assert.Equal(("2026-10-07", 1), (response.Daily[0].Day, response.Daily[0].Enrollments));
Assert.Equal(("2026-11-05", 2), (response.Daily[^1].Day, response.Daily[^1].Enrollments));
}
[Theory]
[InlineData(1, "2026-11-05")]
[InlineData(7, "2026-10-30")]
[InlineData(30, "2026-10-07")]
[InlineData(90, "2026-08-08")]
public void The_window_holds_exactly_n_days_ending_yesterday(int days, string start)
{
var response = Build(Array.Empty<TenantConfiguration>(), Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(), days);
Assert.Equal(start, response.WindowStart);
Assert.Equal(days, response.Daily.Count);
Assert.Equal(start, response.Daily[0].Day);
Assert.Equal("2026-11-05", response.Daily[^1].Day);
}
[Fact]
public void Before_yesterday_is_aggregated_the_window_ends_the_day_before_and_yesterdays_counters_keep_a_tenant_active()
{
// Sessions only yesterday: the counters moved, the daily row is not written yet.
var configs = new[] { Config('b', DaysAgo(120)) };
var stats = new[] { Stats('b', 3, DaysAgo(1, 22)) };
var rows = new[] { Row('b', 40, 1) };
var early = Build(configs, stats, rows, yesterdayAggregated: false);
Assert.Equal("2026-11-04", early.UsageDaysThrough);
Assert.Equal("2026-10-06", early.WindowStart);
var tenant = Tenant(early, 'b');
Assert.Equal("2026-11-05", tenant.LastActiveDay);
Assert.Equal(TenantActivityStages.Quiet, tenant.Stage);
// Once aggregated, a day without a row had no sessions: the counter move alone keeps nobody active.
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'b').Stage);
}
// ---------- stages ----------
[Fact]
public void Every_signup_has_exactly_one_stage_in_order()
{
var configs = new[]
{
Config('n', DaysAgo(10)), // never enrolled
Config('d', DaysAgo(120)), // dormant: last sessions 40 days ago
Config('w', DaysAgo(20)), // new: first sessions 10 days ago
Config('p', DaysAgo(2)), // new: first sessions today, only the counters know
Config('c', DaysAgo(150)), // declining: 10 before, 4 now
Config('a', DaysAgo(300)), // active, signed up before the rows' horizon
Config('q', DaysAgo(100)), // quiet: only today's counters moved in 30 days
Config('g', DaysAgo(5), domain: ""), // a read of an unknown id, not a signup
};
var stats = new[]
{
Stats('d', 5, DaysAgo(40, 9)),
Stats('w', 2, DaysAgo(10, 9)),
Stats('p', 1, Today.AddHours(8)),
Stats('c', 30, DaysAgo(5, 9)),
Stats('a', 100, DaysAgo(1, 9)),
Stats('q', 4, Today.AddHours(7)),
Stats('g', 9, DaysAgo(1, 9)),
};
var rows = new[]
{
Row('d', 40, 2),
Row('w', 10, 2),
Row('c', 40, 10), Row('c', 5, 4),
Row('a', 35, 6), Row('a', 3, 6),
Row('q', 50, 1),
Row('g', 2, 9),
};
var response = Build(configs, stats, rows);
Assert.Equal(TenantActivityStages.NeverEnrolled, Tenant(response, 'n').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(response, 'd').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'w').Stage);
Assert.Equal(TenantActivityStages.New, Tenant(response, 'p').Stage);
Assert.Equal(TenantActivityStages.Declining, Tenant(response, 'c').Stage);
Assert.Equal(TenantActivityStages.Active, Tenant(response, 'a').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(response, 'q').Stage);
Assert.DoesNotContain(response.Tenants, t => t.TenantId == Tid('g'));
var pending = Tenant(response, 'p');
Assert.Null(pending.FirstEnrollmentDay);
Assert.Equal("2026-11-06", pending.LastActiveDay);
Assert.Equal("2026-10-27", Tenant(response, 'w').FirstEnrollmentDay);
Assert.Null(Tenant(response, 'a').FirstEnrollmentDay); // signup before the rows' first day: unknown
var stages = response.Totals.Stages;
Assert.Equal((1, 1, 2, 1, 1, 1), (stages.NeverEnrolled, stages.Dormant, stages.New, stages.Declining, stages.Active, stages.Quiet));
Assert.Equal(response.Totals.SignedUpTenants,
stages.NeverEnrolled + stages.Dormant + stages.New + stages.Declining + stages.Active + stages.Quiet);
Assert.Equal(7, response.Totals.SignedUpTenants);
}
[Fact]
public void Dormant_wins_over_declining_and_a_short_window_leaves_recent_tenants_quiet()
{
var configs = new[] { Config('c', DaysAgo(150)), Config('q', DaysAgo(150)) };
var stats = new[] { Stats('c', 20, DaysAgo(40, 9)), Stats('q', 20, DaysAgo(10, 9)) };
var rows = new[] { Row('c', 40, 10), Row('q', 120, 5), Row('q', 10, 3) };
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'c').Stage);
Assert.Equal(TenantActivityStages.Quiet, Tenant(Build(configs, stats, rows, days: 7), 'q').Stage);
}
[Fact]
public void A_return_after_a_pause_is_not_new_for_a_signup_before_the_rows_horizon()
{
var response = Build(
new[] { Config('a', DaysAgo(200)) },
new[] { Stats('a', 40, DaysAgo(2, 9)) },
new[] { Row('a', 2, 3) });
var tenant = Tenant(response, 'a');
Assert.Equal(TenantActivityStages.Active, tenant.Stage);
Assert.Null(tenant.FirstEnrollmentDay);
}
[Fact]
public void Dormant_starts_at_30_days_without_activity_and_matches_the_growth_figures()
{
var configs = new[] { Config('x', DaysAgo(100)), Config('y', DaysAgo(100)), Config('n', DaysAgo(3)) };
var stats = new[] { Stats('x', 2, DaysAgo(29, 9)), Stats('y', 2, DaysAgo(30, 9)) };
var rows = new[] { Row('x', 29, 2), Row('y', 30, 2) };
var activity = Build(configs, stats, rows);
var growth = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, Array.Empty<string>());
Assert.NotEqual(TenantActivityStages.Dormant, Tenant(activity, 'x').Stage);
Assert.Equal(TenantActivityStages.Dormant, Tenant(activity, 'y').Stage);
Assert.Equal(growth.AllTenants.Dormant, activity.Totals.Stages.Dormant);
Assert.Equal(growth.AllTenants.NeverEnrolled, activity.Totals.Stages.NeverEnrolled);
Assert.Equal(1, activity.Totals.Stages.Dormant);
}
// ---------- figures ----------
[Fact]
public void Tenant_figures_and_totals_add_up_from_the_window_rows()
{
var configs = new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(100)), Config('c', DaysAgo(100)) };
var stats = new[]
{
Stats('a', 50, DaysAgo(1, 9), succeeded: 40, events: 9_000),
Stats('b', 8, DaysAgo(3, 9)),
Stats('c', 4, DaysAgo(45, 9)),
};
var rows = new[]
{
Row('a', 1, 4, succeeded: 3, failed: 1), Row('a', 2, 2, succeeded: 2),
Row('a', 40, 4),
Row('b', 3, 1),
Row('c', 45, 4),
};
var response = Build(configs, stats, rows);
var a = Tenant(response, 'a');
Assert.Equal((6, 4, 5, 1, 2), (a.Enrollments, a.EnrollmentsPrevious, a.Succeeded, a.Failed, a.ActiveDays));
Assert.Equal(50, a.TrendPercent);
Assert.Equal(83, a.SuccessPercent);
Assert.Equal((50L, 40L, 9_000L), (a.LifetimeEnrollments, a.LifetimeSucceeded, a.LifetimeEvents));
Assert.Equal(30, a.DailyEnrollments!.Length);
Assert.Equal(4, a.DailyEnrollments[^1]);
Assert.Equal(2, a.DailyEnrollments[^2]);
var b = Tenant(response, 'b');
Assert.Null(b.TrendPercent); // nothing in the previous window
Assert.Null(b.SuccessPercent); // no finished session
var c = Tenant(response, 'c');
Assert.Equal(-100, c.TrendPercent);
Assert.Null(c.DailyEnrollments);
var totals = response.Totals;
Assert.Equal((2, 2, 7, 8), (totals.ActiveTenants, totals.ActiveTenantsPrevious, totals.Enrollments, totals.EnrollmentsPrevious));
Assert.Equal(0, totals.ActiveTenantsTrendPercent);
Assert.Equal(-13, totals.EnrollmentsTrendPercent);
Assert.Equal((5, 1, (int?)83), (totals.Succeeded, totals.Failed, totals.SuccessPercent));
Assert.Equal(100, totals.Top10SharePercent);
Assert.Equal(totals.Enrollments, response.Daily.Sum(d => d.Enrollments));
Assert.Equal(1, response.Daily[^1].ActiveTenants);
Assert.Equal(2, response.Daily[^3].ActiveTenants + response.Daily[^1].ActiveTenants);
}
[Fact]
public void Tenants_come_by_window_enrollments_then_lifetime_and_the_top_10_share_needs_enrollments()
{
var configs = new[] { Config('a', DaysAgo(50)), Config('b', DaysAgo(50)), Config('c', DaysAgo(50)) };
var stats = new[] { Stats('a', 1, DaysAgo(45, 9)), Stats('b', 9, DaysAgo(45, 9)), Stats('c', 3, DaysAgo(45, 9)) };
var response = Build(configs, stats, new[] { Row('a', 45, 1), Row('b', 45, 9), Row('c', 45, 3) });
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('a') }, response.Tenants.Select(t => t.TenantId));
Assert.Null(response.Totals.Top10SharePercent);
Assert.Equal(-100, response.Totals.EnrollmentsTrendPercent);
}
[Fact]
public void Tenant_ids_join_case_insensitively()
{
var config = new TenantConfiguration { TenantId = Tid('a').ToUpperInvariant(), DomainName = "contoso.example", OnboardedAt = DaysAgo(60) };
var response = Build(new[] { config }, new[] { Stats('a', 5, DaysAgo(1, 9)) }, new[] { Row('a', 1, 5) },
visits: new[] { Visit('a', "oid-1", DaysAgo(2, 10)) });
var tenant = Assert.Single(response.Tenants);
Assert.Equal(5, tenant.Enrollments);
Assert.Equal(5, tenant.LifetimeEnrollments);
Assert.Equal(1, tenant.PortalUsers);
}
// ---------- portal ----------
[Fact]
public void Portal_users_count_people_inside_the_window_and_the_last_visit_takes_the_whole_history()
{
var visits = new[]
{
Visit('a', "oid-1", DaysAgo(30)), // the window's first day, midnight
Visit('a', "oid-1", DaysAgo(3, 8)), // the same person again
Visit('a', "oid-2", DaysAgo(31, 23)), // the day before the window
Visit('a', "oid-3", Today.AddHours(9)), // today, after the window
Visit('b', "oid-9", DaysAgo(60, 12)), // never enrolled, came back two months ago
};
var response = Build(
new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(80)) },
new[] { Stats('a', 3, DaysAgo(1, 9)) },
new[] { Row('a', 1, 3) },
visits: visits);
Assert.True(response.PortalActivityAvailable);
var a = Tenant(response, 'a');
Assert.Equal(1, a.PortalUsers);
Assert.Equal(Today.AddHours(9), a.LastPortalVisitAt);
var b = Tenant(response, 'b');
Assert.Equal(0, b.PortalUsers);
Assert.Equal(DaysAgo(60, 12), b.LastPortalVisitAt);
Assert.Equal(((int?)1, (int?)1), (response.Totals.PortalUsers, response.Totals.PortalTenants));
}
[Fact]
public void Without_the_sign_in_history_the_portal_figures_are_absent_not_zero()
{
var response = Build(new[] { Config('a', DaysAgo(100)) }, new[] { Stats('a', 3, DaysAgo(1, 9)) }, new[] { Row('a', 1, 3) },
portalAvailable: false);
Assert.False(response.PortalActivityAvailable);
Assert.Null(response.Totals.PortalUsers);
Assert.Null(response.Totals.PortalTenants);
var tenant = Assert.Single(response.Tenants);
Assert.Null(tenant.PortalUsers);
Assert.Null(tenant.LastPortalVisitAt);
Assert.Equal(3, tenant.Enrollments);
}
// ---------- identity ----------
[Fact]
public void Edition_follows_msp_then_plan_then_a_running_trial()
{
var msp = Config('m', DaysAgo(10));
msp.ManagedByProTenantId = Tid('z');
msp.PlanTier = "pro";
var pro = Config('p', DaysAgo(10));
pro.PlanTier = "pro";
var trial = Config('t', DaysAgo(10));
trial.TrialExpiresUtc = Now.AddDays(5);
var expired = Config('e', DaysAgo(10));
expired.TrialExpiresUtc = Now.AddDays(-1);
var community = Config('c', DaysAgo(10));
var response = Build(new[] { msp, pro, trial, expired, community }, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>());
Assert.Equal(TenantEditionNames.ProMsp, Tenant(response, 'm').Edition);
Assert.Equal(TenantEditionNames.Pro, Tenant(response, 'p').Edition);
Assert.Equal(TenantEditionNames.ProTrial, Tenant(response, 't').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'e').Edition);
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'c').Edition);
}
[Fact]
public void A_tenant_row_carries_its_names_signup_and_counter_time()
{
var config = Config('a', DaysAgo(30, 10), domain: "fabrikam.example");
config.CompanyName = "Fabrikam";
var blank = Config('b', DaysAgo(30, 10));
blank.CompanyName = " ";
var response = Build(new[] { config, blank }, new[] { Stats('a', 1, DaysAgo(2, 11)) }, new[] { Row('a', 2, 1) });
var a = Tenant(response, 'a');
Assert.Equal(("fabrikam.example", "Fabrikam"), (a.DomainName, a.CompanyName));
Assert.Equal(DaysAgo(30, 10), a.OnboardedAt);
Assert.Equal(DaysAgo(2, 11), a.LastCounterUpdateAt);
var b = Tenant(response, 'b');
Assert.Null(b.CompanyName);
Assert.Null(b.LastCounterUpdateAt);
}
}
@@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet: a function takes the tenant whose data it reads from the request context — <c>TenantHelper.GetTenantId(req)</c>
/// or <c>RequestContext.TenantId</c> — never from the token. Only those follow "view as tenant", where a Global Admin
/// reads another tenant as its member; a data read off the token's <c>tid</c> would silently show the Global Admin's
/// own tenant under the viewed tenant's name. The baseline lists the reads that mean the caller's IDENTITY; it only
/// shrinks.
/// </summary>
public class TenantClaimReadGuardTests
{
private static readonly Regex TokenTenantRead = new(
@"\.GetTenantId\(\)|FindFirst\(""tid""\)|identity/claims/tenantid", RegexOptions.Compiled);
/// <summary>Token-tenant reads that identify the caller: file → why.</summary>
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
"auth/me identifies and onboards the sign-in tenant (its view-as branch runs first), the caller's own What's-new marker and Terms + DPA",
["Functions/Infrastructure/McpUserFunction.cs"] = "the MCP front door binds the caller's identity",
["Functions/Metrics/McpUsageMetricsFunction.cs"] = "the caller's own MCP usage (me)",
["Functions/Rules/PreviewWhitelistFunction.cs"] = "the caller's own tenant's activation notification address",
};
private static string FunctionsRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
}
[Fact]
public void Functions_read_the_data_tenant_from_the_request_context_not_from_the_token()
{
var root = FunctionsRoot();
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var sub in new[] { "Functions", "Pagination" })
{
foreach (var path in Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
{
if (!TokenTenantRead.IsMatch(File.ReadAllText(path)))
continue;
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
seen.Add(rel);
if (!Baseline.ContainsKey(rel))
offenders.Add(rel);
}
}
Assert.True(offenders.Count == 0,
"Token tenant read in a function — take the data tenant from TenantHelper.GetTenantId(req) or the request " +
"context, or add the file to the baseline with the identity reason:\n " + string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries without a token tenant read any more — remove them (the ratchet only shrinks):\n " +
string.Join("\n ", stale));
}
}
@@ -317,6 +317,10 @@ public class TenantConfigPatchServiceTests
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
[InlineData("cloudPcPermissionMissingSince")]
[InlineData("cloudPcPermissionMissingConfirmedAt")]
[InlineData("setupGuideSettingsReviewedAt")]
[InlineData("setupGuideHardwareReviewedAt")]
[InlineData("setupGuideDismissedAt")]
[InlineData("setupGuideDismissedBy")]
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
{
var harness = new Harness(Stored());
@@ -485,6 +489,36 @@ public class TenantConfigPatchServiceTests
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
}
[Theory]
[InlineData("[\"*\"]")]
[InlineData("[\"kiosk|admin\"]")]
[InlineData("[\"S-1-12-1-1-2-3-4\"]")]
[InlineData("\"kiosk\"")]
public async Task Patch_InvalidAllowedLocalAccount_ValidationFails_NoWrite(string json)
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("localAdminAllowedAccountsJson", json)), Ga, "mcp-patch", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("allowed local accounts", outcome.Error);
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_StandardUserAllowedLocalAccount_Applies()
{
var harness = new Harness(Stored());
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("localAdminAllowedAccountsJson", "[\"adm-*\",\"kiosk|user\"]")), Ga, "mcp-patch", null);
Assert.True(outcome.Success, outcome.Error);
Assert.Equal("[\"adm-*\",\"kiosk|user\"]", harness.Current!.LocalAdminAllowedAccountsJson);
}
[Fact]
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
{
@@ -247,6 +247,34 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
Assert.Null(mapped.SetupGuideSettingsReviewedAt);
Assert.Null(mapped.SetupGuideHardwareReviewedAt);
Assert.Null(mapped.SetupGuideDismissedAt);
Assert.Null(mapped.SetupGuideDismissedBy);
}
[Fact]
public void Roundtrip_SetupGuideFields_SurviveStoreAndMap()
{
var at = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
SetupGuideSettingsReviewedAt = at,
SetupGuideHardwareReviewedAt = at.AddMinutes(2),
SetupGuideDismissedAt = at.AddDays(1),
SetupGuideDismissedBy = "admin@contoso.com",
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(at, mapped.SetupGuideSettingsReviewedAt);
Assert.Equal(at.AddMinutes(2), mapped.SetupGuideHardwareReviewedAt);
Assert.Equal(at.AddDays(1), mapped.SetupGuideDismissedAt);
Assert.Equal("admin@contoso.com", mapped.SetupGuideDismissedBy);
}
[Fact]
@@ -393,6 +393,10 @@ public class TypedContractCompletionWireParityTests
trialAvailable = true,
contactEmailSet = false,
companyNameSet = false,
setupGuideSettingsDone = true,
setupGuideHardwareDone = false,
setupGuideDismissed = false,
hasEnrollmentSessions = false,
entitlements = new
{
retentionCapDays = 90,
@@ -424,6 +428,10 @@ public class TypedContractCompletionWireParityTests
TrialAvailable = true,
ContactEmailSet = false,
CompanyNameSet = false,
SetupGuideSettingsDone = true,
SetupGuideHardwareDone = false,
SetupGuideDismissed = false,
HasEnrollmentSessions = false,
Entitlements = new TenantFeatureEntitlements
{
RetentionCapDays = 90,
@@ -47,8 +47,6 @@ public class TypedRequestGuardTests
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
// Foreign schema: Azure Monitor common alert schema, not our contract.
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
// Parses the UPSTREAM Kusto response, not the request body.
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Differential test for the move of the Graph and Entra token-endpoint parsers to System.Text.Json (D-420):
/// <see cref="UpstreamResponseCases"/> builds response bodies from a fixed seed per parser, and the production
/// parser must yield exactly what the Newtonsoft reference in <see cref="LegacyUpstreamParsers"/> yields: same
/// result, or both throw (every caller treats any escaped exception the same way).
/// The bodies leave out the inputs of the deliberate differences D1 to D6; those are pinned in
/// <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with the production
/// parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part before the cut
/// as if the open containers were closed.
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog). The
/// generator and the readings stay for <see cref="UpstreamJsonInvariantTests"/>.
/// </summary>
public class UpstreamJsonDifferentialTests
{
private const int CaseCount = 20_000;
[Fact]
public void Token_response_matches_the_reference()
=> Compare(seed: 4201, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => Describe(LegacyUpstreamParsers.ReadTokenResponse(b)),
hit: o => o.StartsWith("token=\"", StringComparison.Ordinal));
[Fact]
public void Entra_rejection_matches_the_reference()
=> Compare(seed: 4202, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => Describe(LegacyUpstreamParsers.ParseEntraRejection(400, b)),
hit: o => o.Contains("code=\"AADSTS", StringComparison.Ordinal));
[Fact]
public void Autopilot_list_matches_the_reference()
=> Compare(seed: 4203, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => Describe(LegacyUpstreamParsers.FindRegistration(b, Serial)),
hit: o => o.StartsWith("id=", StringComparison.Ordinal));
[Fact]
public void Corporate_page_matches_the_reference()
=> Compare(seed: 4204, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
b => Describe(LegacyUpstreamParsers.ReadIdentityPage(b, CorporateId)),
hit: o => o.StartsWith("found=True", StringComparison.Ordinal));
[Fact]
public void Association_list_matches_the_reference()
=> Compare(seed: 4205, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b => Describe(LegacyUpstreamParsers.ParseAssociation(b, Serial)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void CloudPc_list_matches_the_reference()
=> Compare(seed: 4206, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseCloudPc(b, IntuneId)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void Managed_device_matches_the_reference()
=> Compare(seed: 4207, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseManagedDevice(b, IntuneId)),
hit: o => !o.StartsWith("outcome=NotFound", StringComparison.Ordinal) && o != "<throws>");
[Fact]
public void Script_page_matches_the_reference()
=> Compare(seed: 4208, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b => Describe(LegacyUpstreamParsers.ReadScriptPage(b, withFileName: true)),
hit: o => o.StartsWith("\"", StringComparison.Ordinal));
[Fact]
public void Script_name_matches_the_reference()
=> Compare(seed: 4209, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => Describe(LegacyUpstreamParsers.ReadScriptDisplayName(b)),
hit: o => o != "<null>" && o != "<throws>");
private static void Compare(int seed, Func<UpstreamResponseCases, string> body, Func<string, string> actual, Func<string, string> reference, Func<string, bool> hit)
{
// The reference formats dates in the current culture before parsing them back; en-US keeps its
// whole-second "Z" dates exact. The culture swap on other hosts is pinned in GraphResponseParsingTests.
var saved = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = new CultureInfo("en-US");
try
{
var generator = new UpstreamResponseCases(seed);
var unreadable = Outcome(() => actual("not json"));
var mismatches = new List<string>();
int hits = 0, throws = 0;
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
{
var input = body(generator);
var got = Outcome(() => actual(input));
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
if (got != want)
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
if (hit(want)) hits++;
if (want == "<throws>") throws++;
}
Assert.True(mismatches.Count == 0, "Parser differs from the Newtonsoft reference:\n " + string.Join("\n ", mismatches));
// The generator must exercise the parser, not compare 20,000 identical misses.
Assert.True(hits >= CaseCount / 50, $"only {hits} of {CaseCount} cases reach a result worth comparing");
Assert.True(throws <= CaseCount * 7 / 10, $"{throws} of {CaseCount} cases throw; the generator barely reaches the parser");
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Invariant fuzz for the Graph and Entra token-endpoint parsers (D-420), without a reference. Over bodies of
/// every value kind, the inputs of D1 to D7 included (<see cref="UpstreamResponseCases"/>), each parser throws
/// only what its callers expect, reads a body the same way however it is laid out, and returns no text the body
/// did not send; <see cref="UpstreamJson.ParseObjectOrNull"/> yields an object, null or a JsonException for any
/// text. Permanent: 500 cases per parser and check.
/// </summary>
public class UpstreamJsonInvariantTests
{
private const int CaseCount = 500;
/// <summary>An exception kind a parser's callers expect, named for the failure message.</summary>
private sealed record Thrown(string Name, Func<Exception, bool> Matches);
// The callers catch JsonException or everything; an unreadable body or next link throws it.
private static readonly Thrown Unreadable = new("JsonException", ex => ex is JsonException);
// A list entry that is no object before the match: the validators answer transient, as before the move.
// Only the exact type counts; ObjectDisposedException derives from it and would mean a disposed document.
private static readonly Thrown NonObjectEntry = new("InvalidOperationException", ex => ex.GetType() == typeof(InvalidOperationException));
/// <summary>A parser as the fuzz drives it.</summary>
/// <param name="Seed">The generator seed; each check offsets it to see other bodies.</param>
/// <param name="Body">The body generator for this parser.</param>
/// <param name="Read">The canonical reading of a body.</param>
/// <param name="Texts">Every text the result took from the body.</param>
/// <param name="MayThrow">The exception kinds that may escape; every one of them must also occur.</param>
private sealed record Parser(
int Seed,
Func<UpstreamResponseCases, string> Body,
Func<string, string> Read,
Func<string, IEnumerable<string?>> Texts,
Thrown[] MayThrow);
private static readonly Dictionary<string, Parser> Parsers = new()
{
["token"] = new(4301, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => new[] { GraphTokenService.ReadTokenResponse(b).AccessToken },
new[] { Unreadable }),
["entra"] = new(4302, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => new[] { EntraTokenRejection.Parse(400, b).Description },
Array.Empty<Thrown>()),
["autopilot"] = new(4303, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => AutopilotDeviceValidator.FindRegistration(b, Serial) is { } r
? new[] { r.DeviceId, r.GroupTag, r.PurchaseOrderId, r.DisplayName }
: Array.Empty<string?>(),
new[] { Unreadable, NonObjectEntry }),
["corporate"] = new(4304, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
// An empty next url is the parser's own "no further page", not text from the body.
b => new[] { CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId).NextUrl is { Length: > 0 } next ? next : null },
new[] { Unreadable, NonObjectEntry }),
["association"] = new(4305, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b =>
{
var r = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial);
return new[] { r.AssociationState, r.DevicePreparationPolicyId, r.PreAssociatedByUserPrincipalName, r.AssignedToUserPrincipalName, r.ManagedDeviceId };
},
new[] { NonObjectEntry }),
["cloudpc"] = new(4306, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b =>
{
var r = CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId);
return new[] { r.CloudPcId, r.ManagedDeviceName, r.ServicePlanName };
},
new[] { NonObjectEntry }),
["managed-device"] = new(4307, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b =>
{
var r = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId);
return new[] { r.DeviceName, r.AzureAdDeviceId, r.ManagementState, r.SerialNumber, r.OwnerType, r.EnrollmentType };
},
Array.Empty<Thrown>()),
["script-page"] = new(4308, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b =>
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true);
return scripts.SelectMany(s => new[] { s.Id, s.DisplayName, s.FileName }).Append(next);
},
new[] { Unreadable }),
["script-name"] = new(4309, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => new[] { ScriptDisplayNameResolver.ReadScriptDisplayName(b) },
new[] { Unreadable }),
};
public static IEnumerable<object[]> ParserNames => Parsers.Keys.Select(name => new object[] { name });
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_throws_only_what_its_callers_expect(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed, everyValueKind: true);
var seen = new HashSet<string>();
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
try
{
parser.Read(body);
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
var kind = parser.MayThrow.FirstOrDefault(t => t.Matches(ex));
Assert.True(kind != null, $"{name}, case {i}: {ex.GetType().Name} escapes ({ex.Message}) for body {body}");
seen.Add(kind!.Name);
}
}
// Every listed kind still occurs, so the list cannot quietly outgrow what the parser does.
Assert.Equal(parser.MayThrow.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), seen.OrderBy(n => n, StringComparer.Ordinal));
}
[Theory]
[MemberData(nameof(ParserNames))]
public void Layout_never_changes_what_a_parser_reads(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 100, everyValueKind: true);
var layout = new Random(parser.Seed);
var compared = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
if (ReadableObject(body) is not { } root)
continue;
var expected = Outcome(() => parser.Read(body));
foreach (var spread in new[] { false, true })
{
var twin = Render(root, layout, spread);
var actual = Outcome(() => parser.Read(twin));
Assert.True(expected == actual, $"{name}, case {i}: body {body}\n laid out as {twin}\n reads {actual}\n instead of {expected}");
}
compared++;
}
Assert.True(compared >= CaseCount / 2, $"only {compared} of {CaseCount} bodies were readable objects");
}
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_returns_only_text_the_body_sent(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 200, everyValueKind: true);
var checkedTexts = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
string?[] texts;
try
{
texts = parser.Texts(body).ToArray();
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
continue; // which exceptions may escape is the first check's business
}
var sent = ReadableObject(body) is { } root ? SentTexts(root) : new List<string>();
foreach (var text in texts.OfType<string>())
{
// Trimming and cutting keep a text inside what was sent; an empty text needs a blank one.
var fromBody = text.Length == 0
? sent.Any(string.IsNullOrWhiteSpace)
: sent.Any(s => s.Contains(text, StringComparison.Ordinal));
Assert.True(fromBody, $"{name}, case {i}: \"{text}\" is no text the body sent: {body}");
checkedTexts++;
}
}
// The corporate page returns its next url as the only text, so the bar is low.
Assert.True(checkedTexts >= CaseCount / 50, $"only {checkedTexts} texts came back from {CaseCount} bodies");
}
[Fact]
public void ParseObjectOrNull_yields_an_object_or_null_or_throws_JsonException_for_any_text()
{
var edits = new Random(4310);
int objects = 0, nulls = 0, refused = 0;
foreach (var parser in Parsers.Values)
{
var cases = new UpstreamResponseCases(parser.Seed + 300, everyValueKind: true);
for (var i = 0; i < CaseCount / 2; i++)
{
var body = parser.Body(cases);
foreach (var text in new[] { body, Mutate(body, edits) })
{
try
{
if (UpstreamJson.ParseObjectOrNull(text) is { } json)
{
Assert.Equal(JsonValueKind.Object, json.ValueKind);
objects++;
}
else
{
nulls++;
}
}
catch (JsonException)
{
refused++;
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
Assert.Fail($"{ex.GetType().Name} ({ex.Message}) for text {text}");
}
}
}
}
Assert.True(objects > 0 && nulls > 0 && refused > 0, $"objects {objects}, nulls {nulls}, refused {refused}");
}
private static JsonElement? ReadableObject(string body)
{
try
{
return UpstreamJson.ParseObjectOrNull(body);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Every string value in the body, decoded; one whose escapes are no valid UTF-16 is skipped.</summary>
private static List<string> SentTexts(JsonElement root)
{
var texts = new List<string>();
Collect(root);
return texts;
void Collect(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var member in element.EnumerateObject())
Collect(member.Value);
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
Collect(item);
break;
case JsonValueKind.String:
try { texts.Add(element.GetString()!); }
catch (InvalidOperationException) { }
break;
}
}
}
/// <summary>
/// The body written again in another layout: compact, or spread out with block and line comments and
/// trailing commas. Object members change their order unless a name repeats (the last one must stay last);
/// values keep their raw text, escapes and number form included.
/// </summary>
private static string Render(JsonElement root, Random rng, bool spread)
{
var text = new StringBuilder();
Write(root);
return text.ToString();
void Write(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
var members = element.EnumerateObject().ToList();
if (members.Select(m => m.Name).Distinct(StringComparer.Ordinal).Count() == members.Count)
members = members.OrderBy(_ => rng.Next()).ToList();
text.Append(spread ? "{ /* object */\n " : "{");
for (var i = 0; i < members.Count; i++)
{
if (i > 0)
text.Append(spread ? (rng.Next(2) == 0 ? ",// next\n " : " ,\t/* next */ ") : ",");
text.Append(JsonSerializer.Serialize(members[i].Name)).Append(spread ? " : " : ":");
Write(members[i].Value);
}
text.Append(spread && members.Count > 0 ? ",\n}" : "}");
break;
case JsonValueKind.Array:
var items = element.EnumerateArray().ToList();
text.Append(spread ? "[\n " : "[");
for (var i = 0; i < items.Count; i++)
{
if (i > 0)
text.Append(spread ? " , " : ",");
Write(items[i]);
}
text.Append(spread && items.Count > 0 ? ",\n]" : "]");
break;
default:
text.Append(element.GetRawText());
break;
}
}
}
/// <summary>One random edit: a character removed, inserted or replaced, or the text cut short.</summary>
private static string Mutate(string text, Random rng)
{
if (text.Length == 0)
return "{";
const string alphabet = "{}[]\",:/*\\ \n0123456789-+.eEtrufalsné";
var at = rng.Next(text.Length);
var c = alphabet[rng.Next(alphabet.Length)].ToString();
return rng.Next(4) switch
{
0 => text.Remove(at, 1),
1 => text.Insert(at, c),
2 => text.Substring(0, at),
_ => text.Remove(at, 1).Insert(at, c),
};
}
}
@@ -0,0 +1,298 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
/// <see cref="GraphResponseParsingTests"/>.
/// </summary>
public class UpstreamJsonTests
{
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\r\n\t")]
[InlineData("null")]
[InlineData(" null ")]
[InlineData("/*c*/ null")]
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
[Theory]
[InlineData("{}")]
[InlineData(" { \"a\" : 1 } ")]
[InlineData("{\"a\":1,}")]
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
[InlineData("// leading\n{\"a\":1}")]
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
[Fact]
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
{
// The document is disposed inside the call; only a cloned root stays readable.
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
GC.Collect();
Assert.Equal("b", json.GetProperty("a").GetString());
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
}
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
// is malformed too; Graph and Entra never send it.
[Theory]
[InlineData("[]")]
[InlineData("[{}]")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("true")]
[InlineData("not json")]
[InlineData("<html>busy</html>")]
[InlineData("{")]
[InlineData("{\"a\":}")]
[InlineData("{} x")]
[InlineData("{}{}")]
[InlineData("{} /* unterminated")]
[InlineData("{'a':'b'}")]
[InlineData("{a:1}")]
[InlineData("{\"a\":NaN}")]
[InlineData("{\"a\":0x1F}")]
[InlineData("{\"a\":undefined}")]
[InlineData("{\"a\":\"line\nbreak\"}")]
[InlineData("{\"a\":1,,}")]
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
[Fact]
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
// ── GetString ───────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{"a":"value"}""", "value")]
[InlineData("""{"a":""}""", "")]
[InlineData("""{"a":" padded "}""", " padded ")]
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
[InlineData("""{"a":"42"}""", "42")]
[InlineData("""{"a":"first","a":"last"}""", "last")]
public void GetString_reads_text_as_sent(string json, string expected)
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"a":null}""")]
[InlineData("""{"a":42}""")]
[InlineData("""{"a":4.2}""")]
[InlineData("""{"a":true}""")]
[InlineData("""{"a":false}""")]
[InlineData("""{"a":{}}""")]
[InlineData("""{"a":[]}""")]
[InlineData("""{"a":["x"]}""")]
[InlineData("""{"A":"other case"}""")]
[InlineData("""{"a ":"other name"}""")]
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("[]")]
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
{
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
}
[Theory]
[InlineData("""{"a":"x\ud800y"}""")]
[InlineData("""{"a":"\udc00"}""")]
[InlineData("""{"a":"\ud800A"}""")]
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
// ── Items ───────────────────────────────────────────────────────────────────
[Fact]
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
=> Assert.Equal(
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":42}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"Value":[1]}""")]
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
// ── GetNextLink ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{}""", null)]
[InlineData("""{"@odata.nextLink":null}""", null)]
[InlineData("""{"@odata.nextLink":""}""", "")]
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
// ── GetInt64 ────────────────────────────────────────────────────────────────
[Theory]
[InlineData("3599", 3599L)]
[InlineData("0", 0L)]
[InlineData("-5", -5L)]
[InlineData("9223372036854775807", long.MaxValue)]
[InlineData("-9223372036854775808", long.MinValue)]
[InlineData("\"3599\"", 3599L)]
[InlineData("\" 3599 \"", 3599L)]
[InlineData("\"+3599\"", 3599L)]
[InlineData("\"-1\"", -1L)]
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Theory]
[InlineData("3599.5")]
[InlineData("3599.0")]
[InlineData("7e2")]
[InlineData("9223372036854775808")]
[InlineData("99999999999999999999")]
[InlineData("\"3599.0\"")]
[InlineData("\"3,599\"")]
[InlineData("\"3.599\"")]
[InlineData("\"0x10\"")]
[InlineData("\"\"")]
[InlineData("\"abc\"")]
[InlineData("\"\u0661\u0662\u0663\"")]
[InlineData("\"\\ud800\"")]
[InlineData("null")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Fact]
public void GetInt64_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
// ── GetBoolean ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("\"false\"", null)]
[InlineData("1", null)]
[InlineData("0", null)]
[InlineData("{}", null)]
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
[Fact]
public void GetBoolean_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
[Theory]
[InlineData("2026-05-08T12:16:49Z", 0L)]
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
[InlineData("2026-05-08T12:16:49", 0L)]
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
{
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
[Fact]
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
[Theory]
[InlineData("\"0001-01-01T00:00:00Z\"")]
[InlineData("\"0001-01-01T00:00:00\"")]
[InlineData("\"0001-01-01\"")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("\"2026-13-01T00:00:00Z\"")]
[InlineData("\"/Date(1713096021000)/\"")]
[InlineData("null")]
[InlineData("20260508")]
[InlineData("true")]
[InlineData("{}")]
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
[Fact]
public void GetUtcDateTime_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
// ── Culture matrix ──────────────────────────────────────────────────────────
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
// read the same body the same way.
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Every_member_reads_the_same_in_any_culture(string culture)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
Assert.Null(UpstreamJson.GetInt64(json, "f"));
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// One canonical text per result of the Graph and Entra token-endpoint parsers (D-420), so two readings of a
/// body compare as strings: the differential test compares production with the Newtonsoft reference, the
/// invariant fuzz compares a body with its reformatted twin.
/// </summary>
internal static class UpstreamParserReadings
{
internal const string Serial = "PF3XKQ7";
internal const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
internal static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
/// <summary>The reading, or "&lt;throws&gt;" when the parser throws.</summary>
internal static string Outcome(Func<string> run)
{
try { return run(); }
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return "<throws>"; }
}
private static string Text(string? value) => value is null ? "<null>" : "\"" + value + "\"";
private static string Date(DateTime? value) => value is { } d ? d.ToString("O", CultureInfo.InvariantCulture) + "/" + d.Kind : "<null>";
// Every consumer reads the token with IsNullOrEmpty, so an empty and an absent token describe alike.
internal static string Describe((string? AccessToken, long? ExpiresInSeconds) r) =>
$"token={(string.IsNullOrEmpty(r.AccessToken) ? "<none>" : Text(r.AccessToken))} expires={r.ExpiresInSeconds?.ToString(CultureInfo.InvariantCulture) ?? "<null>"}";
internal static string Describe(EntraTokenRejection r) => $"status={r.StatusCode} code={Text(r.ErrorCode)} description={Text(r.Description)}";
internal static string Describe(AutopilotMonitor.Shared.Models.AutopilotRegistration? r) => r is null
? "<not registered>"
: $"id={Text(r.DeviceId)} tag={Text(r.GroupTag)} po={Text(r.PurchaseOrderId)} name={Text(r.DisplayName)} user={r.UserAssigned}";
internal static string Describe((bool Found, string NextUrl) r) => $"found={r.Found} next={Text(r.NextUrl)}";
internal static string Describe(DeviceAssociationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} serial={Text(r.SerialNumber)} state={Text(r.AssociationState)} policy={Text(r.DevicePreparationPolicyId)} " +
$"preBy={Text(r.PreAssociatedByUserPrincipalName)} assigned={Text(r.AssignedToUserPrincipalName)} pre={Date(r.PreAssociationDateTime)} " +
$"at={Date(r.AssociationDateTime)} device={Text(r.ManagedDeviceId)} error={Text(r.ErrorMessage)}";
internal static string Describe(CloudPcValidationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} device={Text(r.IntuneDeviceId)} cpc={Text(r.CloudPcId)} name={Text(r.ManagedDeviceName)} " +
$"plan={Text(r.ServicePlanName)} error={Text(r.ErrorMessage)}";
internal static string Describe(IntuneDeviceBindingResult r) =>
$"outcome={r.Outcome} device={Text(r.IntuneDeviceId)} name={Text(r.DeviceName)} aad={Text(r.AzureAdDeviceId)} state={Text(r.ManagementState)} " +
$"serial={Text(r.SerialNumber)} owner={Text(r.OwnerType)} enrollment={Text(r.EnrollmentType)} registered={r.AzureAdRegistered?.ToString() ?? "<null>"} " +
$"enrolled={(r.EnrolledDateTime is { } e ? e.ToString("O", CultureInfo.InvariantCulture) : "<null>")} error={Text(r.ErrorMessage)}";
// A null or empty next link both end the full pull, so they describe alike.
internal static string Describe((List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) r) =>
string.Join(";", r.Scripts.Select(s => $"{Text(s.Id)}={Text(s.DisplayName)}/{Text(s.FileName)}")) + $" next={Text(string.IsNullOrEmpty(r.NextUrl) ? null : r.NextUrl)}";
internal static string Describe(string? scriptName) => scriptName ?? "<null>";
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Deterministic Graph and Entra token-endpoint response bodies for the parser tests of D-420. They vary root
/// kind, empty, broken and cut-off bodies, trailing content, comments, trailing commas, whitespace, property
/// order, unknown and duplicate properties, mixed list entries, text with escapes and Unicode, paging links,
/// lifetimes as number or numeric text, and error-code variants.
/// By default they leave out the inputs of the deliberate differences D1 to D6, so the old and the new parser
/// must agree on them. With <c>everyValueKind</c> they add those inputs too: JSON null, numbers, booleans,
/// objects and arrays in text fields, date-shaped text, dates with fractions and offsets, escapes that are no
/// valid UTF-16, unusable lifetimes and error codes.
/// </summary>
internal sealed class UpstreamResponseCases
{
// The same few literals come back thousands of times; serializing each once keeps the fuzz fast. Declared
// first: the pools below call Str while the type initializes.
private static readonly ConcurrentDictionary<string, string> Serialized = new(StringComparer.Ordinal);
private readonly Random _rng;
private readonly bool _everyValueKind;
public UpstreamResponseCases(int seed, bool everyValueKind = false)
{
_rng = new Random(seed);
_everyValueKind = everyValueKind;
}
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
public bool LastWasCutOff { get; private set; }
// Plain text that is never a date in either library and never JSON null.
private static readonly string[] PlainTexts =
{
Str("value"), Str(" padded "), Str("UPPER lower 123"), "\"Ünïcødé ☃ ε\"", Str("quote\" back\\ slash\nnew line"),
Str(new string('x', 300)), Str(""), Str(" "), Str("a/b?c=d&e"), Str("é\u0001\u007f"), Str("2026"), Str("12:16"),
};
// What D1, D3 and D4 read differently: JSON null, other kinds, date-shaped text, an escape that is no valid UTF-16.
private static readonly string[] DeliberateTexts =
{
"null", "42", "-1.5", "true", "{}", "[]", "[\"x\"]", Str("2026-01-01T00:00:00Z"), Str("/Date(1713096021000)/"), "\"x\\ud800y\"",
};
// A whole-second UTC date, a date without time, or a value both read as no date.
private static readonly string[] Dates =
{
Str("2026-05-08T12:16:49Z"), Str("2025-12-31T23:59:59Z"), Str("2026-05-08"), Str("0001-01-01T00:00:00Z"),
Str(""), Str("not-a-date"), "null",
};
// What D2 reads differently (fraction, offset, no offset) and dates that are no text.
private static readonly string[] DeliberateDates =
{
Str("2026-05-08T12:16:49.1234567Z"), Str("2026-05-08T14:16:49+02:00"), Str("2026-05-08T12:16:49"), "20260508", "true",
};
private T Pick<T>(params T[] items) => items[_rng.Next(items.Length)];
/// <summary>One of <paramref name="values"/>; with every value kind, one of those or of <paramref name="deliberate"/>.</summary>
private string Value(string[] values, params string[] deliberate)
{
var i = _rng.Next(values.Length + (_everyValueKind ? deliberate.Length : 0));
return i < values.Length ? values[i] : deliberate[i - values.Length];
}
private bool Chance(int percent) => _rng.Next(100) < percent;
private static string Str(string s) => Serialized.GetOrAdd(s, value => System.Text.Json.JsonSerializer.Serialize(value));
private string Ws() => Pick("", "", "", " ", "\n", "\r\n ", "\t");
private string Text() => Value(PlainTexts, DeliberateTexts);
private string Url() => Pick(
Str("https://graph.microsoft.com/beta/next?$skiptoken=abc"), Str("https://graph.microsoft.com/v1.0/x?$top=5"));
private string DateValue() => Value(Dates, DeliberateDates);
/// <summary>A property: (name, json) or (name, null) when the property is left out.</summary>
private (string Name, string? Json) Prop(string name, string json, int missingPercent = 15)
=> (name, Chance(missingPercent) ? null : json);
private string Obj(params (string Name, string? Json)[] props)
{
var parts = props.Where(p => p.Json != null).Select(p => $"{Str(p.Name)}:{Ws()}{p.Json}").ToList();
if (Chance(10))
parts.Insert(_rng.Next(parts.Count + 1), $"{Str("unknownProperty")}:{Pick("1", "\"x\"", "{}", "[]", "null", "true")}");
if (Chance(5) && parts.Count > 0)
{
// Duplicate key with the same value: whichever occurrence wins, the meaning stays.
parts.Insert(0, parts[_rng.Next(parts.Count)]);
}
if (Chance(30))
parts = parts.OrderBy(_ => _rng.Next()).ToList();
var separator = Pick(",", ",", ", ", ",\n ", ", /*c*/ ", ",// line\n");
var trailing = parts.Count > 0 && Chance(5) ? "," : "";
return "{" + Ws() + string.Join(separator, parts) + trailing + Ws() + "}";
}
private string ValueList(Func<string> item)
{
switch (_rng.Next(100))
{
case < 5: return "null";
case < 9: return Pick("\"oops\"", "{}", "42");
case < 14: return "[]";
}
var entries = new List<string>();
var count = _rng.Next(1, 6);
for (var i = 0; i < count; i++)
entries.Add(_rng.Next(100) switch { < 85 => item(), < 90 => "null", < 94 => Pick("1", "\"x\"", "true"), < 97 => "[]", _ => "{}" });
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
}
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
private string Root(string objectJson)
{
var pick = _rng.Next(100);
LastWasCutOff = pick is >= 92 and < 95;
return pick switch
{
< 82 => Ws() + objectJson + Ws(),
< 84 => "",
< 86 => " ",
< 88 => "null",
< 90 => "[" + objectJson + "]",
< 92 => Pick("42", "\"text\"", "true"),
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
< 98 => objectJson + Pick(" x", " {}", " 1"),
_ => Pick("<html>busy</html>", "not json"),
};
}
private string Matching(string target) => Value(
new[] { Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true" },
"null", "{}", "[" + Str(target) + "]", "\"" + target + "\\ud800\"");
public string TokenBody() => Root(Obj(
Prop("access_token", Value(new[] { Str("eyJ0eXAi.token"), Str("t"), Str(""), "null" }, "42", "{}", "\"t\\ud800\"")),
Prop("expires_in", Value(
new[] { "3599", "0", "-5", "7200", "9223372036854775807", "\"3599\"", "\" 3599 \"", "\"+3599\"", "\"-1\"", "null" },
"\"abc\"", "\"\"", "3599.5", "7e2", "true", "99999999999999999999", "{}", "[]", "\"\\ud800\"")),
Prop("token_type", Str("Bearer"))));
public string EntraBody() => Root(Obj(
Prop("error", Pick(Str("invalid_client"), Str("unauthorized_client"))),
Prop("error_description", Value(
new[]
{
Str("AADSTS7000229: The client application is missing a service principal.\r\nTrace ID: x"),
Str("AADSTS53003: Access has been blocked by Conditional Access policies."), Str("no code here"), Str("AADSTS: no digits"),
Str(new string('d', 400)), Str(" "), "null", "42", "true", "{}", "[]",
},
Str("2026-11-01T08:00:00Z"), "\"AADSTS53003: broken \\ud800\"")),
Prop("error_codes", Value(
new[] { "[7000229]", "[53003,7000229]", "[\"7000229\",53003]", "[7000229.5]", "[7e6]", "[-1]", "[]", "7000229", "null", "[" + _rng.Next(1, 99_999_999) + "]" },
"[99999999999999999999,53003]", "[1e3]")),
Prop("timestamp", Str("2026-11-05 08:00:00Z"))));
public string AutopilotBody(string serial) => Root(Obj(
Prop("@odata.context", Str("https://graph.microsoft.com/v1.0/$metadata#deviceManagement/windowsAutopilotDeviceIdentities")),
Prop("value", ValueList(() => Obj(
Prop("id", Pick(Text(), Str("ap-1"), "null", "42")),
Prop("serialNumber", Matching(serial), 10),
Prop("groupTag", Pick(Text(), "null", "true")),
Prop("purchaseOrderIdentifier", Pick(Text(), "null", "{}")),
Prop("displayName", Pick(Text(), "null", "[]")),
Prop("userPrincipalName", Pick(Text(), "null", Str("u@contoso.com"))))), 5)));
public string CorporateBody()
{
var identifier = Value(
new[]
{
Str("Microsoft Corporation,Virtual Machine,1234-5678"), Str("MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"),
Str("microsoft corporation, virtual machine, 1234 5678"), Str("Other,Model,1"), Str(""), "null", "42",
},
"[" + Str("Microsoft Corporation,Virtual Machine,1234-5678") + "]", "true", "\"Microsoft Corporation,Virtual Machine,1234-5678\\ud800\"");
return Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("importedDeviceIdentityType", Value(new[] { Str("manufacturerModelSerial"), Str("MANUFACTURERMODELSERIAL"), Str("serialNumber"), "null" }, "42")),
Prop("importedDeviceIdentifier", identifier))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "true", "{}", "\"https://graph.microsoft.com/beta/next\\ud800\""), 50)));
}
public string AssociationBody(string serial) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("serialNumber", Matching(serial), 10),
Prop("associationState", Pick(Str("associated"), Str("preAssociated"), Text())),
Prop("devicePreparationPolicyId", Text()),
Prop("preassociatedByUserPrincipalName", Text()),
Prop("assignedToUserPrincipalName", Text()),
Prop("preassociationDateTime", DateValue()),
Prop("associationDateTime", DateValue()),
Prop("managedDeviceId", Text()))), 5)));
public string CloudPcBody(string intuneId) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Text()),
Prop("managedDeviceId", Matching(intuneId), 10),
Prop("managedDeviceName", Text()),
Prop("servicePlanName", Text()))), 5)));
public string ManagedDeviceBody(string intuneId) => Root(Obj(
Prop("id", Matching(intuneId), 10),
Prop("deviceName", Text()),
Prop("azureADDeviceId", Text()),
Prop("managementState", Pick(Str("managed"), Str("retirePending"), Str("WIPEISSUED"), Str("deletePending"), Text())),
Prop("serialNumber", Pick(Text(), "null")),
Prop("managedDeviceOwnerType", Pick(Str("company"), Text(), "null")),
Prop("deviceEnrollmentType", Pick(Str("windowsAzureADJoin"), Text(), "null")),
Prop("azureADRegistered", Value(new[] { "true", "false", "null", "\"true\"", "1" }, "\"false\"", "0", "{}")),
Prop("enrolledDateTime", DateValue())));
public string ScriptPageBody() => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Value(new[] { Str("s1"), Str("4f9e2c1a-0000-0000-0000-000000000001"), Str(""), "null" }, "42", "{}", "\"s1\\ud800\""), 10),
Prop("displayName", Text()),
Prop("fileName", Text()))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "{}", "\"https://graph.microsoft.com/beta/p2\\ud800\""), 50)));
public string ScriptNameBody() => Root(Obj(
Prop("id", Str("s1")),
Prop("displayName", Text()),
Prop("fileName", Text())));
}
@@ -0,0 +1,49 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// What runs while a Global Admin views a tenant as its member (read-only "view as"). The class follows the
/// method — GET reads, everything else writes and is refused — so a new route fails closed. Every exception is
/// listed here with its reason: a POST that only reads, or a GET that starts an action. A new exception needs a
/// deliberate line, and a GET that writes must either be listed or skip its write under view-as in the handler
/// (RecomputeTriggerGate, the consent-status auto-flip).
/// </summary>
public class ViewAsAccessCatalogTests
{
private static readonly IReadOnlyDictionary<string, string> Exceptions = new Dictionary<string, string>(StringComparer.Ordinal)
{
["PUT auth/me/whats-new-seen"] = "the caller's own What's-new marker under the JWT tenant; opening What's new sends it",
["POST realtime/negotiate"] = "a live-update subscription, nothing stored",
["POST realtime/groups/join"] = "a live-update subscription, nothing stored",
["POST realtime/groups/leave"] = "a live-update subscription, nothing stored",
["POST diagnostics/download-ticket"] = "a stateless HMAC ticket for a read",
["POST rules/analyze/dryrun"] = "evaluates a draft rule against a session and persists nothing",
["POST rules/gather/test-pattern"] = "a regex against pasted lines, no tenant data",
["POST tenants/{tenantId}/scripts/display-names"] = "a read sent as POST for its body size",
["POST tenants/{tenantId}/graph-permissions/refresh"] = "clears the in-memory Graph cache only",
["GET config/{tenantId}/autopilot-device-validation/consent-url"] = "starts the admin-consent flow (ops events with the caller's UPN)",
};
private static ViewAsAccess MethodDefault(string method) => method == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write;
[Fact]
public void Every_class_that_differs_from_the_method_default_is_listed_with_a_reason()
{
var actual = EndpointAccessPolicyCatalog.Entries
.Where(e => e.ViewAsAccess != MethodDefault(e.HttpMethod))
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.OrderBy(k => k, StringComparer.Ordinal);
Assert.Equal(Exceptions.Keys.OrderBy(k => k, StringComparer.Ordinal), actual);
}
[Theory]
[InlineData("GET", ViewAsAccess.Read)]
[InlineData("post", ViewAsAccess.Write)]
[InlineData("PUT", ViewAsAccess.Write)]
[InlineData("PATCH", ViewAsAccess.Write)]
[InlineData("DELETE", ViewAsAccess.Write)]
public void An_unclassified_route_follows_its_method(string method, ViewAsAccess expected)
=> Assert.Equal(expected, new EndpointPolicyEntry(method, "some/route", EndpointPolicy.MemberRead).ViewAsAccess);
}
@@ -0,0 +1,167 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Telemetry;
using AutopilotMonitor.Shared;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The pieces of "view as tenant" outside the policy decision: the header parse, the data scope every handler
/// reads (<see cref="TenantHelper"/>), the recompute gate that guards two GET actions, and the places where the
/// real caller — not the viewed tenant — must count (presence, the request row, every other telemetry item).
/// </summary>
public class ViewAsTests
{
private const string Home = "11111111-1111-1111-1111-111111111111";
private const string Viewed = "22222222-2222-2222-2222-222222222222";
private const string GaUpn = "ga@vendor.example";
private static RequestContext ViewingContext(string role = Constants.TenantRoles.Admin) => new()
{
TenantId = Viewed,
TargetTenantId = Viewed,
UserPrincipalName = GaUpn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(Home, role),
};
// ── header parse ─────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void No_tenant_header_is_an_ordinary_request(string? tenantHeader)
=> Assert.Null(ViewAsTarget.FromHeaders(tenantHeader, "Viewer"));
[Theory]
[InlineData("not-a-guid", null)]
[InlineData("00000000-0000-0000-0000-000000000000", null)]
[InlineData(Viewed, "GlobalAdmin")]
[InlineData(Viewed, "Member")]
public void Unusable_headers_are_invalid(string tenantHeader, string? roleHeader)
=> Assert.Same(ViewAsTarget.Invalid, ViewAsTarget.FromHeaders(tenantHeader, roleHeader));
[Theory]
[InlineData(null, Constants.TenantRoles.Admin)]
[InlineData("viewer", Constants.TenantRoles.Viewer)]
[InlineData(" OPERATOR ", Constants.TenantRoles.Operator)]
public void A_tenant_id_is_normalized_and_the_role_defaults_to_admin(string? roleHeader, string expectedRole)
{
var request = ViewAsTarget.FromHeaders(" 22222222-2222-2222-2222-22222222222A ", roleHeader);
Assert.Equal(new ViewAsTarget("22222222-2222-2222-2222-22222222222a", expectedRole), request);
Assert.True(request!.IsValid);
}
// ── data scope and identity ──────────────────────────────────────────────
[Fact]
public void TenantHelper_answers_the_viewed_tenant_under_view_as_and_the_token_tenant_otherwise()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", Home), new Claim("upn", GaUpn) }, "test"));
Assert.Equal(Viewed, TenantHelper.GetTenantId(Request(principal, ViewingContext())));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, new RequestContext { TenantId = Home })));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, context: null)));
}
[Fact]
public void The_identity_tenant_stays_the_home_tenant()
{
Assert.Equal(Home, ViewingContext().IdentityTenantId());
Assert.Equal(Viewed, new RequestContext { TenantId = Viewed }.IdentityTenantId());
}
[Fact]
public void View_as_never_triggers_a_recompute_although_its_admin_owns_the_viewed_tenant()
{
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext(), Viewed));
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext() with { ViewAs = null }, Viewed));
}
// ── counting at the real caller ──────────────────────────────────────────
[Fact]
public void View_as_records_no_presence_in_the_viewed_tenant()
{
Assert.False(UserPresenceMiddleware.Records(ViewingContext()));
Assert.True(UserPresenceMiddleware.Records(ViewingContext() with { ViewAs = null }));
}
[Fact]
public void The_request_row_of_a_view_as_request_counts_at_the_home_tenant_as_global_admin()
{
var ctx = ViewingContext();
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx,
RequestTelemetryMiddleware.ResolveTenantId(ctx.IdentityTenantId(), new Dictionary<object, object>(), "/api/sessions", null));
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(GaUpn, properties[RequestRowDimensions.UserId]);
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, properties[RequestRowDimensions.UserRole]);
Assert.Equal(Viewed, properties[RequestRowDimensions.ViewAsTenantId]);
}
[Fact]
public void An_ordinary_request_row_carries_no_view_as_dimension()
{
var ctx = ViewingContext() with { TenantId = Home, ViewAs = null };
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx, ctx.TenantId);
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(Constants.TenantRoles.Admin, properties[RequestRowDimensions.UserRole]);
Assert.False(properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
[Fact]
public async Task Telemetry_items_are_stamped_only_inside_the_view_as_request_flow()
{
var sink = new CapturingProcessor();
var processor = new ViewAsTelemetryProcessor(sink);
await InViewAsFlow(() => processor.Process(new EventTelemetry("inside")));
processor.Process(new EventTelemetry("outside"));
var inside = (EventTelemetry)sink.Items[0];
var outside = (EventTelemetry)sink.Items[1];
Assert.Equal(Viewed, inside.Properties[RequestRowDimensions.ViewAsTenantId]);
Assert.False(outside.Properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
/// <summary>The scope opens inside an awaited method, like the middleware; it ends with that flow.</summary>
private static async Task InViewAsFlow(Action emit)
{
await Task.Yield();
ViewAsTelemetryProcessor.Enter(Viewed);
emit();
}
private sealed class CapturingProcessor : ITelemetryProcessor
{
public List<ITelemetry> Items { get; } = new();
public void Process(ITelemetry item) => Items.Add(item);
}
private static HttpRequestData Request(ClaimsPrincipal principal, RequestContext? context)
{
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (context != null)
items[RequestContext.ItemsKey] = context;
var functionContext = new Mock<FunctionContext>();
functionContext.SetupGet(c => c.Items).Returns(items);
return new Mock<HttpRequestData>(functionContext.Object).Object;
}
}
@@ -39,23 +39,26 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
/// every partition is exhausted.
/// </para>
/// <para>
/// That limit is a row count, not an Azure page size: Azure Tables refuses <c>$top</c> above
/// 1000, so a reader passes <c>AzureTablesPaginator.ServerPageSize(limit)</c> as
/// <c>maxPerPage</c> and keeps reading across server pages until it has its limit.
/// </para>
/// </remarks>
internal static class PerPartitionFanOutMerge
{
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
};
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
@@ -743,6 +744,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "DpaAcceptancePending", config.DpaAcceptancePending },
{ "DpaAcceptedBy", config.DpaAcceptedBy },
{ "DpaAcceptedAt", config.DpaAcceptedAt },
{ "SetupGuideSettingsReviewedAt", config.SetupGuideSettingsReviewedAt },
{ "SetupGuideHardwareReviewedAt", config.SetupGuideHardwareReviewedAt },
{ "SetupGuideDismissedAt", config.SetupGuideDismissedAt },
{ "SetupGuideDismissedBy", config.SetupGuideDismissedBy },
{ "ContactEmail", config.ContactEmail },
{ "CompanyName", config.CompanyName },
{ "Disabled", config.Disabled },
@@ -865,6 +870,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false,
DpaAcceptedBy = entity.GetString("DpaAcceptedBy"),
DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"),
SetupGuideSettingsReviewedAt = entity.GetDateTime("SetupGuideSettingsReviewedAt"),
SetupGuideHardwareReviewedAt = entity.GetDateTime("SetupGuideHardwareReviewedAt"),
SetupGuideDismissedAt = entity.GetDateTime("SetupGuideDismissedAt"),
SetupGuideDismissedBy = entity.GetString("SetupGuideDismissedBy"),
ContactEmail = entity.GetString("ContactEmail"),
CompanyName = entity.GetString("CompanyName"),
Disabled = entity.GetBoolean("Disabled") ?? false,
@@ -3,9 +3,11 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
@@ -63,12 +65,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
};
}
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100)
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100)
{
var pk = tenantId.ToLowerInvariant();
var result = new List<DistressReportEntry>();
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {pk} and ErrorType eq {errorType.ToString()}");
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == pk, maxPerPage: maxResults))
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)))
{
result.Add(MapToEntry(entity));
if (result.Count >= maxResults)
@@ -66,6 +66,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<TenantStats?> GetTenantStatsAsync(string tenantId)
=> _storage.GetTenantStatsAsync(tenantId);
public Task<bool> TenantHasEnrollmentsAsync(string tenantId)
=> _storage.TenantHasEnrollmentsAsync(tenantId);
public Task<List<TenantStats>> GetAllTenantStatsAsync()
=> _storage.GetAllTenantStatsAsync();
@@ -93,6 +96,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
=> _storage.GetPortalVisitsSinceAsync(sinceUtc);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);

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