Compare commits

...

48 Commits

Author SHA1 Message Date
Oliver Kieselbach e01e7abc2b bump pointer: internal, .claude/commands (D-434 registration 410: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:06:11 +01:00
Oliver Kieselbach 512b145484 Backend: the comment on register-session's 410 sits on its method
The summary of the 410 response stood above ClampStartedAt and promised
that the agent treats registration and telemetry alike. It now sits on
WriteSessionLockedAsync and says what the agent does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:40 +01:00
Oliver Kieselbach 8ece30631a Agent: a registration answered with 410 ends monitoring; a WhiteGlove Part 2 start registers a new session
register-session answers 410 while a deleted session's tombstone is fresh.
The agent treated that like a network failure: six attempts, exit 7,
everything kept for the next boot. A WhiteGlove resume never left that
loop, because the 48-hour break is skipped for it.

A 410 that echoes the request's correlation id is now its own signal. The
start sends one report (RegistrationRejectedSessionGone) and ends
monitoring with a forced cleanup, as the telemetry path already does on
410. A WhiteGlove Part 2 start rotates its session id and registers again
instead, so the user phase is observed; agent_started names the reason in
sessionRotationReason. A 410 without the echo stays a retried failure.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:05:39 +01:00
Oliver Kieselbach c9078d00b6 bump pointer: internal, .claude/commands (D-433 released: backend 1.5.1329, web and MCP 1.6.595 on 1ff68dc)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:30:25 +01:00
Oliver Kieselbach 1ff68dc0af bump pointer: internal, .claude/commands (D-433 script-name lookups: decision, plan, backlog, health marker)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:08:01 +01:00
Oliver Kieselbach 7c8204357b Web: the session page starts fresh for every session
A link from one session to another only changes ?id=, so the route stays
mounted, and the append-only event list kept the previous session's
events: both sessions ended up in one timeline. The page content is now
keyed by the session id.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:48 +01:00
Oliver Kieselbach dfcf7ad8ba Web: one script-name lookup per session view, one follow-up after a time limit
The session page loads events page by page, and the hook fired a lookup
for every page that added a script. It now waits for the complete event
walk and asks once, with every script of the session.

When the backend reports that a time limit cut the lookup short, the hook
asks once more after 5 s. The follow-up never takes away a name the first
answer had, and a session switch drops a pending one.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:35 +01:00
Oliver Kieselbach 54d4c0bd4c Script names: an incomplete lookup leaves a trace and reports its time limit
A lookup that ends without an answer for every ref, because one of the
resolver's time limits fired or Graph or the token context failed, writes
one Warning with the reasons. These paths logged at Information or not at
all, and nothing collects worker logs below Warning.

The response carries timedOut when a time limit cut the lookup short, the
request may carry retry, and the request row gets ScriptNameLookup
(timedOut / retry / retryTimedOut), so a follow-up lookup can be told from
a first one.

Also:
- The token cap is 3 s instead of 1.5 s: the first mint on a cold instance
  often needs longer, and a lookup without a token returns no name at all.
- A not-found id is kept as long as a name (7 days) instead of being asked
  again after every cleanup run.
- A list pull whose budget ends between two pages is no longer stored as
  the tenant's complete list.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:07:10 +01:00
Oliver Kieselbach 11bc0ec2ab bump pointer: internal, .claude/commands (D-432 released: backend 1.5.1328, web and MCP on 7e9b0ce)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 13:06:27 +01:00
Oliver Kieselbach 7e9b0cea54 bump pointer: internal, .claude/commands (D-432 configuration read after a write: decision, concept doc, plan, backlog, health markers)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:29:14 +01:00
Oliver Kieselbach c115cb354e MCP: get_tenant_config reads the stored configuration
The tool sends X-Tenant-Config: stored, so the read that checks an
update_tenant_config does not come from a backend instance's cached copy.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:58 +01:00
Oliver Kieselbach 9cfe3fb74d Backend: a saved tenant configuration reaches the next request that needs it
The tenant configuration stays cached per instance for five minutes, and a
write clears only the cache of the instance that served it. Two readers no
longer act on a copy another instance's write has outdated:

- A signed-in request carrying X-Tenant-Config: stored (the portal sends it for
  a few minutes after a configuration write, the MCP on get_tenant_config)
  reads the stored row of the acting tenant, and of the target tenant once the
  cross-tenant check has passed, into the instance cache before authorization
  and the function read it. A storage failure keeps the cached copy. The
  header grants nothing: it costs the caller's own request one read.
- The agent gate confirms a refusal the configuration decides (tenant
  suspended, no validation switched on, manufacturer or model not allowed,
  bootstrap not enabled) against the stored row first, at most once per 15 s
  per tenant and instance, and runs the device chain once more when the stored
  row switches on a validation it had not asked. A device enrolled right after
  an admin allowed it is no longer turned away for up to five minutes.
  Requests that pass read nothing extra. The bootstrap routes check their
  feature switch through one helper that confirms the same way.

GET config/{tenantId} and the feature flags answer a storage failure with an
error instead of a synthesized default configuration.

Request-row dimensions: ConfigRefresh (stored row read on the caller's hint),
ConfigConfirm (averted / confirmed / rerun on agent requests) and
ConfigPatchUnchanged on the field patch (sent fields the row already held).

A guard test keeps every function that writes the tenant row on a route the
portal recognizes as a configuration write.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:55 +01:00
Oliver Kieselbach 892ff4c1e1 Portal: saved settings show at once, a card saves only its edits, setup guide replay
After a successful write of the tenant configuration the browser asks for the
stored configuration on its API calls for six minutes (X-Tenant-Config: stored,
lib/storedConfigHint.ts). The backend caches a configuration per instance, so a
page loaded right after a save, for example on the way back from the admin
consent, could show the values from before it. The mark lives in localStorage:
it survives the redirect and holds in other tabs. The header leaves the fetch
cache mode alone, and a backend that does not know it ignores it.

A settings card sends exactly the fields that were edited. The first save of a
card used to send every field the configuration had never stored, with the
form's default; a page showing an older configuration could write such a
default over a value saved elsewhere. What a card sends and what counts as
unsaved is one computation now (sectionSavePayload). A switch that saves on
change always sends the value that was clicked. The form's defaults are pinned
against the model's and the agent's defaults in the generated manifest.

Setup guide: "Go through the steps again" on the completion screen starts it at
step 1, and ?setup-guide=settings|hardware|validation|deploy opens it at that
step for tenant admins. It works on the tenant's current configuration and
resets nothing. The dialog shows its own writes ("Back" after "Apply" shows
"Already on"), recording a step drops the cached feature flags, the hardware
step asks for at least one manufacturer instead of claiming an empty list
rejects every device, and "The approval was not completed" needs a loaded
configuration.

The duplicate baseline loses one entry.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:28:44 +01:00
Oliver Kieselbach 2cb181734a bump pointer: internal (D-431 released with agent 2.0.1502)
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 20:33:53 +01:00
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
246 changed files with 12474 additions and 1727 deletions
+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"
}
]
},
@@ -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");
}
}
}
@@ -80,6 +80,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Registration_session_gone_report_is_exempt_from_the_cooldown_like_the_telemetry_one()
{
// The integrity report goes out post-config, seconds before the registration is refused.
var clock = Clock();
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var reporter = Reporter(client, clock);
await reporter.TrySendAsync(AgentErrorType.IntegrityCheckFailed, "m");
clock.Advance(TimeSpan.FromSeconds(5));
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, client.Reports[1].ErrorType);
Assert.Equal(410, client.Reports[1].HttpStatusCode);
// Key dedup still applies: a second one is held back.
await reporter.TrySendAsync(AgentErrorType.RegistrationRejectedSessionGone, "m", httpStatusCode: 410);
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
{
@@ -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));
}
}
}
@@ -0,0 +1,167 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// A registration the backend answered with HTTP 410 at process start: the session was deleted.
/// Exactly one <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> report, then the
/// start-time end of monitoring with a forced cleanup — the same end state as the emergency
/// break and the kill signal. The WhiteGlove Part 2 start that registers a new session instead
/// is covered by <c>SessionRegistrationHelperTests</c>; here only its trace on <c>agent_started</c>.
/// </summary>
public sealed class SessionGoneAtRegistrationTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly SessionIdPersistence _persistence;
private readonly string _stateDir;
private readonly List<string> _sequence = new List<string>();
public SessionGoneAtRegistrationTests()
{
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
_persistence = new SessionIdPersistence(_tmp.Path);
_persistence.GetOrCreate(_logger);
_stateDir = Path.Combine(_tmp.Path, "State");
}
public void Dispose() => _tmp.Dispose();
private sealed class RecordingEmergencyReporter : EmergencyReporter
{
private readonly List<string> _sequence;
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode, int Attempts)> Reports { get; } =
new List<(AgentErrorType, string, int?, int)>();
public RecordingEmergencyReporter(List<string> sequence)
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
{
_sequence = sequence;
}
public override Task TrySendAsync(
AgentErrorType errorType,
string message,
int? httpStatusCode = null,
long? sequenceNumber = null,
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null,
double? sessionAgeHours = null,
RegistrationFailureSummary? priorRegistrationFailure = null)
{
Reports.Add((errorType, message, httpStatusCode, attempts));
_sequence.Add("report");
return Task.CompletedTask;
}
}
private CleanupService CleanupFactory()
{
_sequence.Add("cleanup");
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
}
private bool MarkerExists => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
[Theory]
[InlineData(true)]
[InlineData(false)] // forced like the kill signal: SelfDestructOnComplete does not gate it
public void Reports_once_then_ends_monitoring_with_a_forced_cleanup(bool selfDestructOnComplete)
{
_persistence.SaveWhiteGloveComplete(_logger);
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", SelfDestructOnComplete = selfDestructOnComplete };
var reporter = new RecordingEmergencyReporter(_sequence);
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, reporter, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
// The report leaves before the cleanup deletes the agent.
Assert.Equal(new[] { "report", "cleanup" }, _sequence);
var report = Assert.Single(reporter.Reports);
Assert.Equal(AgentErrorType.RegistrationRejectedSessionGone, report.ErrorType);
Assert.Equal(410, report.HttpStatusCode);
Assert.Contains("410", report.Message);
Assert.True(report.Attempts > 1, "the process ends right after: the one report gets a retry");
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
Assert.False(_persistence.IsWhiteGloveResume());
Assert.Null(_persistence.LoadSessionCreatedAt());
// The next start ends at the bootstrap guard, exactly as after the emergency break.
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
}
[Fact]
public void Ends_monitoring_without_a_reporter()
{
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1" };
var exitCode = BackendSessionRegistration.ConcludeSessionGone(
config, emergencyReporter: null!, _persistence, _stateDir, CleanupFactory, consoleMode: false, _logger);
Assert.Equal(0, exitCode);
Assert.Equal(new[] { "cleanup" }, _sequence);
Assert.True(MarkerExists);
Assert.False(_persistence.SessionExists());
}
[Theory]
[InlineData(SessionRotationReasons.SessionGone)]
[InlineData(SessionRotationReasons.OwnerMismatch)]
public void Agent_started_names_why_the_session_was_rotated(string reason)
{
var data = EmitAgentStarted(rotatedFromSessionId: "11111111-1111-1111-1111-111111111111", reason);
Assert.Equal(true, data["sessionRotated"]);
Assert.Equal("11111111-1111-1111-1111-111111111111", data["previousSessionId"]);
Assert.Equal(reason, data["sessionRotationReason"]);
}
[Fact]
public void Agent_started_carries_no_rotation_reason_on_the_normal_path()
{
var data = EmitAgentStarted(rotatedFromSessionId: null, sessionRotationReason: null);
Assert.Equal(false, data["sessionRotated"]);
Assert.False(data.ContainsKey("sessionRotationReason"));
}
private Dictionary<string, object> EmitAgentStarted(string? rotatedFromSessionId, string? sessionRotationReason)
{
var sink = new FakeSignalIngressSink();
LifecycleEmitters.EmitAgentStarted(
new InformationalEventPost(sink, SystemClock.Instance, _logger),
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
"2.0.test",
remoteConfigService: null,
_logger,
rotatedFromSessionId: rotatedFromSessionId,
sessionRotationReason: sessionRotationReason);
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
}
}
}
@@ -166,6 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.OwnerMismatch, result.RotationReason);
}
private static BackendRefusalException TenantConflict()
@@ -313,6 +314,136 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(1, tracker.ConsecutiveFailures);
}
// ── 410 at registration: the backend deleted the session ────────────
private static BackendSessionGoneException SessionGone()
=> new BackendSessionGoneException("Backend returned 410 Gone: the session was deleted or is being deleted.", 410);
[Fact]
public async Task Session_gone_ends_the_run_at_once_without_retry_rotation_or_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
// The rotation hook is wired on every start; without the Part 2 flag it must stay unused.
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; return Guid.NewGuid().ToString(); });
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(410, result.HttpStatusCode);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(0, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Null(result.RotatedFromSessionId);
Assert.Null(result.RotationReason);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_rotates_once_and_registers_the_new_session()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var originalSessionId = config.SessionId;
var rotatedTo = Guid.NewGuid().ToString();
var seenSessionIds = new System.Collections.Generic.List<string>();
var rotations = 0;
var apiClient = new FakeApiClient(
attempt => attempt == 1
? throw SessionGone()
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
onRegister: r => seenSessionIds.Add(r.SessionId));
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
Assert.Equal(0, tracker.ConsecutiveFailures);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(SessionRotationReasons.SessionGone, result.RotationReason);
}
[Fact]
public async Task Second_session_gone_after_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var rotations = 0;
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => throw new InvalidOperationException("a 410 must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { rotations++; config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; },
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(1, rotations);
}
[Fact]
public async Task Session_gone_with_a_failing_rotation_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => throw new IOException("disk full"),
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Null(result.RotatedFromSessionId);
}
[Fact]
public async Task Session_gone_at_a_whiteglove_part2_start_without_rotation_hook_ends_the_run()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw SessionGone());
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
networkLinkWait: _ => Task.CompletedTask,
rotateOnSessionGone: true);
Assert.Equal(SessionRegistrationOutcome.SessionGone, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
}
[Fact]
public async Task Fails_after_six_exceptions_without_auth_error()
{
@@ -138,6 +138,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
}
// ── 410: session gone, only when the backend said so ────────────────
[Fact]
public async Task Backend_410_on_registration_is_session_gone()
{
using var client = BuildClient(
HttpStatusCode.Gone,
"{\"success\":false,\"message\":\"Session is being deleted by an administrator (state=Tombstoned); registration is rejected.\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendSessionGoneException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
Assert.Equal(410, ex.StatusCode);
}
[Fact]
public async Task A_410_without_the_backends_echo_stays_a_plain_http_error()
{
// Answered on the network path: the caller would end monitoring on a session-gone
// signal, so it must stay an ordinary, retried failure.
using var client = BuildClient(
HttpStatusCode.Gone,
"<html><head><title>Gone</title></head><body>Gone</body></html>",
"text/html",
fromBackend: false);
await Assert.ThrowsAsync<HttpRequestException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
@@ -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;
@@ -260,6 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
await ThrowOnAuthFailureAsync(response, correlationId).ConfigureAwait(false);
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
ThrowOnSessionGone(response, correlationId);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -412,6 +413,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
errorCode);
}
/// <summary>
/// Throws <see cref="BackendSessionGoneException"/> for a 410 the backend answered: the
/// session was deleted or is being deleted. Only a response that echoes this request's
/// <c>X-Correlation-ID</c> counts — the caller ends monitoring on it, so a 410 from the
/// network path stays an ordinary failure (<c>EnsureSuccessStatusCode</c>) and is retried.
/// </summary>
private static void ThrowOnSessionGone(HttpResponseMessage response, string correlationId)
{
if (response.StatusCode != HttpStatusCode.Gone)
return;
if (!ResponseOriginClassifier.EchoesCorrelationId(response, correlationId))
return;
throw new BackendSessionGoneException(
"Backend returned 410 Gone: the session was deleted or is being deleted.",
(int)response.StatusCode);
}
/// <summary>
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
/// or a non-string value yields null, never an exception.
@@ -537,4 +556,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
ErrorCode = errorCode;
}
}
/// <summary>
/// Thrown for a 410 Gone the backend answered: the session named in the request was deleted or
/// is being deleted. No retry can clear it and it says nothing about the device's authorization.
/// </summary>
public class BackendSessionGoneException : Exception
{
/// <summary>HTTP status code returned by the backend (410).</summary>
public int StatusCode { get; }
public BackendSessionGoneException(string message, int statusCode) : base(message)
{
StatusCode = statusCode;
}
}
}
@@ -15,7 +15,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
/// (the terminal session-gone reports <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
/// and <see cref="AgentErrorType.RegistrationRejectedSessionGone"/> are exempt)
///
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
/// </summary>
@@ -123,11 +124,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return;
}
// The session-unknown report is terminal and sent at most once per process (the
// A session-gone report is terminal and sent at most once per process (the
// termination follows immediately): a routine report minutes earlier must not cost
// the last word about the session. Only the cooldown is waived; key dedup and the
// per-session maximum still apply.
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown
|| errorType == AgentErrorType.RegistrationRejectedSessionGone;
if (!exemptFromCooldown && _lastReportTime != null &&
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
@@ -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 { }
}
}
}
@@ -46,6 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// authorized agent.</item>
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
/// without retrying — a refusal with a code does not clear by waiting.</item>
/// <item>410 from the backend (<see cref="BackendSessionGoneException"/>): the persisted
/// session was deleted. With <c>rotateOnSessionGone</c> (a WhiteGlove Part 2 start, whose
/// enrollment is only now continuing) the session is rotated like above and registered
/// once more. Every other start returns <see cref="SessionRegistrationOutcome.SessionGone"/>
/// without retrying — the caller ends monitoring. So does a second 410 or a failed rotation:
/// the 48-hour break skips a WhiteGlove resume, so no path here may leave it retrying.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
@@ -75,6 +81,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// Optional pre-registration link wait used by tests. Production callers pass
/// <c>null</c> → <see cref="WaitForNetworkLinkAsync"/> (bounded by <see cref="NetworkLinkWaitMax"/>).
/// </param>
/// <param name="rotateOnSessionGone">
/// True at a WhiteGlove Part 2 start: a 410 for the persisted session then becomes a new
/// session through <paramref name="rotateSession"/> instead of ending monitoring.
/// </param>
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
BackendApiClient apiClient,
AgentConfiguration agentConfig,
@@ -86,7 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
Func<Exception, Task> onTerminalTransportFailure = null,
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
Func<AgentLogger, Task> networkLinkWait = null,
Func<string> rotateSession = null)
Func<string> rotateSession = null,
bool rotateOnSessionGone = false)
{
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
@@ -101,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
string lastError = null;
Exception lastException = null;
string rotatedFromSessionId = null;
string rotationReason = null;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
@@ -112,7 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
if (response != null && response.Success)
{
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId);
return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId, rotationReason);
}
lastError = response?.Message ?? "(null response)";
@@ -149,9 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
$"Rotated to {newSessionId}; re-registering.");
rotationReason = SessionRotationReasons.OwnerMismatch;
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
catch (BackendSessionGoneException ex)
{
if (rotateOnSessionGone && rotateSession != null && rotatedFromSessionId == null)
{
var goneSessionId = registration.SessionId;
string newSessionId = null;
try
{
newSessionId = rotateSession();
}
catch (Exception rotateEx)
{
logger.Error($"Backend answered 410 for session {goneSessionId} but session rotation failed: {rotateEx.Message}", rotateEx);
}
if (newSessionId != null)
{
rotatedFromSessionId = goneSessionId;
rotationReason = SessionRotationReasons.SessionGone;
logger.Warning(
$"Backend answered 410 for session {goneSessionId} at the start of a WhiteGlove Part 2: " +
$"the session was deleted. Rotated to {newSessionId}; re-registering.");
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
}
logger.Error($"Session registration refused with 410 Gone — the backend deleted session {registration.SessionId}.");
return SessionRegistrationResult.SessionGone(ex.StatusCode, ex.Message);
}
catch (BackendRefusalException ex)
{
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
@@ -294,6 +337,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>All attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
Failed = 2,
/// <summary>
/// 410 from the backend: the persisted session was deleted. No retry was attempted; the
/// caller ends monitoring and cleans up.
/// </summary>
SessionGone = 3,
}
/// <summary>Why a run registered under a new SessionId (<see cref="SessionRegistrationResult.RotationReason"/>).</summary>
public static class SessionRotationReasons
{
/// <summary>The backend refused the persisted id with <c>session_owner_mismatch</c>.</summary>
public const string OwnerMismatch = "owner_mismatch";
/// <summary>The backend answered 410 for the persisted id at a WhiteGlove Part 2 start.</summary>
public const string SessionGone = "session_gone";
}
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
@@ -308,12 +367,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
/// <summary>
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
/// The SessionId this run started with when the backend refused it (<c>session_owner_mismatch</c>,
/// or 410 at a WhiteGlove Part 2 start) and the session was rotated before the successful
/// registration. Null on the normal path.
/// </summary>
public string RotatedFromSessionId { get; }
/// <summary>One of <see cref="SessionRotationReasons"/> when <see cref="RotatedFromSessionId"/> is set, else null.</summary>
public string RotationReason { get; }
/// <summary>
/// True when the run ended on a 401/403 that something on the network path answered (TLS
/// inspection, a proxy page — <see cref="BackendAuthException.IsForeignResponse"/>), not
@@ -327,7 +389,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
int httpStatusCode,
string errorMessage,
string rotatedFromSessionId = null,
bool isIntercepted = false)
bool isIntercepted = false,
string rotationReason = null)
{
Outcome = outcome;
Response = response;
@@ -335,10 +398,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
ErrorMessage = errorMessage;
RotatedFromSessionId = rotatedFromSessionId;
IsIntercepted = isIntercepted;
RotationReason = rotatedFromSessionId != null ? rotationReason : null;
}
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId);
public static SessionRegistrationResult Succeeded(
RegisterSessionResponse response, string rotatedFromSessionId = null, string rotationReason = null)
=> new SessionRegistrationResult(
SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId, rotationReason: rotationReason);
public static SessionRegistrationResult SessionGone(int statusCode, string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.SessionGone, null, statusCode, message);
public static SessionRegistrationResult AuthFailed(int statusCode, string message, bool intercepted = false)
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message, isIntercepted: intercepted);
@@ -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);
@@ -257,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2
/// <c>true</c> when the file-based <c>enrollment-complete.marker</c> signals that a
/// previous session already completed enrollment — written by
/// <see cref="EndMonitoringAtStart"/> (emergency break, intercepted-registration give-up,
/// config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// session gone at registration, config kill signal) right before it fires the cleanup retry, so that a next-boot observing the marker exits
/// cleanly even if the cleanup retry failed to remove the Scheduled Task. Triggers another
/// self-destruct attempt when <paramref name="selfDestructOnComplete"/> is set.
/// <para>
@@ -369,10 +369,11 @@ namespace AutopilotMonitor.Agent.V2
/// next start exits cleanly even if the cleanup retry fails (Scheduled Task still alive,
/// files locked, …), runs the cleanup retry when <paramref name="runSelfDestruct"/> is set
/// (the emergency break and the give-up pass <c>SelfDestructOnComplete</c>, the kill signal
/// forces it), and deletes the persisted session files. Shared by the session-age emergency
/// break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>) and the config-channel kill signal
/// so all three leave the device in the same state. Never throws.
/// and the session-gone end force it), and deletes the persisted session files. Shared by
/// the session-age emergency break, the intercepted-registration give-up
/// (<c>BackendSessionRegistration.ConcludeFailedRun</c>), the registration answered with 410
/// (<c>BackendSessionRegistration.ConcludeSessionGone</c>) and the config-channel kill signal
/// so all four leave the device in the same state. Never throws.
/// </summary>
internal static void EndMonitoringAtStart(
string stateDirectory,
@@ -275,7 +275,9 @@ namespace AutopilotMonitor.Agent.V2
// mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration.
// Exit codes: 6 = AuthFailed, 7 = anything else. A registration intercepted on more
// starts than the buffer allows cleans up like the emergency break (state folder +
// cleanup factory) and still exits 6. See Runtime/BackendSessionRegistration.cs.
// cleanup factory) and still exits 6. A registration the backend answers with 410
// (session deleted) cleans up the same way and exits 0; a WhiteGlove Part 2 start
// registers a new session instead. See Runtime/BackendSessionRegistration.cs.
var registration = Runtime.BackendSessionRegistration.Register(
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
@@ -283,7 +285,8 @@ namespace AutopilotMonitor.Agent.V2
sessionPersistence: bootstrap.SessionPersistence,
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString(),
stateDirectory: stateSubdir,
cleanupServiceFactory: bootstrap.CleanupServiceFactory);
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
isWhiteGloveResume: bootstrap.IsWhiteGloveResume);
if (registration.ShouldExit)
{
return registration.ExitCode;
@@ -347,7 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger);
LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger,
rotatedFromSessionId: registrationResult?.RotatedFromSessionId,
priorRegistrationFailures: registration.PriorFailures);
priorRegistrationFailures: registration.PriorFailures,
sessionRotationReason: registrationResult?.RotationReason);
// Wire-visible signal that the agent is running on defaults/cache
// rather than the live tenant config — closes the cold-start
// blind spot observed in session 8f2bef72 (2026-05-19).
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// the next boot. The failed run is recorded for the emergency break report. A registration
/// that something on the network path answered gets <c>InterceptedRegistrationRetryBoots</c>
/// further boots, then the agent cleans up like the emergency break (<see cref="ConcludeFailedRun"/>).
/// A registration the backend answers with 410 — the session was deleted — ends monitoring at
/// once (<see cref="ConcludeSessionGone"/>); only a WhiteGlove Part 2 start registers a new
/// session instead, because its enrollment is only now continuing.
/// A successful registration after failed starts reports them on <c>agent_started</c>
/// (<see cref="ClearAfterSuccess"/>).
/// </summary>
@@ -37,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
internal static readonly TimeSpan PendingDistressBound = TimeSpan.FromSeconds(5);
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>).</param>
/// <param name="stateDirectory">State folder for <c>enrollment-complete.marker</c> on the intercepted-registration give-up and the session-gone end.</param>
/// <param name="cleanupServiceFactory">Self-destruct for the give-up (run when <c>SelfDestructOnComplete</c>) and the session-gone end (always run).</param>
/// <param name="isWhiteGloveResume">This start is a WhiteGlove Part 2: a 410 for the persisted session becomes a new session.</param>
public static SessionRegistrationOutcomeResult Register(
AgentConfiguration agentConfig,
BackendAuthBundle auth,
@@ -50,7 +54,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
SessionIdPersistence sessionPersistence = null,
string configFetchOutcome = null,
string stateDirectory = null,
Func<CleanupService> cleanupServiceFactory = null)
Func<CleanupService> cleanupServiceFactory = null,
bool isWhiteGloveResume = false)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -86,7 +91,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
rotateSession: rotateSession)
rotateSession: rotateSession,
rotateOnSessionGone: isWhiteGloveResume)
.GetAwaiter().GetResult();
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
@@ -97,6 +103,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
}
if (registrationResult.Outcome == SessionRegistrationOutcome.SessionGone)
{
// The report goes out through the auth bundle's client: dispose only afterwards.
var exitCode = ConcludeSessionGone(
agentConfig, auth.EmergencyReporter, sessionPersistence, stateDirectory,
cleanupServiceFactory, consoleMode, logger);
try { mtlsHttpClient?.Dispose(); } catch { }
try { auth.BackendApiClient?.Dispose(); } catch { }
return SessionRegistrationOutcomeResult.Exit(exitCode);
}
var record = RecordFailedRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
@@ -264,6 +281,41 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
/// <summary>
/// The end of a start whose registration the backend answered with 410: the session was
/// deleted. One report over the emergency channel (a deleted session has no timeline to
/// carry an event), then monitoring ends like on the telemetry-channel 410 — marker first,
/// self-destruct regardless of <c>SelfDestructOnComplete</c>, session files deleted
/// (<see cref="Program.EndMonitoringAtStart"/>). Returns exit code 0: an intended end.
/// </summary>
internal static int ConcludeSessionGone(
AgentConfiguration agentConfig,
EmergencyReporter emergencyReporter,
SessionIdPersistence sessionPersistence,
string stateDirectory,
Func<CleanupService> cleanupServiceFactory,
bool consoleMode,
AgentLogger logger)
{
logger.Error(
$"=== SESSION GONE (HTTP 410): the backend deleted session {agentConfig.SessionId} — " +
"reporting once over the emergency channel, then cleaning up with self-destruct. ===");
if (consoleMode)
Console.Error.WriteLine("Session was deleted in the backend. Cleaning up and exiting.");
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.RegistrationRejectedSessionGone,
"Session registration rejected with HTTP 410 — the backend deleted this session. Agent cleaning up with self-destruct.",
logger);
Program.EndMonitoringAtStart(
stateDirectory, sessionPersistence, runSelfDestruct: true, cleanupServiceFactory, logger,
markerText: $"Session gone at {DateTime.UtcNow:O} (registration refused with HTTP 410)",
cleanupReason: "session_gone");
return 0;
}
/// <summary>
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
RemoteConfigService remoteConfigService,
AgentLogger logger,
string rotatedFromSessionId = null,
PriorRegistrationFailures priorRegistrationFailures = null)
PriorRegistrationFailures priorRegistrationFailures = null,
string sessionRotationReason = null)
{
try
{
@@ -72,9 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{ "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
{ "previousExitType", previousExit?.ExitType ?? "unknown" },
{ "unrestrictedMode", agentConfig.UnrestrictedMode },
// SESSION-OWNER-BINDING: true when the backend refused the persisted session
// id (session_owner_mismatch) and this run rotated to a new session before
// registering — the re-enroll-without-wipe shape, made visible on the timeline.
// True when the backend refused the persisted session id and this run rotated
// to a new session before registering: session_owner_mismatch (re-enroll
// without wipe) or 410 at a WhiteGlove Part 2 start (session deleted).
// sessionRotationReason below names which.
{ "sessionRotated", rotatedFromSessionId != null },
{ "previousSessionId", rotatedFromSessionId ?? string.Empty },
// remoteConfigFetched answers "is the agent running on a live-fetched
@@ -96,6 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (recovered)
data["remoteConfigRecovered"] = true;
if (rotatedFromSessionId != null && !string.IsNullOrEmpty(sessionRotationReason))
data["sessionRotationReason"] = sessionRotationReason;
// Which bootstrap installed this agent (installer and loader versions, MSI or
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
// fleet's share of old or directly assigned installers countable.
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
@@ -193,14 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Runtime
};
}
// Bounded blocking send for the one session-gone report: the terminate that follows ends
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
private const int SessionGoneReportAttempts = 2;
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
/// <summary>
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
@@ -227,25 +218,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
if (emergencyReporter != null)
{
try
{
emergencyReporter
.TrySendAsync(
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: SessionGoneReportAttempts,
perAttemptTimeout: SessionGoneReportTimeout,
retryDelay: SessionGoneReportRetryDelay)
.Wait(SessionGoneReportBudget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
SessionGoneReport.SendBlocking(
emergencyReporter,
AgentErrorType.TelemetryRejectedSessionUnknown,
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
logger);
var terminateAction = new ServerAction
{
@@ -0,0 +1,50 @@
using System;
using System.Net;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Runtime
{
/// <summary>
/// The one emergency report an agent sends when the backend answered HTTP 410 for its own
/// session — on a telemetry upload (<see cref="ServerControlPlane.HandleSessionGone"/>) or at
/// registration (<see cref="BackendSessionRegistration"/>). The send blocks within a bound:
/// the caller ends the process right after and the cleanup deletes the agent, so a
/// fire-and-forget send would be abandoned mid-flight. Retries stay inside one anti-flood
/// reservation (ONE report). Never throws.
/// </summary>
internal static class SessionGoneReport
{
private const int Attempts = 2;
private static readonly TimeSpan PerAttemptTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(3);
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(40);
public static void SendBlocking(
EmergencyReporter emergencyReporter,
AgentErrorType errorType,
string message,
AgentLogger logger)
{
if (emergencyReporter == null) return;
try
{
emergencyReporter
.TrySendAsync(
errorType,
message,
httpStatusCode: (int)HttpStatusCode.Gone,
attempts: Attempts,
perAttemptTimeout: PerAttemptTimeout,
retryDelay: RetryDelay)
.Wait(Budget);
}
catch (Exception ex)
{
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
}
}
}
}
@@ -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")]
@@ -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()
{
@@ -0,0 +1,228 @@
using System.Net;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The agent gate never refuses on a cached configuration another instance's write has outdated:
/// before a refusal the configuration decides, it reads the stored row and decides on that. An admin
/// allows a manufacturer or switches a validation on, and the next device is admitted at once, on
/// every instance. A request the cached copy admits must not cost a read — that is the hot path.
/// The request comes from <see cref="CertTenantBindingTests"/>: a valid certificate of the sample
/// tenant, manufacturer "Contoso", model "TestBook".
/// </summary>
public sealed class ConfigConfirmBeforeRefusalTests
{
private static readonly string TenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
private sealed record Gate(SecurityValidator Validator, Mock<TenantConfigurationService> Config, Dictionary<object, object> Items);
/// <summary>A permissive tenant: no device validator needed, every hardware allowed.</summary>
private static TenantConfiguration Admitting(Action<TenantConfiguration>? change = null)
{
var config = new TenantConfiguration
{
TenantId = TenantId,
AllowInsecureAgentRequests = true,
ManufacturerWhitelist = "*",
ModelWhitelist = "*",
};
change?.Invoke(config);
return config;
}
/// <summary>
/// <paramref name="cached"/> is what this instance holds; <paramref name="stored"/> is what a read
/// before a refusal returns (null: nothing to read, as inside the confirm interval). Like the real
/// service, a confirming read makes the stored row the cached one.
/// </summary>
private static Gate Build(TenantConfiguration cached, TenantConfiguration? stored)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
var current = cached;
var config = new Mock<TenantConfigurationService>(configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
config.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => (current, true));
config.Setup(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>())).ReturnsAsync(() =>
{
if (stored != null) current = stored;
return stored;
});
var adminConfig = new Mock<AdminConfigurationService>(configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
var validator = new SecurityValidator(
configService: config.Object,
adminConfigService: adminConfig.Object,
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: Mock.Of<ILogger>(),
bootstrapSessionService: null,
deviceAssociationValidator: null);
return new Gate(validator, config, new Dictionary<object, object>());
}
private static Task<SecurityValidationResult> Validate(Gate gate)
=> gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items), TenantId);
private static string? Stamp(Gate gate)
=> gate.Items.TryGetValue(RequestRowMarkers.ConfigConfirmKey, out var value) ? value as string : null;
// ---------------------------------------------------------------- a refusal is confirmed first
public static TheoryData<string, TenantConfiguration, string> OutdatedCopies() => new()
{
{ "manufacturer just allowed", Admitting(c => c.ManufacturerWhitelist = "Dell*"), "Hardware not authorized" },
{ "model just allowed", Admitting(c => c.ModelWhitelist = "Latitude*"), "Hardware not authorized" },
{ "validation requirement just met", Admitting(c => c.AllowInsecureAgentRequests = false), "Device validation is required" },
{ "tenant just re-enabled", Admitting(c => { c.Disabled = true; c.DisabledReason = "paused"; }), "Tenant is suspended" },
};
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_stored_row_admits_what_the_outdated_copy_would_refuse(string _, TenantConfiguration outdated, string refusal)
{
var gate = Build(cached: outdated, stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid, $"refused with '{result.ErrorMessage}' although the stored row admits (outdated copy would say '{refusal}')");
Assert.Equal(RequestRowMarkers.ConfigConfirm.Averted, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task The_refusal_stands_when_the_stored_row_says_the_same(string _, TenantConfiguration refusing, string refusal)
{
var gate = Build(cached: refusing, stored: ConfigRepoMockExtensions.Clone(refusing));
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Theory]
[MemberData(nameof(OutdatedCopies))]
public async Task Without_a_read_the_cached_copy_decides(string _, TenantConfiguration refusing, string refusal)
{
// Inside the confirm interval, or storage failed: nothing newer to decide on.
var gate = Build(cached: refusing, stored: null);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal(refusal, result.ErrorMessage);
Assert.Null(Stamp(gate));
}
// ---------------------------------------------------------------- the hot path stays untouched
[Fact]
public async Task A_request_the_cached_copy_admits_reads_nothing()
{
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await Validate(gate);
Assert.True(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public async Task A_refusal_the_configuration_does_not_decide_reads_nothing()
{
// A certificate of another tenant: the configuration admits, the certificate does not.
var gate = Build(cached: Admitting(), stored: Admitting());
var result = await gate.Validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), gate.Items),
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
Assert.False(result.IsValid);
Assert.Null(Stamp(gate));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(It.IsAny<string>()), Times.Never);
}
// ---------------------------------------------------------------- the device validation chain
[Fact]
public async Task A_validation_switched_on_since_runs_the_chain_once_more_and_no_further()
{
// The Cloud PC validation is on in both rows, but this gate has no Cloud PC validator wired:
// nothing admits the device, on either pass. What counts here is the control flow.
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var stored = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; c.ValidateIntuneDeviceBinding = true; });
var gate = Build(cached, stored);
var result = await Validate(gate);
Assert.False(result.IsValid);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Rerun, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Exactly(2));
gate.Config.Verify(x => x.ConfirmBeforeRefusalAsync(TenantId), Times.Once);
}
[Fact]
public async Task The_chain_does_not_run_again_when_the_stored_row_asks_nothing_new()
{
var cached = Admitting(c => { c.AllowInsecureAgentRequests = false; c.ValidateCloudPcDevice = true; });
var gate = Build(cached, stored: ConfigRepoMockExtensions.Clone(cached));
var result = await Validate(gate);
Assert.Equal("Device not registered", result.ErrorMessage);
Assert.Equal(RequestRowMarkers.ConfigConfirm.Confirmed, Stamp(gate));
gate.Config.Verify(x => x.TryGetConfigurationAsync(TenantId), Times.Once);
}
[Fact]
public void Only_a_validation_that_was_off_and_is_now_on_asks_for_another_pass()
{
var used = new TenantConfiguration { ValidateAutopilotDevice = true };
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true }));
Assert.False(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration()));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateAutopilotDevice = true, ValidateCorporateIdentifier = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateDeviceAssociation = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateCloudPcDevice = true }));
Assert.True(SecurityValidator.SwitchesOnUnaskedValidation(used, new TenantConfiguration { ValidateIntuneDeviceBinding = true }));
}
// ---------------------------------------------------------------- the patch marker
[Fact]
public void A_field_patch_counts_the_sent_fields_the_stored_row_already_held()
{
static PatchOutcome Applied(params string[] changed)
=> new(true, PatchFailure.None, null, null, changed, new Dictionary<string, string>(), null);
// Wire names are camelCase, applied names are the model's property names.
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "logLevel" }, Applied("LogLevel")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "enableTimezoneAutoSet", "rebootOnComplete" }, Applied("RebootOnComplete")));
Assert.Equal(1, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(new[] { "diagnosticsUploadMode" }, Applied()));
Assert.Equal(0, PatchTenantConfigurationFieldsFunction.UnchangedFieldCount(
new[] { "logLevel" }, PatchOutcome.Fail(PatchFailure.ValidationFailed, "invalid")));
}
}
@@ -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"));
}
}
}
@@ -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);
@@ -0,0 +1,55 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Graph;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// POST tenants/{tenantId}/scripts/display-names: a lookup that ran into a time limit says so in
/// its answer, so the portal asks once more, and the request row tells a cut first lookup, a
/// follow-up and a follow-up that was cut again apart. A first lookup within its limits is the
/// normal case and carries neither the flag nor a marker.
/// </summary>
public class GetScriptDisplayNamesFunctionTests
{
private const string TenantId = "00000000-0000-4000-8000-000000000002";
private const string Ref = "Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001";
[Theory]
[InlineData(false, false, null)]
[InlineData(true, false, "timedOut")]
[InlineData(false, true, "retry")]
[InlineData(true, true, "retryTimedOut")]
public async Task A_cut_lookup_is_flagged_in_the_answer_and_marked_on_the_request_row(
bool timedOut, bool isFollowUp, string? expectedMarker)
{
var resolver = new Mock<IScriptDisplayNameResolver>();
resolver
.Setup(r => r.ResolveAsync(TenantId, It.IsAny<IReadOnlyCollection<ScriptRef>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, IReadOnlyCollection<ScriptRef> refs, CancellationToken _) =>
new ScriptDisplayNameLookup(refs.ToDictionary(r => r, _ => (string?)null), timedOut));
var function = new GetScriptDisplayNamesFunction(NullLogger<GetScriptDisplayNamesFunction>.Instance, resolver.Object);
var body = "{\"refs\":[\"" + Ref + "\"]" + (isFollowUp ? ",\"retry\":true" : "") + "}";
var (req, context) = EndpointHarness.Request(TenantId, jsonBody: body);
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
Assert.True(doc.RootElement.GetProperty("refs").TryGetProperty(Ref, out _));
// The key is left out, not false, when no time limit fired.
Assert.Equal(timedOut, doc.RootElement.TryGetProperty("timedOut", out var flag));
if (timedOut) Assert.True(flag.GetBoolean());
if (expectedMarker == null)
Assert.False(context.Items.ContainsKey(RequestRowMarkers.ScriptNameLookupKey));
else
Assert.Equal(expectedMarker, context.Items[RequestRowMarkers.ScriptNameLookupKey]);
}
}
@@ -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);
}
}
@@ -25,10 +25,21 @@ internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
}
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
/// <summary>Tenants whose token never arrives: the call waits until the caller's budget cancels it.</summary>
public HashSet<string> HangingTenants { get; } = new();
/// <summary>When set, <see cref="TryGetTokenContextAsync"/> throws it.</summary>
public Exception? TokenContextThrows { get; set; }
public async Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
{
if (TokenContextThrows != null) throw TokenContextThrows;
if (HangingTenants.Contains(tenantId))
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
}
ContextByTenant.TryGetValue(tenantId, out var ctx);
return Task.FromResult(ctx);
return ctx;
}
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
@@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
@@ -28,9 +31,43 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.Single(result);
Assert.Null(result[refs[0]]);
Assert.Single(result.Names);
Assert.Null(result.Names[refs[0]]);
Assert.Empty(fix.Http.Requests); // Graph never called when permission gate fails
Assert.Empty(fix.IncompleteLines); // "no token or no role" is an answer, not a cut
Assert.False(result.TimedOut);
}
[Fact]
public async Task Token_that_outlasts_its_budget_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture(tokenAcquireBudget: TimeSpan.FromMilliseconds(50));
fix.Detector.HangingTenants.Add(TenantId);
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
var line = Assert.Single(fix.IncompleteLines);
Assert.Contains($"tenant={TenantId} ", line);
Assert.Contains("reasons=TokenTimeout ", line);
Assert.Contains("requested=1 ", line);
Assert.True(result.TimedOut);
}
[Fact]
public async Task Failing_token_context_is_reported_as_an_incomplete_lookup()
{
var fix = new Fixture();
fix.Detector.TokenContextThrows = new InvalidOperationException("simulated");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Contains("reasons=TokenError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // a failure is not a time limit: asking again is not suggested
}
[Fact]
@@ -58,7 +95,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("MyScript.ps1", result[r]);
Assert.Equal("MyScript.ps1", result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -77,11 +114,78 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Bootstrap", result[r]);
Assert.Equal("Bootstrap", result.Names[r]);
Assert.Single(fix.Http.Requests);
// Both list entries cached for future calls.
Assert.Equal(2, fix.Repo.Entries.Count);
Assert.True(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Empty(fix.IncompleteLines);
}
[Fact]
public async Task Script_missing_from_the_list_is_still_resolved_by_id()
{
// The list leaves out some scripts that a single GET returns. Treating "not in the list"
// as "not found" would drop their names.
var fix = new Fixture();
fix.GivenPermission();
fix.Http.When("deviceHealthScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"listed\",\"displayName\":\"Listed\"}]}");
fix.Http.When("deviceHealthScripts/unlisted", HttpStatusCode.OK,
"{\"id\":\"unlisted\",\"displayName\":\"Unlisted\"}");
var listed = new ScriptRef(ScriptKind.Remediation, "listed");
var unlisted = new ScriptRef(ScriptKind.Remediation, "unlisted");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { listed, unlisted });
Assert.Equal("Listed", result.Names[listed]);
Assert.Equal("Unlisted", result.Names[unlisted]);
Assert.Equal(2, fix.Http.Requests.Count);
Assert.False(fix.Repo.Entries[(TenantId, ScriptKind.Remediation, "unlisted")].IsNotFound);
Assert.Empty(fix.IncompleteLines);
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_list_pull_is_reported_and_skips_the_single_lookups()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Http.WhenTimedOut("deviceManagementScripts?");
fix.Http.When("deviceManagementScripts/abc", HttpStatusCode.OK,
"{\"id\":\"abc\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var r = new ScriptRef(ScriptKind.Platform, "abc");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.Contains("reasons=ListTimeout:Platform ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Fact]
public async Task Budget_ending_between_list_pages_stores_nothing_and_leaves_the_kind_unfetched()
{
// A list cut after its first page is incomplete. Stored together with the meta row it
// would count as the tenant's whole list for a week.
var fix = new Fixture();
fix.GivenPermission();
using var caller = new CancellationTokenSource();
fix.Http.OnRequest = _ => caller.Cancel();
fix.Http.When("deviceManagementScripts?", HttpStatusCode.OK,
"{\"value\":[{\"id\":\"id-1\",\"displayName\":\"Bootstrap\"}]," +
"\"@odata.nextLink\":\"https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$skiptoken=2\"}");
var r = new ScriptRef(ScriptKind.Platform, "id-1");
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r }, caller.Token);
Assert.Equal("Bootstrap", result.Names[r]); // page one was read before the cut
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries);
Assert.False(fix.Repo.Meta.ContainsKey((TenantId, ScriptKind.Platform)));
Assert.True(result.TimedOut);
}
[Fact]
@@ -102,7 +206,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Equal("Net-New", result[r]);
Assert.Equal("Net-New", result.Names[r]);
Assert.Single(fix.Http.Requests);
Assert.Contains("deviceHealthScripts/net-new-id", fix.Http.Requests[0]);
// Per-ID fetch also writes to cache.
@@ -125,9 +229,40 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.True(fix.Repo.Entries.ContainsKey((TenantId, ScriptKind.Platform, "deleted-id")));
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
Assert.Empty(fix.IncompleteLines); // a 404 is an answer
Assert.False(result.TimedOut);
}
[Fact]
public async Task Timed_out_single_lookup_is_reported_and_ends_the_loop()
{
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Remediation)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
fix.Http.WhenTimedOut("deviceHealthScripts/first");
fix.Http.When("deviceHealthScripts/second", HttpStatusCode.OK,
"{\"id\":\"second\",\"displayName\":\"SHOULD-NOT-BE-RETURNED\"}");
var refs = new[]
{
new ScriptRef(ScriptKind.Remediation, "first"),
new ScriptRef(ScriptKind.Remediation, "second"),
};
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests);
Assert.Empty(fix.Repo.Entries); // a cut is not an answer: nothing is cached as not found
Assert.Contains("reasons=LookupTimeout ", Assert.Single(fix.IncompleteLines));
Assert.True(result.TimedOut);
}
[Theory]
@@ -152,8 +287,8 @@ public class ScriptDisplayNameResolverTests
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.Null(first.Names[r]);
Assert.Null(second.Names[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
@@ -168,7 +303,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, Array.Empty<ScriptRef>());
Assert.Empty(result);
Assert.Empty(result.Names);
Assert.Empty(fix.Http.Requests);
}
@@ -182,7 +317,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
}
[Fact]
@@ -200,9 +335,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]); // per-ID never ran
Assert.Null(result.Names[r]); // per-ID never ran
Assert.Single(fix.Http.Requests); // only the failed full-pull
Assert.Contains("deviceManagementScripts?", fix.Http.Requests[0]);
Assert.Contains("reasons=ListError:Platform ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut); // Graph refused: an immediate second lookup would add to the throttle
}
[Fact]
@@ -234,9 +371,11 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, refs);
Assert.All(refs, r => Assert.Null(result[r]));
Assert.All(refs, r => Assert.Null(result.Names[r]));
Assert.Single(fix.Http.Requests); // second call never issued
Assert.Contains("deviceHealthScripts/first", fix.Http.Requests[0]);
Assert.Contains("reasons=LookupError ", Assert.Single(fix.IncompleteLines));
Assert.False(result.TimedOut);
}
[Fact]
@@ -257,7 +396,7 @@ public class ScriptDisplayNameResolverTests
var result = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(result[r]);
Assert.Null(result.Names[r]);
Assert.Empty(fix.Http.Requests);
}
@@ -269,19 +408,28 @@ public class ScriptDisplayNameResolverTests
public FakeScriptNameCacheRepository Repo { get; } = new();
public StubHttpMessageHandler Http { get; } = new();
public StubHttpClientFactory HttpFactory { get; }
public Mock<ILogger<ScriptDisplayNameResolver>> Logger { get; } = new();
public ScriptDisplayNameResolver Sut { get; }
public DateTimeOffset FixedNow { get; } = new(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
public Fixture()
public Fixture(TimeSpan? tokenAcquireBudget = null)
{
HttpFactory = new StubHttpClientFactory(Http);
Sut = new ScriptDisplayNameResolver(
Detector, Repo, HttpFactory,
NullLogger<ScriptDisplayNameResolver>.Instance,
Logger.Object,
new TelemetryClient(new TelemetryConfiguration()),
new FakeTimeProvider(FixedNow));
new FakeTimeProvider(FixedNow),
tokenAcquireBudget);
}
/// <summary>The summary lines written for lookups that ended without every answer.</summary>
public List<string> IncompleteLines => Logger.Invocations
.Where(i => i.Method.Name == nameof(ILogger.Log) && i.Arguments[0] is LogLevel.Warning)
.Select(i => i.Arguments[2]?.ToString() ?? string.Empty)
.Where(m => m.StartsWith("Script-name lookup incomplete:", StringComparison.Ordinal))
.ToList();
public void GivenPermission()
{
Detector.ContextByTenant[TenantId] = new GraphTenantTokenContext
@@ -12,8 +12,7 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
/// <list type="bullet">
/// <item>Positive rows older than 7 days are removed.</item>
/// <item>Positive rows younger than 7 days are kept.</item>
/// <item>NotFound rows older than 1 hour are removed.</item>
/// <item>NotFound rows younger than 1 hour are kept.</item>
/// <item>NotFound rows follow the same 7 days: older ones are removed, younger ones are kept.</item>
/// <item>A failed delete on one row doesn't poison the run — others still get processed.</item>
/// </list>
/// </summary>
@@ -55,18 +54,20 @@ public class ScriptNameCacheCleanupFunctionTests
}
[Fact]
public async Task Deletes_notfound_rows_older_than_1_hour()
public async Task Deletes_notfound_rows_only_after_the_same_7_days()
{
// A not-found row that goes after hours instead of days sends one Graph lookup per
// unknown ID on the first view after every cleanup run.
var fix = new Fixture();
var deletedId = "deleted-1h-ago";
var recentId = "recent-30min";
var deletedId = "not-found-8-days-ago";
var recentId = "not-found-2-hours-ago";
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, deletedId)] = new ScriptDisplayNameEntry
{
TenantId = TenantId,
Kind = ScriptKind.Remediation,
ScriptId = deletedId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
FetchedAt = fix.FixedNow - TimeSpan.FromDays(8),
IsNotFound = true,
};
fix.Repo.Entries[(TenantId, ScriptKind.Remediation, recentId)] = new ScriptDisplayNameEntry
@@ -75,7 +76,7 @@ public class ScriptNameCacheCleanupFunctionTests
Kind = ScriptKind.Remediation,
ScriptId = recentId,
DisplayName = null,
FetchedAt = fix.FixedNow - TimeSpan.FromMinutes(30),
FetchedAt = fix.FixedNow - TimeSpan.FromHours(2),
IsNotFound = true,
};
@@ -16,18 +16,34 @@ namespace AutopilotMonitor.Functions.Tests.GraphResolution;
internal sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly List<(string UrlSubstring, HttpStatusCode Status, string Body)> _scripted = new();
private readonly List<string> _timedOut = new();
public List<string> Requests { get; } = new();
/// <summary>Runs when a request arrives, before it is answered (e.g. to cancel the caller mid-flight).</summary>
public Action<string>? OnRequest { get; set; }
public StubHttpMessageHandler When(string urlSubstring, HttpStatusCode status, string body)
{
_scripted.Add((urlSubstring, status, body));
return this;
}
/// <summary>Scripts a request that ends the way a per-request timeout does, without waiting.</summary>
public StubHttpMessageHandler WhenTimedOut(string urlSubstring)
{
_timedOut.Add(urlSubstring);
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
OnRequest?.Invoke(url);
if (_timedOut.Any(s => url.Contains(s, StringComparison.OrdinalIgnoreCase)))
{
throw new TaskCanceledException($"StubHttpMessageHandler: scripted timeout for {url}");
}
var match = _scripted.FirstOrDefault(s => url.Contains(s.UrlSubstring, StringComparison.OrdinalIgnoreCase));
if (match.Equals(default((string, HttpStatusCode, string))))
{
@@ -76,6 +76,34 @@ public class GraphWireParityTests
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
}
[Fact]
public void GetScriptDisplayNamesResponse_carries_timedOut_only_when_a_time_limit_fired()
{
// The portal asks once more when it sees timedOut. Null leaves the key out, so an
// answer within the limits keeps the shapes pinned above.
var payload = new Dictionary<string, string?>
{
["Platform:6cef8fa7-9163-40cf-8f56-52f3aaaa1006"] = null,
};
AssertParity(
new { refs = payload, timedOut = true },
new GetScriptDisplayNamesResponse { Refs = payload, TimedOut = true });
}
[Fact]
public void ScriptDisplayNamesRequest_reads_the_retry_flag_and_tolerates_its_absence()
{
var options = ApiJsonOptions.Create();
const string Ref = "\"Platform:7df090b8-a274-41d0-9067-52f3aaaa1007\"";
var followUp = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "],\"retry\":true}", options);
var first = JsonSerializer.Deserialize<ScriptDisplayNamesRequest>("{\"refs\":[" + Ref + "]}", options);
Assert.True(followUp!.Retry);
Assert.Null(first!.Retry);
}
[Fact]
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
{
@@ -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."
});
}
@@ -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).");
}
}
@@ -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.
@@ -0,0 +1,116 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>X-Tenant-Config: stored</c> (<c>readStoredConfig</c>): a signed-in caller that has just written a
/// tenant configuration reads the stored row instead of this instance's cached copy — for its own tenant
/// and for a target it may reach. Without the header nothing changes: no extra read on any request.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private static string FlagsPath(string tenantId) => $"/api/config/{tenantId}/feature-flags";
private static void StoreRows(Harness h)
=> h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string tenantId) => TenantConfiguration.CreateDefault(tenantId));
private static int Reads(Harness h, string tenantId)
=> h.ConfigRepo.Invocations.Count(i =>
i.Method.Name == nameof(AutopilotMonitor.Shared.DataAccess.IConfigRepository.GetTenantConfigurationAsync)
&& string.Equals(i.Arguments[0] as string, tenantId, StringComparison.OrdinalIgnoreCase));
[Fact]
public async Task Without_the_header_a_request_reads_the_cached_copy_only()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed); // warms the cache
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_the_callers_tenant_is_read_from_storage_despite_a_cached_copy()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller);
h.ConfigRepo.Invocations.Clear();
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
Assert.Equal(1, Reads(h, TenantA));
}
[Fact]
public async Task With_the_header_a_Global_Admin_reads_its_own_and_the_target_tenant()
{
// A Global Admin skips the suspension gate, so without the header nothing here reads the target.
var h = BuildHarness();
StoreRows(h);
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var caller = AuthedPrincipal(TenantA, "ga@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller); // warms whatever the decision caches
h.ConfigRepo.Invocations.Clear();
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller)).Allowed);
Assert.Equal(0, Reads(h, TenantA) + Reads(h, TenantB));
Assert.True((await h.Middleware.DecideAsync("GET", FlagsPath(TenantB), null, caller, readStoredConfig: true)).Allowed);
Assert.Equal(1, Reads(h, TenantA));
Assert.Equal(1, Reads(h, TenantB));
}
[Fact]
public async Task The_header_never_makes_an_instance_read_a_tenant_the_caller_may_not_reach()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync(
"GET", FlagsPath(TenantB), null, AuthedPrincipal(TenantA, "admin@contoso.com"), readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Equal(0, Reads(h, TenantB));
}
[Fact]
public async Task Without_a_signed_in_caller_the_header_reads_nothing()
{
var h = BuildHarness();
StoreRows(h);
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, principal: null, readStoredConfig: true);
Assert.False(r.Allowed);
Assert.Empty(h.ConfigRepo.Invocations);
}
[Fact]
public async Task A_storage_failure_on_that_read_does_not_fail_the_request()
{
var h = BuildHarness();
StoreRows(h);
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var caller = AuthedPrincipal(TenantA, "admin@contoso.com");
await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller); // a cached copy exists
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
var r = await h.Middleware.DecideAsync("GET", FlagsPath(TenantA), null, caller, readStoredConfig: true);
Assert.True(r.Allowed);
}
}
@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <c>GET config/{tenantId}</c> is the baseline of the settings editor and of every read-modify-write
/// in the portal, <c>GET config/{tenantId}/feature-flags</c> decides its banners and gates. Both
/// answer with the tenant's row or with an error — never with the synthesized default the plain
/// cached read hands out when storage fails.
/// </summary>
public sealed class PortalConfigReadTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static (TenantConfigurationService Service, StoredTenantConfig Store) Service(TenantConfiguration? row)
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var service = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return (service, store);
}
private static TenantConfiguration Row(string manufacturers)
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ManufacturerWhitelist = manufacturers;
return row;
}
private static string? Manufacturers(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
{
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return doc.RootElement.GetProperty("manufacturerWhitelist").GetString();
}
[Fact]
public async Task Config_answers_with_the_tenants_row()
{
var (service, _) = Service(Row("Dell*"));
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Dell*", Manufacturers(response));
}
[Fact]
public async Task Config_answers_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
public async Task Config_still_creates_the_default_row_for_a_tenant_without_one()
{
var (service, store) = Service(null);
var (req, _) = EndpointHarness.Request(TenantId);
var response = await new GetTenantConfigurationFunction(NullLogger<GetTenantConfigurationFunction>.Instance, service).Run(req, TenantId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(store.Row);
Assert.Equal(TenantConfiguration.CreateDefault(TenantId).ManufacturerWhitelist, Manufacturers(response));
}
[Fact]
public async Task Flags_answer_a_storage_failure_with_an_error_not_with_defaults()
{
var (service, store) = Service(Row("Dell*"));
store.ThrowOnRead = new InvalidOperationException("storage unavailable");
var (req, _) = EndpointHarness.Request(TenantId);
// The read fails before the homing service or the enrollment counter is asked.
var function = new GetTenantFeatureFlagsFunction(
NullLogger<GetTenantFeatureFlagsFunction>.Instance, service, appHomingService: null!, Mock.Of<IMetricsRepository>());
var response = await function.Run(req, TenantId);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
}
@@ -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();
@@ -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));
}
}
@@ -175,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");
}
}
@@ -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()
{
@@ -0,0 +1,207 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A tenant configuration is cached per backend instance, and a write clears only the cache of the
/// instance that served it. These tests model two instances (two services, each with its own cache)
/// over one stored row and pin the two ways the stored row reaches the other instance without
/// shortening the cache: <see cref="TenantConfigurationService.RefreshFromStoreAsync"/> for a request
/// that follows its caller's own write, and <see cref="TenantConfigurationService.ConfirmBeforeRefusalAsync"/>
/// before a refusal.
/// </summary>
public sealed class TenantConfigReadAfterWriteTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTimeOffset Start = new(2026, 11, 11, 8, 0, 0, TimeSpan.Zero);
private sealed record Instances(
TenantConfigurationService A, TenantConfigurationService B, StoredTenantConfig Store, Mock<IConfigRepository> Repo, TestTimeProvider Clock);
private static Instances TwoInstances(Action<TenantConfiguration>? seed = null)
{
var row = TenantConfiguration.CreateDefault(TenantId);
seed?.Invoke(row);
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(row);
var clock = new TestTimeProvider(Start);
TenantConfigurationService Instance() => new(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()),
ManagedTenantProIndex.None, clock);
return new Instances(Instance(), Instance(), store, repo, clock);
}
private static Task<TenantConfigUpdate> AllowOnly(TenantConfigurationService instance, string manufacturers)
=> instance.UpdateAsync(TenantId, row =>
{
row.ManufacturerWhitelist = manufacturers;
return true;
}, "test", "hardware");
// ---------------------------------------------------------------- the hazard itself
[Fact]
public async Task A_write_through_one_instance_leaves_the_other_instances_cached_copy_untouched()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B holds a copy from before the write
await AllowOnly(s.A, "Dell*");
Assert.Equal("Dell*", (await s.A.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
// This is the behaviour the two reads below exist for; it changes only with the cache itself.
Assert.NotEqual("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
// ---------------------------------------------------------------- RefreshFromStoreAsync
[Fact]
public async Task Refresh_puts_the_stored_row_into_the_instance_cache_for_every_later_read()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal("Dell*", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
Assert.Equal("Dell*", (await s.B.GetConfigurationIfExistsAsync(TenantId))!.ManufacturerWhitelist);
var (viaGate, exists) = await s.B.TryGetConfigurationAsync(TenantId);
Assert.True(exists);
Assert.Equal("Dell*", viaGate.ManufacturerWhitelist);
}
[Fact]
public async Task A_failed_refresh_keeps_the_cached_copy_and_does_not_fail_the_request()
{
var s = TwoInstances();
var before = (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist;
await AllowOnly(s.A, "Dell*");
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
await s.B.RefreshFromStoreAsync(TenantId);
Assert.Equal(before, (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Refresh_of_a_tenant_without_a_row_creates_nothing()
{
var repo = new Mock<IConfigRepository>();
var store = repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
await service.RefreshFromStoreAsync(TenantId);
Assert.Null(store.Row);
Assert.Empty(store.Writes);
}
// ---------------------------------------------------------------- ConfirmBeforeRefusalAsync
[Fact]
public async Task Confirm_returns_the_stored_row_and_refreshes_the_cache()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
await AllowOnly(s.A, "Dell*,Contoso");
var stored = await s.B.ConfirmBeforeRefusalAsync(TenantId);
Assert.Equal("Dell*,Contoso", stored!.ManufacturerWhitelist);
Assert.Equal("Dell*,Contoso", (await s.B.GetConfigurationAsync(TenantId)).ManufacturerWhitelist);
}
[Fact]
public async Task Confirm_reads_storage_once_per_interval_per_tenant()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval - TimeSpan.FromSeconds(1));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
s.Clock.SetUtcNow(Start + TenantConfigurationService.RefusalConfirmInterval);
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
}
[Fact]
public async Task Confirm_counts_each_tenant_on_its_own_and_ignores_the_case_of_the_id()
{
var s = TwoInstances();
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId.ToUpperInvariant()));
Assert.NotNull(await s.B.ConfirmBeforeRefusalAsync("22222222-2222-2222-2222-222222222222"));
}
[Fact]
public async Task Confirm_answers_null_when_the_read_fails_or_no_row_exists_so_the_refusal_stands()
{
var s = TwoInstances();
s.Store.ThrowOnRead = new InvalidOperationException("storage unavailable");
Assert.Null(await s.B.ConfirmBeforeRefusalAsync(TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.Null(await service.ConfirmBeforeRefusalAsync(TenantId));
Assert.Null(await service.ConfirmBeforeRefusalAsync(string.Empty));
}
// ---------------------------------------------------------------- the bootstrap feature switch
[Fact]
public async Task Bootstrap_gate_sees_a_plan_change_another_instance_wrote()
{
var s = TwoInstances();
await s.B.GetConfigurationAsync(TenantId); // B: still the free plan
Assert.False(TenantEntitlementService.IsBootstrapEnabled((await s.B.GetConfigurationAsync(TenantId)), DateTime.UtcNow));
await s.A.UpdateAsync(TenantId, row =>
{
row.BootstrapTokenEnabled = true;
return true;
}, "test", "bootstrap");
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
}
[Fact]
public async Task Bootstrap_gate_reads_nothing_extra_while_the_cached_copy_says_yes()
{
var s = TwoInstances(row => row.BootstrapTokenEnabled = true);
await s.B.GetConfigurationAsync(TenantId);
s.Repo.Invocations.Clear();
Assert.True(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
s.Repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Bootstrap_gate_refuses_a_disabled_feature_and_an_unknown_tenant()
{
var s = TwoInstances();
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(s.B, TenantId));
var repo = new Mock<IConfigRepository>();
repo.BackWithRow(null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), new MemoryCache(new MemoryCacheOptions()));
Assert.False(await BootstrapFeatureGate.IsEnabledAsync(service, TenantId));
}
}
@@ -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]
@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet for the portal's "I have just written the configuration" hint. After a successful write
/// under <c>/api/config/</c> (or one of the listed routes) the portal asks for the stored tenant
/// configuration for a few minutes, because a write clears only the cache of the instance that
/// served it (web <c>lib/storedConfigHint.ts</c>, <c>writesTenantConfig</c>). A function that writes
/// the tenant row from a route the portal does not recognise brings the outdated reads back for
/// that route — so every such function lives under <c>Functions/Config</c> with routes below
/// <c>config/</c>, or is listed here with the reason it needs no hint.
/// </summary>
public class TenantConfigWriteRouteGuardTests
{
private static readonly Regex TenantConfigWrite = new(
@"_(?:tenantConfigService|configService|tenantConfigurationService)\s*\.\s*(?:UpdateAsync|CreateOrUpdateAsync)\("
+ @"|_patchService\s*\.\s*(?:ApplyFieldPatchAsync|RevertAsync)\(",
RegexOptions.Compiled);
private static readonly Regex WriteRoute = new(
@"HttpTrigger\([^)]*?""(?:post|put|patch|delete)""[^)]*?Route\s*=\s*""(?<route>[^""]+)""",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
/// <summary>Writers outside <c>Functions/Config</c>: file → (why, the portal route the web must list, if any).</summary>
private static readonly IReadOnlyDictionary<string, (string Why, string? PortalRoute)> Baseline =
new Dictionary<string, (string, string?)>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
("the terms confirmation is on the portal's list; the sign-in writes of auth/me answer with what they wrote", "/api/auth/dpa-acceptance"),
["Functions/Admin/TenantOffboardFunction.cs"] =
("writes the offboarding tombstone; the tenant's members are locked out from then on", null),
};
private static string RepoRoot()
{
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;
}
[Fact]
public void A_function_that_writes_the_tenant_row_uses_a_route_the_portal_recognises()
{
var functionsRoot = Path.Combine(RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
var portalList = File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Web", "autopilot-monitor-web", "lib", "storedConfigHint.ts"));
Assert.Contains("\"/api/config/\"", portalList);
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var path in Directory.EnumerateFiles(Path.Combine(functionsRoot, "Functions"), "*.cs", SearchOption.AllDirectories))
{
var source = File.ReadAllText(path);
if (!TenantConfigWrite.IsMatch(source))
continue;
var rel = Path.GetRelativePath(functionsRoot, path).Replace('\\', '/');
seen.Add(rel);
if (Baseline.TryGetValue(rel, out var entry))
{
if (entry.PortalRoute != null && !portalList.Contains($"\"{entry.PortalRoute}\"", StringComparison.Ordinal))
offenders.Add($"{rel}: the portal no longer lists {entry.PortalRoute}");
continue;
}
if (!rel.StartsWith("Functions/Config/", StringComparison.Ordinal))
{
offenders.Add($"{rel}: writes the tenant row outside Functions/Config");
continue;
}
foreach (Match route in WriteRoute.Matches(source))
{
var value = route.Groups["route"].Value;
if (!value.StartsWith("config/", StringComparison.Ordinal) && !value.StartsWith("global/config", StringComparison.Ordinal))
offenders.Add($"{rel}: write route '{value}' is not under config/");
}
}
Assert.True(offenders.Count == 0,
"A tenant-configuration write the portal's hint does not cover — move the route under config/, or add the file to the "
+ "baseline with its reason and, for a portal route, extend writesTenantConfig in lib/storedConfigHint.ts:\n "
+ string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries that no longer write the tenant row — remove them:\n " + string.Join("\n ", stale));
}
}
@@ -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,
@@ -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();
@@ -86,36 +86,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
OpsEventQueryFilters? filters = null)
{
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
try
// Single-category path: PK-targeted query, RowKey is reverse-tick →
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
if (!string.IsNullOrEmpty(category))
{
// Single-category path: PK-targeted query, RowKey is reverse-tick →
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
if (!string.IsNullOrEmpty(category))
{
var filter = BuildFilter(category, dateFrom, dateTo, filters);
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: _table,
filter: filter,
pageSize: pageSize,
continuation: continuation);
var filter = BuildFilter(category, dateFrom, dateTo, filters);
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: _table,
filter: filter,
pageSize: pageSize,
continuation: continuation);
var page = new List<OpsEventEntry>(entities.Count);
foreach (var entity in entities) page.Add(MapToEntry(entity));
return new RawPage<OpsEventEntry>(page, nextRawToken);
}
var page = new List<OpsEventEntry>(entities.Count);
foreach (var entity in entities) page.Add(MapToEntry(entity));
return new RawPage<OpsEventEntry>(page, nextRawToken);
}
// All-category path: per-partition fan-out + merge-sort. Azure pages
// cross-partition queries by (PK asc, RK asc), so without this fan-out
// the first page would come entirely from the alphabetically-first
// category — defeating "newest first globally". Mirrors the same
// pattern used for cross-tenant session queries.
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get ops events page");
return RawPage<OpsEventEntry>.Empty;
}
// All-category path: per-partition fan-out + merge-sort. Azure pages
// cross-partition queries by (PK asc, RK asc), so without this fan-out
// the first page would come entirely from the alphabetically-first
// category — defeating "newest first globally". Mirrors the same
// pattern used for cross-tenant session queries.
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
}
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
@@ -135,7 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
var read = new List<(string RowKey, OpsEventEntry Item)>();
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
{
read.Add((e.RowKey, MapToEntry(e)));
if (read.Count >= limit) break;
@@ -71,8 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -83,8 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var registration = request.Registration;
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, registration.TenantId))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
@@ -72,8 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -53,8 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var request = read.Value!;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -2,6 +2,7 @@ using System;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -94,8 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
@@ -42,8 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var tenantId = requestCtx.TargetTenantId;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
// Return empty list instead of error — feature simply not visible
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
@@ -47,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var userIdentifier = requestCtx.UserPrincipalName;
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, tenantId))
{
return await req.ForbiddenAsync("Bootstrap token feature is not enabled for this tenant");
}
@@ -75,8 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
}
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
if (!await BootstrapFeatureGate.IsEnabledAsync(_configService, session.TenantId))
{
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await req.NotFoundAsync("Code not found, expired, or revoked");
@@ -36,7 +36,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
_logger.LogInformation($"GetTenantConfiguration: {requestCtx.TargetTenantId} by user {userIdentifier}");
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default: this answer is the baseline
// of the settings editor and of every read-modify-write in the portal, and the plain
// cached read answers a storage failure with defaults. A tenant without a row still
// gets its default row.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Only a caller with WRITE authority over THIS target tenant sees the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
@@ -1,9 +1,11 @@
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -16,15 +18,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly AppHomingService _appHomingService;
private readonly IMetricsRepository _metricsRepository;
/// <summary>
/// Tenants whose enrollment counter was seen above zero. The counter only grows, so a seen
/// "true" costs no further read on this instance. An offboarded and re-onboarded tenant starts
/// at zero again; until this instance recycles, its setup guide then stays hidden here.
/// </summary>
private static readonly ConcurrentDictionary<string, bool> KnownEnrolledTenants = new(StringComparer.OrdinalIgnoreCase);
public GetTenantFeatureFlagsFunction(
ILogger<GetTenantFeatureFlagsFunction> logger,
TenantConfigurationService configService,
AppHomingService appHomingService)
AppHomingService appHomingService,
IMetricsRepository metricsRepository)
{
_logger = logger;
_configService = configService;
_appHomingService = appHomingService;
_metricsRepository = metricsRepository;
}
/// <summary>
@@ -35,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
///
/// Adding a field here is a deliberate decision that the field is non-sensitive: it must not
/// expose webhook URLs, SAS tokens, admin allowlists, or any other admin-only data. The full
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, gated
/// to TenantAdminOrGA.
/// configuration (which does contain such data) lives behind GET /api/config/{tenantId}, which
/// redacts its secrets for every caller without write authority over the tenant.
/// </summary>
[Function("GetTenantFeatureFlags")]
public async Task<HttpResponseData> Run(
@@ -48,11 +60,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// The tenant's row or an error, never a synthesized default (see GetTenantConfiguration):
// the flags decide banners and gates, and a failed answer is not cached by the portal.
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId)
?? await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
? await HasEnrollmentSessionsAsync(requestCtx.TargetTenantId)
: null;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive, hasEnrollmentSessions));
return response;
}
catch (Exception ex)
@@ -61,13 +80,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>Null when the counter could not be read: the guide then stays hidden rather than guessing.</summary>
private async Task<bool?> HasEnrollmentSessionsAsync(string tenantId)
{
if (KnownEnrolledTenants.ContainsKey(tenantId)) return true;
try
{
var has = await _metricsRepository.TenantHasEnrollmentsAsync(tenantId);
if (has) KnownEnrolledTenants.TryAdd(tenantId, true);
return has;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Feature flags: enrollment history of tenant {TenantId} unknown", tenantId);
return null;
}
}
/// <summary>
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
/// Pulled out as a static method so unit tests can verify field-level mapping
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
/// read-time edition resolution (trial expiry degrades automatically).
/// </summary>
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
internal static TenantFeatureFlagsResponse BuildPayload(
TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false, bool? hasEnrollmentSessions = null)
{
// Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
// managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
@@ -126,6 +163,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
// Same contract for the second half of the contact profile: boolean only.
CompanyNameSet = !string.IsNullOrWhiteSpace(config.CompanyName),
// Setup guide checklist (dashboard card, sidebar entry): done states and the
// dismissal. Booleans only, derived from the row and the enrollment counter.
SetupGuideSettingsDone = SetupGuideState.SettingsDone(config),
SetupGuideHardwareDone = SetupGuideState.HardwareDone(config),
SetupGuideDismissed = config.SetupGuideDismissedAt != null,
HasEnrollmentSessions = hasEnrollmentSessions,
Entitlements = new TenantFeatureEntitlements
{
RetentionCapDays = entitlements.RetentionCapDays,
@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -88,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
request.Reason,
callerTier);
StampUnchangedFields(req, request.Fields.Keys, outcome);
return await WriteOutcome(req, outcome);
}
catch (Exception ex)
@@ -96,6 +100,26 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>
/// Counts the sent fields the stored row already held onto the request row. The portal sends
/// only what its user changed, so such a field means the page showed an outdated configuration.
/// </summary>
private static void StampUnchangedFields(HttpRequestData req, IEnumerable<string> sentFields, PatchOutcome outcome)
{
var unchanged = UnchangedFieldCount(sentFields, outcome);
if (unchanged > 0)
RequestRowMarkers.Stamp(req, RequestRowMarkers.ConfigPatchUnchangedKey, unchanged.ToString(CultureInfo.InvariantCulture));
}
/// <summary>Sent fields (wire names) a successful patch did not change; zero for a failed patch.</summary>
internal static int UnchangedFieldCount(IEnumerable<string> sentFields, PatchOutcome outcome)
{
if (!outcome.Success)
return 0;
var applied = new HashSet<string>(outcome.AppliedFields, StringComparer.OrdinalIgnoreCase);
return sentFields.Count(field => !applied.Contains(field));
}
/// <summary>
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
/// X-Client-Source: mcp) or the plain API.
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
return true;
}, "plan", "plan tier change");
if (await UpdateFailureAsync(req, update) is { } failure)
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
return failure;
var config = update.Config!;
@@ -406,28 +406,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
/// <summary>
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
/// </summary>
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
{
switch (update.Status)
{
case TenantConfigUpdateStatus.NotFound:
return await req.NotFoundAsync("Tenant not found");
case TenantConfigUpdateStatus.OffboardingInProgress:
// The answer the suspension gate gives once its cache has caught up with the tombstone.
return await req.ErrorAsync(HttpStatusCode.Forbidden,
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
case TenantConfigUpdateStatus.Conflict:
return await req.ErrorAsync(HttpStatusCode.Conflict,
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
default:
return null;
}
}
/// <summary>
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
@@ -511,7 +489,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
return true;
}, "plan", "self-service trial start");
if (await UpdateFailureAsync(req, update) is { } failure)
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
return failure;
if (deny is { } refused)
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Config
{
/// <summary>
/// The setup guide's markers on the tenant row. The guide applies settings through the field
/// PATCH like any settings card; this endpoint only stamps what the PATCH cannot: that an admin
/// finished a step, or hid the guide for the whole tenant. Server time and the caller's UPN.
/// </summary>
public class SetupGuideFunction
{
internal const string StepSettings = "settings";
internal const string StepHardware = "hardware";
internal const string StepDismiss = "dismiss";
internal const string StepRestore = "restore";
private readonly ILogger<SetupGuideFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
public SetupGuideFunction(
ILogger<SetupGuideFunction> logger,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
}
/// <summary>
/// POST /api/config/{tenantId}/setup-guide — TenantAdminOrGA (catalog-enforced), refused in
/// view-as. Body <c>{ "step": "settings" | "hardware" | "dismiss" | "restore" }</c>. A step that
/// is already recorded writes nothing and answers with the current markers.
/// </summary>
[Function("RecordSetupGuideStep")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/setup-guide")] HttpRequestData req,
string tenantId)
{
try
{
var requestCtx = req.GetRequestContext();
var read = await req.ReadAsync<SetupGuideStepRequest>();
if (read.Error != null) return read.Error;
var step = NormalizeStep(read.Value!.Step);
if (step == null)
return await req.BadRequestAsync("step must be one of: settings, hardware, dismiss, restore.");
var caller = requestCtx.UserPrincipalName ?? "Unknown";
var nowUtc = DateTime.UtcNow;
var update = await _configService.UpdateAsync(
requestCtx.TargetTenantId,
row => ApplyStep(row, step, caller, nowUtc),
"setup-guide",
$"setup guide: {step}");
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
return failure;
var config = update.Config!;
if (update.Status == TenantConfigUpdateStatus.Updated)
{
await _maintenanceRepo.LogAuditEntryAsync(
requestCtx.TargetTenantId,
"UPDATE",
"SetupGuide",
requestCtx.TargetTenantId,
caller,
new Dictionary<string, string> { { "Step", step } });
}
return await req.OkAsync(new SetupGuideStateResponse
{
Success = true,
SettingsReviewedAt = config.SetupGuideSettingsReviewedAt,
HardwareReviewedAt = config.SetupGuideHardwareReviewedAt,
DismissedAt = config.SetupGuideDismissedAt,
DismissedBy = config.SetupGuideDismissedBy,
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "RecordSetupGuideStep");
}
}
/// <summary>The known step in lower case, or null.</summary>
internal static string? NormalizeStep(string? step)
{
var value = step?.Trim().ToLowerInvariant();
return value is StepSettings or StepHardware or StepDismiss or StepRestore ? value : null;
}
/// <summary>
/// Stamps <paramref name="step"/> on the fresh row inside the conditional write. False (nothing to
/// write) when the step is already recorded — the first completion stays the one on record.
/// </summary>
internal static bool ApplyStep(TenantConfiguration row, string step, string caller, DateTime nowUtc)
{
switch (step)
{
case StepSettings:
if (row.SetupGuideSettingsReviewedAt != null) return false;
row.SetupGuideSettingsReviewedAt = nowUtc;
break;
case StepHardware:
if (row.SetupGuideHardwareReviewedAt != null) return false;
row.SetupGuideHardwareReviewedAt = nowUtc;
break;
case StepDismiss:
if (row.SetupGuideDismissedAt != null) return false;
row.SetupGuideDismissedAt = nowUtc;
row.SetupGuideDismissedBy = caller;
break;
case StepRestore:
if (row.SetupGuideDismissedAt == null) return false;
row.SetupGuideDismissedAt = null;
row.SetupGuideDismissedBy = null;
break;
default:
return false;
}
row.UpdatedBy = caller;
return true;
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
@@ -77,18 +78,22 @@ public class GetScriptDisplayNamesFunction
var distinctRefs = parsedRefs.Distinct().ToList();
var resolved = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var lookup = await _resolver.ResolveAsync(requestCtx.TargetTenantId, distinctRefs, default);
var payload = new Dictionary<string, string?>(distinctRefs.Count);
foreach (var r in distinctRefs)
{
payload[r.ToString()] = resolved.TryGetValue(r, out var name) ? name : null;
payload[r.ToString()] = lookup.Names.TryGetValue(r, out var name) ? name : null;
}
var marker = RequestRowMarkers.ScriptNameLookup.For(lookup.TimedOut, isRetry: body.Retry == true);
if (marker != null) RequestRowMarkers.Stamp(req, RequestRowMarkers.ScriptNameLookupKey, marker);
return await req.OkAsync(new GetScriptDisplayNamesResponse
{
Refs = payload,
Malformed = malformed.Count > 0 ? malformed : null,
TimedOut = lookup.TimedOut ? true : null,
});
}
catch (Exception ex)
@@ -890,6 +890,12 @@ public class AuthFunction
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
// Navbar label, for the callers the config GET serves too (a tenant role, or a Global
// Admin); a roleless member only gets the Progress Portal. In view-as tenantConfig is
// the viewed tenant's row and the role the emulated one.
CompanyName = (role != null || isGlobalAdmin) && !string.IsNullOrWhiteSpace(tenantConfig.CompanyName)
? tenantConfig.CompanyName.Trim()
: null,
ViewAs = viewAs
}, needsAutoAdmin);
}
@@ -148,8 +148,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
if (!rateLimitResult.IsAllowed)
return req.CreateResponse(HttpStatusCode.OK);
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
// Gate 5: Tenant existence check (cached — cheap O(1) lookup). The config also decides
// the effective error type below.
var (tenantConfig, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
if (!exists)
return req.CreateResponse(HttpStatusCode.OK);
@@ -223,11 +224,14 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
var errorType = ResolveErrorType(
report.ErrorType, report.HttpStatusCode, manufacturer, model, report.CertSourceState, tenantConfig);
// Persist to Table Storage
var entry = new DistressReportEntry
{
TenantId = tenantId,
ErrorType = report.ErrorType.ToString(),
ErrorType = errorType.ToString(),
Manufacturer = manufacturer,
Model = model,
SerialNumber = serialNumber,
@@ -256,28 +260,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Notify tenant channels for hardware whitelist rejections (opt-in). Durable via
// the notification-dispatch queue — a fire-and-forget send after the response was
// not counted as open work by the host and could be dropped on scale-in.
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
if (errorType == DistressErrorType.HardwareNotAllowed
&& _hardwareThrottle.ShouldNotify(tenantId, manufacturer, model))
{
try
{
var (config, configExists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
if (configExists)
var hwChannels = tenantConfig.GetNotificationChannels()
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
.ToList();
if (hwChannels.Count > 0)
{
var hwChannels = config!.GetNotificationChannels()
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
.ToList();
if (hwChannels.Count > 0)
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
{
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
{
TenantId = tenantId,
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
Alert = alert,
EnqueuedAt = DateTime.UtcNow,
});
}
TenantId = tenantId,
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
Alert = alert,
EnqueuedAt = DateTime.UtcNow,
});
}
}
catch (Exception notifyEx)
@@ -289,7 +289,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// In-app bell notification on first-ever hardware rejection per (tenant, manufacturer, model).
// Lifetime dedup via tracker table — independent of the webhook 24h in-memory throttle so
// tenant admins see a one-shot persistent notification even if the webhook is not configured.
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
if (errorType == DistressErrorType.HardwareNotAllowed
&& !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(model))
{
try
@@ -318,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// No webhook — volume is tiny and the remediation is not time-critical. Reports
// without a serial number cannot be deduped and fire no bell; they still appear
// in the panel.
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
if (errorType == DistressErrorType.TpmPssUnsupported
&& !string.IsNullOrEmpty(serialNumber))
{
try
@@ -343,8 +343,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
"AgentDistress [{ErrorType}] reported={ReportedErrorType} tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
errorType, report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
@@ -354,7 +354,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_telemetryClient.TrackEvent("AgentDistressReport", new Dictionary<string, string>
{
["TenantId"] = tenantId,
["ErrorType"] = report.ErrorType.ToString(),
["ErrorType"] = errorType.ToString(),
["ReportedErrorType"] = report.ErrorType.ToString(),
["Manufacturer"] = manufacturer ?? string.Empty,
["Model"] = model ?? string.Empty,
["SerialNumber"] = serialNumber ?? string.Empty,
@@ -387,6 +388,31 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
}
/// <summary>
/// The error type to store and act on. The agent reports every backend 403 as
/// <see cref="DistressErrorType.DeviceNotRegistered"/>, but <c>SecurityValidator</c> checks the
/// hardware whitelist before it looks up the device's registration: a 403 for hardware
/// outside the whitelist is a hardware rejection, and only as one does it reach the bell,
/// the webhook and the rejected-hardware list. The conditions follow the validator's order:
/// a suspended tenant, a tenant without any validation and bootstrap-token auth (no
/// certificate) are refused or let through before the hardware check, so their 403 stays as
/// reported. If that order changes, this has to change with it.
/// </summary>
internal static DistressErrorType ResolveErrorType(
DistressErrorType reported, int? httpStatusCode, string? manufacturer, string? model,
DistressCertSourceState? certSourceState, TenantConfiguration config)
{
if (reported != DistressErrorType.DeviceNotRegistered || httpStatusCode != 403) return reported;
if (string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(model)) return reported;
if (config.IsCurrentlyDisabled()) return reported;
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests) return reported;
if (certSourceState == DistressCertSourceState.NoCertInStore) return reported;
var hardware = HardwareWhitelistValidator.ValidateHardware(
manufacturer, model, config.GetManufacturerWhitelist(), config.GetModelWhitelist());
return hardware.IsValid ? reported : DistressErrorType.HardwareNotAllowed;
}
/// <summary>
/// Validates that a distress report timestamp is within acceptable range.
/// Rejects timestamps older than 24 hours or more than 5 minutes in the future.
@@ -13,9 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <list type="bullet">
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
/// renamed Intune script eventually surfaces under its new name.</item>
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
/// once the cache is gone the resolver will lazily re-check.</item>
/// <item>Negative (NotFound) rows live just as long (<see cref="NotFoundTtl"/>): they
/// suppress repeated 404 fetches for an ID Graph does not know. A list pull that
/// contains the ID after all overwrites the row.</item>
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
/// </list>
@@ -27,8 +27,11 @@ public class ScriptNameCacheCleanupFunction
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
/// <summary>
/// Same lifetime as a name. A shorter one asks Graph again for every unknown ID on the
/// first view after each cleanup run.
/// </summary>
public static readonly TimeSpan NotFoundTtl = PositiveTtl;
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
private const string Cron = "0 0 3 * * *";
@@ -2,6 +2,7 @@ using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -10,7 +11,7 @@ namespace AutopilotMonitor.Functions.Functions.Push;
/// <summary>
/// Device management from the portal: list (own devices for everyone on the tier, every device
/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes
/// of the scope for its admins), rename, remove (wipe push, then the row is gone) and test. Tenant routes
/// under <c>push/devices…</c> (TenantAdminOrOperator), platform routes under
/// <c>global/push/devices…</c> (GlobalAdminOnly).
/// </summary>
@@ -36,6 +37,12 @@ public class PushDevicesFunctions
string deviceId)
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
[Function("RenamePushDevice")]
public Task<HttpResponseData> Rename(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "push/devices/{deviceId}")] HttpRequestData req,
string deviceId)
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
[Function("TestPushDevice")]
public Task<HttpResponseData> Test(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req,
@@ -53,6 +60,12 @@ public class PushDevicesFunctions
string deviceId)
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
[Function("RenamePlatformPushDevice")]
public Task<HttpResponseData> RenamePlatform(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/push/devices/{deviceId}")] HttpRequestData req,
string deviceId)
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
[Function("TestPlatformPushDevice")]
public Task<HttpResponseData> TestPlatform(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req,
@@ -88,6 +101,25 @@ public class PushDevicesFunctions
}
}
private async Task<HttpResponseData> RenameCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
{
try
{
if (!PushFunctionSupport.IsPlausibleId(deviceId))
return await req.NotFoundAsync("Device not found.");
var read = await req.ReadAsync<RenamePushDeviceRequest>(PushFunctionSupport.LabelBodyMaxBytes);
if (read.Error != null) return read.Error;
var error = await _pairing.RenameAsync(ctx.Scope, deviceId, read.Value!.Label, ctx.Caller);
if (error != PushOpError.None)
return await PushFunctionSupport.ErrorAsync(req, error, error == PushOpError.NotFound ? "Device not found." : null);
return req.CreateResponse(HttpStatusCode.NoContent);
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "RenamePushDevice");
}
}
private async Task<HttpResponseData> TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
{
try
@@ -15,6 +15,9 @@ namespace AutopilotMonitor.Functions.Functions.Push;
/// </summary>
internal static class PushFunctionSupport
{
/// <summary>Body cap of the portal routes that carry only a device label.</summary>
public const long LabelBodyMaxBytes = 1024;
/// <summary>
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
@@ -57,6 +60,7 @@ internal static class PushFunctionSupport
PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable,
"The push channel is not configured on the platform."),
PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."),
PushOpError.InvalidLabel => req.BadRequestAsync(message ?? "Enter a device name."),
_ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
new InvalidOperationException("unmapped push error"), "Push"),
};
@@ -2,6 +2,7 @@ using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -114,7 +115,10 @@ public class PushPairingFunctions
{
if (!PushFunctionSupport.IsPlausibleId(pairingId))
return await req.NotFoundAsync("Pairing not found.");
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller);
// The body is optional: without one the device keeps the name it sent.
var read = await req.ReadOptionalAsync<ConfirmPairingRequest>(PushFunctionSupport.LabelBodyMaxBytes);
if (read.Error != null) return read.Error;
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller, read.Value?.Label);
return result.Ok
? await req.OkAsync(result.Value!)
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found.");
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{
string tenantId = TenantHelper.GetTenantId(req);
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.DeviceNotRegistered, maxResults: 200);
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
return await req.OkAsync(new DeviceNotRegisteredResponse
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{
string tenantId = TenantHelper.GetTenantId(req);
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.HardwareNotAllowed, maxResults: 200);
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
return await req.OkAsync(new HardwareRejectedResponse
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{
string tenantId = TenantHelper.GetTenantId(req);
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
var reports = await _repository.GetDistressReportsAsync(tenantId, DistressErrorType.TpmPssUnsupported, maxResults: 200);
var tpmReports = reports.Where(IsTpmPssReport).ToList();
IReadOnlyDictionary<string, DateTime>? lastCertificateIngest = null;
@@ -434,10 +434,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
RegisteredAt = DateTime.UtcNow
});
/// <summary>
/// 410 Gone response for the cascade-delete guard. Mirrors the V2 ingest 410 contract so
/// the agent treats register and telemetry uniformly when a session is being deleted.
/// </summary>
/// <summary>
/// Clamps <see cref="SessionRegistration.StartedAt"/> to the same plausibility window
/// events get (<see cref="EventTimestampValidator.SanitizeTimestamp"/>): outside
@@ -492,6 +488,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
});
/// <summary>
/// 410 Gone for the cascade-delete guard: a cascade owns the session, or its tombstone is
/// still fresh. Same status as the telemetry ingest. The agent ends monitoring on it and
/// removes itself; a WhiteGlove Part 2 start registers under a new session id instead.
/// </summary>
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
{

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